-
Notifications
You must be signed in to change notification settings - Fork 550
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Keycloak component #4289
Add Keycloak component #4289
Conversation
src/Aspire.Hosting.Keycloak/KeycloakResouceBuilderExtensions.cs
Outdated
Show resolved
Hide resolved
Given no client and your note about the base address -- I suspect you'd rely on folks using service discovery based on the name then... as Some other questions I have as a noob to Keycloak...
|
@timheuer Yes, all this does is enable service discovery so that services can set the base address of the Authority parameter when configuring OIDC. Here's a simplified example of how I'm using it in the app I'm working on: AppHostvar builder = DistributedApplication.CreateBuilder(args);
var keycloak = builder.AddKeycloak("keycloak")
.WithDataVolume();
var api = builder.AddProject<Projects.GameStore_Api>("gamestore-api")
.WithReference(keycloak);
builder.AddProject<Projects.GameStore_Frontend>("gamestore-frontend")
.WithReference(api)
.WithReference(keycloak);
builder.Build().Run(); Blazor Appbuilder.Services.AddHttpClient("OpenIdConnectBackchannel", o => o.BaseAddress = new("http://keycloak"));
builder.Services
.AddAuthentication(Schemes.Keycloak)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(Schemes.Keycloak, options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.SignOutScheme = Schemes.Keycloak;
options.ResponseType = OpenIdConnectResponseType.Code;
options.UsePkce = true;
options.SaveTokens = true;
options.MapInboundClaims = false;
options.Scope.Add("games:all");
options.Scope.Add(OpenIdConnectScope.OfflineAccess);
options.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Name;
});
builder.Services
.AddOptions<OpenIdConnectOptions>(Schemes.Keycloak)
.Configure<IConfiguration, IHttpClientFactory, IHostEnvironment>((options, configuration, httpClientFactory, hostEnvironment) =>
{
var realm = configuration["Keycloak:Realm"] ?? throw new InvalidOperationException("Realm is not set");
var clientId = configuration["Keycloak:ClientId"] ?? throw new InvalidOperationException("ClientId is not set");
var backchannelHttpClient = httpClientFactory.CreateClient("OpenIdConnectBackchannel");
options.Backchannel = backchannelHttpClient;
options.Authority = $"{backchannelHttpClient.BaseAddress}/realms/{realm}";
options.ClientId = clientId;
options.RequireHttpsMetadata = !hostEnvironment.IsDevelopment();
}); The whole backchannel deal is weird, but seems to be the only way to do this since setting the Authority like this would not resolve the real keycloak address (which is sad): options.Authority = $"http://keycloak/realms/{realm}"; I learned about that approach in this eShop workshop. Regarding the config and Production, I honestly don't know. All I know is how to use Keycloak for my dev environment, but I have no intention to take it to Prod nor I have any idea how to do so. Although there are some pointers here. I just started using Keycloak a few days ago and, since I got it working with Aspire, I thought I would contribute what I got working. |
We need tests and a playground project for this. |
src/Aspire.Hosting.Keycloak/KeycloakResouceBuilderExtensions.cs
Outdated
Show resolved
Hide resolved
var myService = builder.AddProject<Projects.MyService>() | ||
.WithReference(keycloak); | ||
``` | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be good to add an example here of what code is required in the consuming ASP.NET Core project resource to configure the OIDC authentication handler to work with the injected Keycloak URL via service discovery.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Example added to the Keycloak component.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should have it in both READMEs, so people can connect the 2 packages (one for the AppHost and one for their projects). Or at least point to which NuGet package should be used in the apps here.
I think this is enough for an MVP, assuming the @julioct can you add a playground project and tests for this in this PR too please? |
@DamianEdwards Yep, |
|
@@ -0,0 +1,22 @@ | |||
<Project Sdk="Microsoft.NET.Sdk"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should consider naming this something more specific so that it's clear it's not a client for the Keycloak admin API, but rather a package for easily configuring an ASP.NET Core app to use Keycloak as an IDP. Perhaps Aspire.Keycloak.Authentication
?
@eerhardt thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed that Aspire.Keycloak
is a pretty broad/general name. I'm not sure I have a great suggestion. But Aspire.Keycloak.Authentication
seems better.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@julioct - thoughts on the naming here?
var backchannelHttpClient = httpClientFactory.CreateClient(KeycloakBackchannel); | ||
|
||
options.Backchannel = backchannelHttpClient; | ||
options.Authority = $"{backchannelHttpClient.BaseAddress}/realms/{settings.Realm}"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this flow from the AppHost or is the user required to set the realm in the consuming app manually? I'd expect the realm to be pre-appended by the WithReference
call in the AppHost project, e.g. .WithReference(keycloak, "myrealm")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Damian, would be great to have the realm configured in the AppHost. But what's the exact expectation here?
Like this?
var keycloak = builder.AddKeycloak("keycloak", realm: "myrealm" );
Or like this?
var keycloak = builder.AddKeycloak("keycloak")
.WithRealm("myrealm");
Or like this?
var apiService = builder.AddProject<Projects.Keycloak_ApiService>("apiservice")
.WithReference(keycloak, "myrealm");
Happy to implement it either way, but could you please point me to an example I can follow of the right way to do it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is Realm required (meaning is there a default if none provided)? If not, then other code building up the URI is going to eventually fail as you set the Authority, correct?
Realm to me kinda feels like a database pattern to me like you add the resource, then the realm (AddPostgres().AddDatabase)) - I kinda think this would be AddKeyCloak("keycloak").AddRealm("myrealm")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking the last example .WithReference(keycloak, "myrealm")
, rather than promote realms to child resources of their own, although I see the similarity to databases vs. database servers. Interested in others' thoughts @davidfowl @mitchdenny @eerhardt
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@DamianEdwards @timheuer Two comments on this one:
-
Is adding the realm on the host what we really want? The realm is like an AAD tenant and that is something that you usually configure on your consuming projects. Feels a bit too magical for client projects to have no idea what realm they are working with.
-
Assuming we set the realm on the host, what's the expectation of
WithReference(keycloak, "myrealm")
? I don't know of a way to construct a discoverable endpoint like http://localhost:1234/realms/WeatherShop directly in the host side. But I could set an environment variable that the client component will know about and read to build the endpoint. Would that work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feels a bit too magical for client projects to have no idea what realm they are working with.
The realm has to exist on the Keycloak server though. Client projects get the database details they're connecting to as well so not sure this is much different.
But I could set an environment variable that the client component will know about and read to build the endpoint. Would that work?
Yep that's what I was thinking. This again is very similar to what happens when you call AddDatabase("somename")
. The database isn't automatically created, but it gets added to the connection information for any client project calling WithReference
on it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added the WithReference overload. In the host you can do this now:
builder.AddProject<Projects.Keycloak_ApiService>("apiservice")
.WithReference(keycloak, realm: "WeatherShop");
And client projects can do just this, since host provides everything:
builder.AddKeycloakJwtBearer();
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now client projects need to pass in the connection name like this:
builder.AddKeycloakJwtBearer("keycloak");
Or this:
builder.AddKeycloakOpenIdConnect("keycloak");
src/Aspire.Hosting.Keycloak/KeycloakResouceBuilderExtensions.cs
Outdated
Show resolved
Hide resolved
cc @NikiforovAll are you around to loan any keycloak expertise? How does this gel with https://nikiforovall.github.io/dotnet/keycloak/2024/06/02/aspire-support-for-keycloak.html and your other keycloak APIs? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would like to suggest producing configuration compatible with https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/security?view=aspnetcore-8.0#configuring-authentication-strategy, this way you don't really need to define Keycloak-specific JWT Bearer scheme
ArgumentNullException.ThrowIfNull(builder); | ||
ArgumentNullException.ThrowIfNull(keycloakBuilder); | ||
|
||
builder.WithReference(keycloakBuilder) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Multiple realm registrations are possible. In this case, we override and use the latest one. Is it intended behavior?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea, multiple realms are not supported with the proposed implementation.
From a developer perspective, it looks good to me. 👍 It's unfortunate that this serves as an alternative to my project 🥲, Keycloak.AuthServices, there's so much more to it than just OIDC/JwtBearer integration. It would be worthwhile to explore how to glue them together. Although it's possible to do it, I suspect that users/developers might opt for the easier route of using just the Aspire component, given its similar functionality. I have a few ideas/comments for the proposed solution in the PR:
var builder = DistributedApplication.CreateBuilder(args);
var keycloak = builder.AddKeycloak("keycloak").WithDataVolume();
var jwtBearer = builder.AddJwtBearer("JwtBearer")
.WithReference(new KeycloakRealmJwtBearerAuthentication(realm: "MyRealm", keycloakInstance: keycloak));
var api = builder.AddProject<Projects.GameStore_Api>("gamestore-api")
.WithReference(jwtBearer);
builder.Build().Run(); |
I would defer this until we have multiple auth providers. I'd rather us build the keycloak implementation and another one and then determine if we can extract the common feature set. |
@NikiforovAll Great feedback, thank you! Keycloak.AuthServices is a great implementation that I keep coming back to learn more about what's possible with Keycloak.
I think one way to enable this would be by bringing in the realm as a proper resource, so we can do stuff like this: var keycloak = builder.AddKeycloak("keycloak");
var realm1 = keycloak.AddRealm("realm1");
var realm2 = keycloak.AddRealm("realm2");
var apiService = builder.AddProject<Projects.Keycloak_ApiService>("apiservice")
.WithReference(realm1)
.WithReference(realm2); And the Aspire__Keycloak__Realms__realm1 = "realm1" Which looks weird, plus I'm unsure what will happen when Then, client projects would use the component like this: builder.AddKeycloakJwtBearer(
connectionName: "keycloak",
realm: "realm1",
configureJwtBearerOptions: options =>
{
options.Audience = "weather.api";
}); @NikiforovAll I could not find a multi realm approach in Keycloak.AuthServices.Aspire.Hosting @DamianEdwards @davidfowl Is this something we want to support? If so, I'm sure there's a better approach ?
@NikiforovAll Could you elaborate more on this idea? |
Regarding multi realms, perhaps better to encode the values as multi-value, e.g.: Aspire__Keycloak__Realms__0 = "realm1" And then update the Another approach would be to not have realms in the AppHost at all and just require it in the |
Add port recommendation
…ak libraries This allows us to ship these libraries as preview even when the rest of the Aspire libraries ship stable. Will remove and ship stable once the libraries have had time to get feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As we are planning on shipping 8.1 in the coming days, do we think this is in a state where it can be merged and shipped as "preview" for 8.1? I think it would be a really good enhancemnt, and would allow us to get feedback on the functionality.
@mitchdenny - thoughts on merging this for 8.1?
@@ -0,0 +1,16 @@ | |||
<Project Sdk="Microsoft.NET.Sdk"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there some sort of "functional test" that can be written? One that:
- Starts up the Keycloak container
- Makes requests to it like an app would
- Verifies that the requests return successful results
I know it isn't apples-to-apples, but here's that kind of test with a Redis container:
aspire/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs
Lines 18 to 52 in cd6f024
[Fact] | |
[RequiresDocker] | |
public async Task VerifyRedisResource() | |
{ | |
var builder = CreateDistributedApplicationBuilder(); | |
var redis = builder.AddRedis("redis"); | |
using var app = builder.Build(); | |
await app.StartAsync(); | |
var hb = Host.CreateApplicationBuilder(); | |
hb.Configuration.AddInMemoryCollection(new Dictionary<string, string?> | |
{ | |
[$"ConnectionStrings:{redis.Resource.Name}"] = await redis.Resource.GetConnectionStringAsync() | |
}); | |
hb.AddRedisClient(redis.Resource.Name); | |
using var host = hb.Build(); | |
await host.StartAsync(); | |
var redisClient = host.Services.GetRequiredService<IConnectionMultiplexer>(); | |
var db = redisClient.GetDatabase(); | |
await db.StringSetAsync("key", "value"); | |
var value = await db.StringGetAsync("key"); | |
Assert.Equal("value", value); | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there some sort of "functional test" that can be written? One that:
- Starts up the Keycloak container
- Makes requests to it like an app would
- Verifies that the requests return successful results
I know it isn't apples-to-apples, but here's that kind of test with a Redis container:
aspire/tests/Aspire.Hosting.Redis.Tests/RedisFunctionalTests.cs
Lines 18 to 52 in cd6f024
[Fact] [RequiresDocker] public async Task VerifyRedisResource() { var builder = CreateDistributedApplicationBuilder(); var redis = builder.AddRedis("redis"); using var app = builder.Build(); await app.StartAsync(); var hb = Host.CreateApplicationBuilder(); hb.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { [$"ConnectionStrings:{redis.Resource.Name}"] = await redis.Resource.GetConnectionStringAsync() }); hb.AddRedisClient(redis.Resource.Name); using var host = hb.Build(); await host.StartAsync(); var redisClient = host.Services.GetRequiredService<IConnectionMultiplexer>(); var db = redisClient.GetDatabase(); await db.StringSetAsync("key", "value"); var value = await db.StringGetAsync("key"); Assert.Equal("value", value); }
I guess a "functional" test for the Keycloak component would involve checking that one of the claims of the authenticated user can be retrieved from the UserPrincipal object, either from the minimal API side or the Blazor app side.
In either case, the user has to first authenticate on Keycloak sign-in screen to complete the OIDC flow, which I'm not sure how to automate for the test.
Likely there's a way, but I can't allocate time right now to dive into it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From my standpoint, this looks good enough to get a first preview version out.
@mitchdenny - any concerns with merging this for 8.1?
@eerhardt @julioct happy for this to go in. My perspective is that we should aim to ship this as a preview package for the 8.1 iteration and collect feedback that might result in some API changes. For example, I think the I'm going to kick the build just to double check everything is stable against main and then merge. |
/azp run |
Azure Pipelines successfully started running 1 pipeline(s). |
@julioct thanks for contributing this to the .NET Aspire project! Going through the review process to add not only hosting APIs but also component APIs is a big effort and we appreciate it. Our plan is to ship this package as a preview in .NET Aspire 8.1 with a view for marking the package as stable in .NET Aspire 8.2. We'll mention this new resource / component in the upcoming .NET Aspire 8.1 blog post, are you happy for us to link to your GitHub profile and give you a shout out? |
Wow, that's awesome, thank you @mitchdenny @eerhardt and team! Not looking for fame, but nothing wrong with the shout out. Eagerly waiting for the 8.1 release! |
"type" : "password", | ||
"userLabel" : "My password", | ||
"createdDate" : 1719876373873, | ||
"secretData" : "{\"value\":\"4DfZJfojd4Ef08uoboJ2V+RMpF20yGEbw2pDouefjmcTmAVxE6wPkbLN03u/bAElCm4Y/ndJ9YruH3Q5pFuSLQ==\",\"salt\":\"E8I7GRLxZI2lzpKCI6h/bw==\",\"additionalParameters\":{}}", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@julioct - is this secret necessary? Can it be removed? I assume/hope it isn't actually a leaked credential that works for something real.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is likely the password of the testuser, prepared for the Playground example.
We can remove the user from there, but then when running the playground you will first have to register a new user on Keycloak before testing the example.
Would that be OK?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can use environment variables in the imported realm configuration file: Using Environment Variables within the Realm Configuration Files
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @julioct. I've merged that change and will backport to release/8.1 to fix our builds.
Contributes to #1326
Microsoft Reviewers: Open in CodeFlow