aspire icon indicating copy to clipboard operation
aspire copied to clipboard

Add Keycloak component

Open julioct opened this issue 1 year ago • 3 comments

  • 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

julioct avatar May 24 '24 21:05 julioct

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 avatar May 25 '24 00:05 timheuer

@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.

julioct avatar May 25 '24 15:05 julioct

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

davidfowl avatar May 25 '24 15:05 davidfowl

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?

DamianEdwards avatar Jun 08 '24 00:06 DamianEdwards

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?

@DamianEdwards Yep, WithDataVolume persists all data between container instances. Working on a playground project, and a few more things...

julioct avatar Jun 11 '24 00:06 julioct

  • Added a new Keycloak component to simplify the JWT bearer and OIDC authentication configuration for consuming projects
  • Added a playground

julioct avatar Jun 11 '24 18:06 julioct

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?

davidfowl avatar Jun 12 '24 23:06 davidfowl

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:

  1. 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
  1. 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 the AppHost is built

  2. 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();

NikiforovAll avatar Jun 13 '24 07:06 NikiforovAll

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.

davidfowl avatar Jun 13 '24 07:06 davidfowl

@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.

  1. 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 ?


  1. 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 the AppHost is built

@NikiforovAll Could you elaborate more on this idea?

julioct avatar Jun 13 '24 16:06 julioct

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.

DamianEdwards avatar Jun 13 '24 17:06 DamianEdwards

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.

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?

julioct avatar Jun 13 '24 19:06 julioct

Aspire__Keycloak__Realms__0 = "realm1" Aspire__Keycloak__Realms__1 = "realm2"

davidfowl avatar Jun 13 '24 22:06 davidfowl

@julioct Can you summarize the API being added here?

davidfowl avatar Jun 13 '24 23:06 davidfowl

@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 avatar Jun 14 '24 13:06 julioct

@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");
});

DamianEdwards avatar Jun 14 '24 16:06 DamianEdwards

@julioct aren't we removing the realm config from the AppHost project and moving it to the consuming project now?

Done!

julioct avatar Jun 14 '24 20:06 julioct

I'm wondering if these calls should be top level on IHostApplicationBuilder or if they should be on AuthenticationBuilder?

davidfowl avatar Jun 15 '24 14:06 davidfowl

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?

julioct avatar Jun 17 '24 18:06 julioct

Should we make this change?

Yes.

davidfowl avatar Jun 18 '24 05:06 davidfowl

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

julioct avatar Jun 18 '24 17:06 julioct

Nice!

Is there a method for importing a realm configuration?

davidfowl avatar Jun 18 '24 22:06 davidfowl

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.

julioct avatar Jun 18 '24 23:06 julioct

This is looking really promising! Thank you for all of your hard work @julioct!

@mitchdenny and @eerhardt @sebastienros can you review please?

davidfowl avatar Jun 19 '24 04:06 davidfowl

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.

paulomorgado avatar Jun 19 '24 08:06 paulomorgado

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.

julioct avatar Jun 19 '24 13:06 julioct

: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

dotnet-bot avatar Jun 19 '24 15:06 dotnet-bot

@julioct Next we need tests.

davidfowl avatar Jun 20 '24 07:06 davidfowl

@julioct

How would this work if a service in a container needs to validate a token issued by the KeycloakResource?

paulomorgado avatar Jun 20 '24 19:06 paulomorgado

@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.

julioct avatar Jun 20 '24 19:06 julioct