Add Keycloak component
- Adds the Keycloak resource and extension methods.
- There is no client, since all services need is to discover the Keycloak server base address to configure the JWT/OpenID Authority.
Microsoft Reviewers: Open in CodeFlow
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 AddKeycloak("foo") would then be an env var of services__foo__http__0 and you're expecting them to have the authority set as https+http://foo right?
Some other questions I have as a noob to Keycloak...
- Config: looks like environment vars are supported for most config, but also a .conf -- might consider adding a bind mount for conf/keycloak.conf (or the correct means as it looks like config file is not an environment config that could read)
- Production DB: Might be good to see how this all looks with the preferred documentation way of using Postgres as the prod DB for non-dev mode
@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:
AppHost
var 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 App
builder.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. We also need to discuss this is a good MVP for keycloak. Seems like we should expose a helper for importing realms as well https://github.com/dotnet-presentations/eshop-app-workshop/blob/10390e5a0a4778d622c218f72578ddc1283ce562/src/eShop.AppHost/KeycloakResource.cs#L51
We need tests and a playground project for this. We also need to discuss this is a good MVP for keycloak. Seems like we should expose a helper for importing realms as well
I think this is enough for an MVP, assuming the WithDataVolume method works to persist all data between container instances.
@julioct can you add a playground project and tests for this in this PR too please?
We need tests and a playground project for this. We also need to discuss this is a good MVP for keycloak. Seems like we should expose a helper for importing realms as well
I think this is enough for an MVP, assuming the
WithDataVolumemethod works to persist all data between container instances.@julioct can you add a playground project and tests for this in this PR too please?
@DamianEdwards Yep, WithDataVolume persists all data between container instances.
Working on a playground project, and a few more things...
- Added a new Keycloak component to simplify the JWT bearer and OIDC authentication configuration for consuming projects
- Added a playground
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?
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?
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:
- Multiple realm registrations: I think we should consider the "multiple realms registration" scenario. Depending on the use case user might want to:
| Scenario | Comments |
|---|---|
| Configure "OIDC/JWT" | "MyRealm" realm |
| Configure "Admin API" integration | "Master" realm |
| Configure "Protection API" integration | "MyRealm" realm |
-
Make use of Authentication Strategy: It might be a good idea to contribute directly to
Authentication.Schemes.{Bearer|OpenIdConnect}configuration as a result of referencing the component, ref: "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/security?view=aspnetcore-8.0#configuring-authentication-strategy". Although, it might impact how theAppHostis built -
Unified auth component: I think @DamianEdwards mentioned the idea of a unified "Authentication" component, it might be more flexible and clear to add it using a separate component that has various adapters. Something like this:
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();
Unified auth component: I think @DamianEdwards mentioned the idea of a unified "Authentication" component, it might be more flexible and clear to add it using a separate component that has various adapters. Something like this:
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.
- Multiple realm registrations: I think we should consider the "multiple realms registration" scenario. Depending on the use case user might want to:
Scenario Comments Configure "OIDC/JWT" "MyRealm" realm Configure "Admin API" integration "Master" realm Configure "Protection API" integration "MyRealm" realm
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 WithReference overload now could setup env vars like these in the consuming projects:
Aspire__Keycloak__Realms__realm1 = "realm1" Aspire__Keycloak__Realms__realm2 = "realm2"
Which looks weird, plus I'm unsure what will happen when WithReference references the Keycloak resource multiple times for each realm.
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 . Maybe I missed something?
@DamianEdwards @davidfowl Is this something we want to support? If so, I'm sure there's a better approach ?
- Make use of Authentication Strategy: It might be a good idea to contribute directly to
Authentication.Schemes.{Bearer|OpenIdConnect}configuration as a result of referencing the component, ref: "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/security?view=aspnetcore-8.0#configuring-authentication-strategy". Although, it might impact how theAppHostis built
@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" Aspire__Keycloak__Realms__0 = "realm2"
And then update the builder.AddKeycloakJwtBearer to use the first configured realm if a specific realm is not passed.
Another approach would be to not have realms in the AppHost at all and just require it in the builder.AddKeycloakJwtBearer call, e.g. builder.AddKeycloakJwtBearer("keycloak", "myrealm"). We could revisit realms later on if we want to tackle actually creating realms as part of the resource creation.
Another approach would be to not have realms in the AppHost at all and just require it in the
builder.AddKeycloakJwtBearercall, e.g.builder.AddKeycloakJwtBearer("keycloak", "myrealm"). We could revisit realms later on if we want to tackle actually creating realms as part of the resource creation.
I like this idea. Feels cleaner and more extensible, as you mention. Should be good enough for an MVP.
Should we move forward with this change?
Aspire__Keycloak__Realms__0 = "realm1" Aspire__Keycloak__Realms__1 = "realm2"
@julioct Can you summarize the API being added here?
@julioct Can you summarize the API being added here?
Yes:
Aspire.Hosting.Keycloak
/// <summary>
/// Provides extension methods for adding Keycloak resources to an <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static class KeycloakResouceBuilderExtensions
{
/// <summary>
/// Adds a Keycloak container to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource. </param>
/// <param name="port">The host port that the underlying container is bound to when running locally.</param>
/// <param name="admin">The parameter used as the admin for the Keycloak resource. If <see langword="null"/> a default value will be used.</param>
/// <param name="adminPassword">The parameter used as the admin password for the Keycloak resource. If <see langword="null"/> a default password will be used.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<KeycloakResource> AddKeycloak(
this IDistributedApplicationBuilder builder,
string name,
int? port = null,
IResourceBuilder<ParameterResource>? admin = null,
IResourceBuilder<ParameterResource>? adminPassword = null);
/// <summary>
/// Adds a named volume for the data folder to a Keycloak container resource.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="name">The name of the volume. Defaults to an auto-generated name based on the application and resource names.</param>
/// <param name="isReadOnly">A flag that indicates if this is a read-only volume.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<KeycloakResource> WithDataVolume(this IResourceBuilder<KeycloakResource> builder, string? name = null, bool isReadOnly = false);
/// <summary>
/// Adds a reference to a Keycloak resource to the specified resource builder.
/// </summary>
/// <typeparam name="TResource">The type of the resource builder.</typeparam>
/// <param name="builder">The resource builder to add the reference to.</param>
/// <param name="keycloakBuilder">The Keycloak resource builder to reference.</param>
/// <param name="realm">The realm to reference in the Keycloak server.</param>
/// <returns>The resource builder with the added reference.</returns>
public static IResourceBuilder<TResource> WithReference<TResource>(
this IResourceBuilder<TResource> builder,
IResourceBuilder<KeycloakResource> keycloakBuilder,
string realm
);
}
Aspire.Keycloak
public static class AspireKeycloakExtensions
{
/// <summary>
/// Adds Keycloak JWT Bearer authentication to the application.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder" /> to read config from and add services to.</param>
/// <param name="connectionName">The connection name to use to find a connection string.</param>
/// <param name="authenticationScheme">The authentication scheme name. Default is "Bearer".</param>
/// <param name="configureJwtBearerOptions">An optional action to configure the <see cref="JwtBearerOptions"/>.</param>
/// <param name="configureSettings">An optional action to configure the <see cref="KeycloakSettings"/>.</param>
public static void AddKeycloakJwtBearer(
this IHostApplicationBuilder builder,
string connectionName,
string authenticationScheme = JwtBearerDefaults.AuthenticationScheme,
Action<JwtBearerOptions>? configureJwtBearerOptions = null,
Action<KeycloakSettings>? configureSettings = null);
/// <summary>
/// Adds Keycloak OpenID Connect authentication to the application.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder" /> to read config from and add services to.</param>
/// <param name="connectionName">The connection name to use to find a connection string.</param>
/// <param name="openIdConnectScheme">The OpenID Connect authentication scheme name. Default is "OpenIdConnect".</param>
/// <param name="cookieScheme">The cookie authentication scheme name. Default is "Cookie".</param>
/// <param name="configureOpenIdConnectOptions">An optional action to configure the <see cref="OpenIdConnectOptions"/>.</param>
/// <param name="configureSettings">An optional action to configure the <see cref="KeycloakSettings"/>.</param>
public static void AddKeycloakOpenIdConnect(
this IHostApplicationBuilder builder,
string connectionName,
string openIdConnectScheme = OpenIdConnectDefaults.AuthenticationScheme,
string cookieScheme = CookieAuthenticationDefaults.AuthenticationScheme,
Action<OpenIdConnectOptions>? configureOpenIdConnectOptions = null,
Action<KeycloakSettings>? configureSettings = null);
}
Sample AppHost configuration
var builder = DistributedApplication.CreateBuilder(args);
var keycloak = builder.AddKeycloak("keycloak");
var realm = "WeatherShop";
var apiService = builder.AddProject<Projects.Keycloak_ApiService>("apiservice")
.WithReference(keycloak, realm);
builder.AddProject<Projects.Keycloak_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithReference(keycloak, realm)
.WithReference(apiService);
builder.Build().Run();
Sample backend API configuration
builder.AddKeycloakJwtBearer("keycloak", configureJwtBearerOptions: options =>
{
options.Audience = "weather.api";
});
Sample frontend configuration
builder.AddKeycloakOpenIdConnect("keycloak", configureOpenIdConnectOptions: options =>
{
options.ClientId = "WeatherWeb";
options.ResponseType = OpenIdConnectResponseType.Code;
options.Scope.Add("weather:all");
});
@julioct aren't we removing the realm config from the AppHost project and moving it to the consuming project now? So:
Sample AppHost configuration
var builder = DistributedApplication.CreateBuilder(args);
var keycloak = builder.AddKeycloak("keycloak");
var apiService = builder.AddProject<Projects.Keycloak_ApiService>("apiservice")
.WithReference(keycloak);
builder.AddProject<Projects.Keycloak_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithReference(keycloak)
.WithReference(apiService);
builder.Build().Run();
Sample backend API configuration
builder.AddKeycloakJwtBearer("keycloak", realm: "WeatherShop", configureJwtBearerOptions: options =>
{
options.Audience = "weather.api";
});
Sample frontend configuration
builder.AddKeycloakOpenIdConnect("keycloak", realm: "WeatherShop", configureOpenIdConnectOptions: options =>
{
options.ClientId = "WeatherWeb";
options.ResponseType = OpenIdConnectResponseType.Code;
options.Scope.Add("weather:all");
});
@julioct aren't we removing the realm config from the AppHost project and moving it to the consuming project now?
Done!
I'm wondering if these calls should be top level on IHostApplicationBuilder or if they should be on AuthenticationBuilder?
I'm wondering if these calls should be top level on IHostApplicationBuilder or if they should be on AuthenticationBuilder?
That's a good point, feels more natural to make the calls in AuthenticationBuilder, and looks like it provides everything we need in AddKeycloakJwtBearer and AddKeycloakOpenIdConnect.
Should we make this change?
Should we make this change?
Yes.
Should we make this change?
Yes.
Done!
Here's a small preview of the Aspire + Keycloak integration in action:
https://github.com/dotnet/aspire/assets/2374270/6b19225d-cb9f-4b9e-8c3c-8c385f878833
Nice!
Is there a method for importing a realm configuration?
Nice!
Is there a method for importing a realm configuration?
I think there's a way, but I'm not sure how useful that is beyond a playground/demo scenario.
Likely what folks would eventually want is to be able to create a realm with a bunch of settings as part of their Aspire host configuration, but I'm not sure it is doable.
This is looking really promising! Thank you for all of your hard work @julioct!
@mitchdenny and @eerhardt @sebastienros can you review please?
Nice! Is there a method for importing a realm configuration?
I think there's a way, but I'm not sure how useful that is beyond a playground/demo scenario.
Likely what folks would eventually want is to be able to create a realm with a bunch of settings as part of their Aspire host configuration, but I'm not sure it is doable.
You can mount initialization files or directory: https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/blob/main/src/Keycloak.AuthServices.Aspire.Hosting/KeycloakBuilderExtensions.cs#L89-L118
Maybe @NikiforovAll could have some input here.
Nice! Is there a method for importing a realm configuration?
I think there's a way, but I'm not sure how useful that is beyond a playground/demo scenario. Likely what folks would eventually want is to be able to create a realm with a bunch of settings as part of their Aspire host configuration, but I'm not sure it is doable.
You can mount initialization files or directory: https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/blob/main/src/Keycloak.AuthServices.Aspire.Hosting/KeycloakBuilderExtensions.cs#L89-L118
Maybe @NikiforovAll could have some input here.
Yes, can come in a future update if needed.
:bangbang: Found issues :bangbang:
| Project | Coverage Type | Expected | Actual |
|---|---|---|---|
| Aspire.StackExchange.Redis | Branch | 42 | 41.35 :small_red_triangle_down: |
| Microsoft.Extensions.ServiceDiscovery | Branch | 81 | 80.86 :small_red_triangle_down: |
| Microsoft.Extensions.ServiceDiscovery | Line | 81 | 80.94 :small_red_triangle_down: |
Full code coverage report: https://dev.azure.com/dnceng-public/public/_build/results?buildId=712866&view=codecoverage-tab
@julioct Next we need tests.
@julioct
How would this work if a service in a container needs to validate a token issued by the KeycloakResource?
@julioct Next we need tests.
Added hosting and component tests.
Integration tests would require:
- Importing an existing realm
- Creating a user on Keycloak on the fly (realm exports don't include users)
- Performing OIDC authentication and try to access a protected API from the test
Sadly I can't work on those at this point.