Showing posts with label oidc. Show all posts
Showing posts with label oidc. Show all posts

Adding Keycloak Authentication to an Existing .NET Aspire Application

By the end of this post, you'll have a working login/logout flow backed by Keycloak, running locally via Aspire and deployable via Docker Compose.

If your Aspire app doesn't have authentication yet, this is your fastest path to a real identity provider. This tutorial walks through wiring Keycloak OIDC into an existing .NET Aspire + Blazor Server app: from AppHost registration to login/logout UI, using production code from NoteBookmark, an open-source bookmark manager built with .NET Aspire and Blazor.\


Step 1: Add Aspire.Hosting.Keycloak to AppHost

Aspire provides first-class Keycloak support through the Aspire.Hosting.Keycloak package. Add it to your AppHost project:

For AppHost project

dotnet add package Aspire.Hosting.Keycloak

Run dotnet restore to pull the package.


Step 2: Register Keycloak in AppHost.cs

With the package installed, register Keycloak as a resource in your AppHost. Aspire will spin up a Keycloak container, wire its connection details into dependent projects, and ensure proper startup ordering.


// ...

// Add Keycloak authentication server
var keycloak = builder.AddKeycloak("keycloak", port: 8080)
    .WithDataVolume(); // Persist Keycloak data across container restarts

if (builder.Environment.IsDevelopment())
{
    // ...

    builder.AddProject<NoteBookmark_BlazorApp>("blazor-app")
        // ...
        .WithReference(keycloak)  // <-- reference Keycloak
        .WaitFor(keycloak)  // <-- wait for Keycloak to be ready
        .WithExternalHttpEndpoints()
        .PublishAsDockerComposeService((resource, service) =>
        {
            service.ContainerName = "notebookmark-blazor";
        });
}

Key Changes:

  • AddKeycloak("keycloak", port: 8080): Registers a Keycloak resource listening on port 8080.
  • WithDataVolume(): Persists Keycloak's configuration and realm data across container restarts. Without this, you'd lose your realm setup every time the container stops.
  • .WithReference(keycloak): Injects Keycloak connection settings (base URL, etc.) into the BlazorApp as environment variables.
  • .WaitFor(keycloak): Ensures Keycloak is fully started before launching the Blazor app. This is critical: if your app starts before Keycloak is ready, OIDC discovery will fail.

 

Step 3: Set Up Keycloak for Non-Aspire (aka prod) Deployments

This post focuses on the Aspire dev setup, but for production (Docker Compose, Kubernetes), you need a standalone Keycloak. Here's what that looks like and why.

Aspire can actually help you bridge the gap. AddDockerComposeEnvironment() in AppHost generates a draft Docker Compose file from your Aspire model, a great starting point before customizing for production. Worth checking out if you want a head start.

The final compose files for both Keycloak and the NoteBookmark app are available in the NoteBookmark repo:

A few things worth noting about the setup:

  • Postgres as the backing store: Keycloak uses a dedicated Postgres instance (not the app's database) to persist realm configuration, users, and sessions.
  • KC_HTTP_ENABLED: "true": Allows HTTP traffic internally. In production, Keycloak runs behind a reverse proxy (nginx, Traefik) that handles TLS termination: HTTPS externally, HTTP internally.
  • KC_FEATURES: "token-exchange": Enables the token exchange feature, needed if you want service-to-service auth flows.

Step 4: Configure Keycloak Realm and OIDC Client

This configuration is required in both production and development environments, but only needs to be done once per environment. In dev, thanks to .WithDataVolume(), all Keycloak settings are persisted between run and debug sessions, so you only configure it once and it survives restarts.

Once Keycloak is running, configure it:

  1. Navigate to http://localhost:8080 and log in with your admin credentials.
  2. Create a new realm:
    • Click Create Realm
    • Name: notebookmark (match the realm in your Authority URL below)
  3. Create an OIDC client:
    • Clients → Create Client
    • Client ID: notebookmark
    • Client Protocol: openid-connect
    • Access Type: confidential (generates a client secret)
    • Valid Redirect URIs: http://localhost:5173/* (adjust for your Blazor app's URL)
    • Web Origins: http://localhost:5173
  4. Go to the Credentials tab and copy the Client Secret: you'll need this in your app config.
Keycloak client configuration screen


Step 5: Add OpenID Connect to the Blazor App

Now wire up the authentication pipeline in your Blazor Server app.

Add the NuGet Package


For BlazorApp project

dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

Update Program.cs

BlazorApp/Program.cs:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;

//...

// Add authentication
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    var authority = builder.Configuration["Keycloak:Authority"];
    options.Authority = authority;
    options.ClientId = builder.Configuration["Keycloak:ClientId"];
    options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"];
    options.ResponseType = "code";
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;

    // Allow overriding RequireHttpsMetadata via configuration.
    // Relax the requirement when running in a container against HTTP Keycloak.
    var requireHttpsConfigured = builder.Configuration.GetValue<bool?>("Keycloak:RequireHttpsMetadata");
    var isRunningInContainer = string.Equals(
        System.Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"),
        "true",
        StringComparison.OrdinalIgnoreCase);

    if (requireHttpsConfigured.HasValue)
    {
        options.RequireHttpsMetadata = requireHttpsConfigured.Value;
    }
    else
    {
        var defaultRequireHttps = !builder.Environment.IsDevelopment();
        if (isRunningInContainer &&
            !string.IsNullOrEmpty(authority) &&
            authority.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
        {
            defaultRequireHttps = false;
        }
        options.RequireHttpsMetadata = defaultRequireHttps;
    }

    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");

    options.TokenValidationParameters = new()
    {
        NameClaimType = "preferred_username",
        RoleClaimType = "roles"
    };

    // Configure logout to pass id_token_hint to Keycloak
    options.Events = new OpenIdConnectEvents
    {
        OnRedirectToIdentityProviderForSignOut = async context =>
        {
            var idToken = await context.HttpContext.GetTokenAsync("id_token");
            if (!string.IsNullOrEmpty(idToken))
            {
                context.ProtocolMessage.IdTokenHint = idToken;
            }
        }
    };
});

builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();

// ... existing Razor Components, FluentUI, etc. ...

var app = builder.Build();
app.MapDefaultEndpoints();

// ... existing middleware ...

// CRITICAL: UseAuthentication BEFORE UseAuthorization
app.UseAuthentication();
app.UseAuthorization();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

// Authentication endpoints
app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) =>
{
    var authProperties = new AuthenticationProperties { RedirectUri = returnUrl ?? "/" };
    await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
});

app.MapGet("/authentication/logout", async (HttpContext context) =>
{
    var authProperties = new AuthenticationProperties { RedirectUri = "/" };
    await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
});

app.Run();


Configuration


Create or update appsettings.json in the BlazorApp project:

{
  "Keycloak": {
    "Authority": "http://localhost:8080/realms/notebookmark",
    "ClientId": "notebookmark",
    "ClientSecret": "your-client-secret-from-keycloak",
    "RequireHttpsMetadata": false
  }
}

For your Prod, Docker Compose deployments, use environment variables in your docker-compose.yaml:

environment:
  Keycloak__Authority: ${KEYCLOAK_AUTHORITY}
  Keycloak__ClientId: ${KEYCLOAK_CLIENT_ID}
  Keycloak__ClientSecret: ${KEYCLOAK_CLIENT_SECRET}
In the development enviroment, Aspire's .WithReference(keycloak) automatically injects environment variables like services__keycloak__http__0 for the Keycloak base URL. You can read this in your config or manually set the Authority URL as shown above.

 

Handling HTTP vs HTTPS: The RequireHttpsMetadata Gotcha

By default, the OpenID Connect middleware requires HTTPS for metadata discovery (RequireHttpsMetadata = true). This is a security best practice for production, but it causes problems in local/container dev environments where Keycloak runs on HTTP.

The code above implements a smart fallback:

  1. Check explicit configuration first: If Keycloak:RequireHttpsMetadata is set in config, use that value.
  2. Detect container environment: If running in a container (DOTNET_RUNNING_IN_CONTAINER=true) and the Authority URL is HTTP, disable the HTTPS requirement.
  3. Default to HTTPS in production: Outside of Development mode, default to requiring HTTPS.

This ensures:

  • Local dev works seamlessly with HTTP Keycloak
  • Container-to-container communication works (HTTP internally)
  • Production enforces HTTPS (assuming you've configured it properly)

Note: In production, run Keycloak behind a reverse proxy (nginx, Traefik, etc.) that handles TLS termination. Your app sees https://yourdomain.com, Keycloak internally runs on HTTP.

That's the server-side setup done. Now let's build the Blazor UI pieces that make auth visible to users.


Step 6: Blazor UI: Login, Logout, and Route Protection

With the backend authentication pipeline configured, it's time to build the UI components that let users actually log in, log out, and interact with protected content. We'll create three key pieces: the login/logout pages, a login display component, and routing configuration that enforces authorization.

Keycloak login screen

The Login and Logout Razor Pages

First, we need pages to trigger authentication flows. These aren't typical Blazor pages with markup—they're redirect triggers that hand off control to Keycloak.

Login.razor

Create Components/Pages/Login.razor:

@page "/login"
@attribute [AllowAnonymous]
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.OpenIdConnect
@inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor

@code {
    protected override async Task OnInitializedAsync()
    {
        var uri = new Uri(Navigation.Uri);
        var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
        var returnUrl = query["returnUrl"] ?? "/";

        var httpContext = HttpContextAccessor.HttpContext;
        if (httpContext != null)
        {
            var authProperties = new AuthenticationProperties
            {
                RedirectUri = returnUrl
            };
            await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
        }
    }
}

What's happening here?

  • No markup: This page doesn't render anything. Its job is to initiate the OpenID Connect authentication challenge, which redirects the browser to Keycloak.
  • ChallengeAsync: This triggers the OIDC middleware to redirect the user to Keycloak's login page.
  • Return URL: We capture the returnUrl query parameter so users land back where they started after logging in.
  • [AllowAnonymous]: Critical! Without this, the page would require authentication to access, creating a redirect loop.

Logout.razor

Create Components/Pages/Logout.razor:

@page "/logout"
@attribute [AllowAnonymous]
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@using Microsoft.AspNetCore.Authentication.OpenIdConnect
@inject IHttpContextAccessor HttpContextAccessor

@code {
    protected override async Task OnInitializedAsync()
    {
        var httpContext = HttpContextAccessor.HttpContext;
        if (httpContext != null)
        {
            var properties = new AuthenticationProperties
            {
                RedirectUri = "/"
            };
            await httpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, properties);
            await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        }
    }
}


Why sign out of TWO schemes?


This is where many implementations fail. OpenID Connect uses a dual authentication scheme:

  1. OpenIdConnect scheme: Handles the protocol dance with Keycloak (redirects, token exchange, logout).
  2. Cookie scheme: Manages the local session in your Blazor app.

When logging out, you must sign out of both, in this order:

  1. OIDC first: This redirects to Keycloak's logout endpoint, ending the SSO session.
  2. Cookie second: This clears the local authentication cookie.

Signing out of only the cookie leaves the Keycloak session active—users can click "Login" and get back in without re-entering credentials. Signing out only from OIDC leaves the local cookie intact, so the app still thinks they're logged in.

The RedirectUri in the authentication properties controls where users land after the Keycloak logout completes. We send them to the home page.

 

Step 7: The LoginDisplay Component

Now we need a UI element to show login state and provide login/logout actions. This typically lives in your app's header or navigation bar.

Note: NoteBookmark uses FluentUI Blazor (the <Fluent...> components), it's not a requirement, but it definitely looks great ;)

Create Components/Layout/LoginDisplay.razor:

@rendermode InteractiveServer
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation

<AuthorizeView>
    <Authorized>
        <FluentStack Orientation="Orientation.Horizontal" HorizontalGap="8" 
                     HorizontalAlignment="HorizontalAlignment.Right" 
                     VerticalAlignment="VerticalAlignment.Center">
            <span>Hello, @context.User.Identity?.Name</span>
            <FluentButton Appearance="Appearance.Lightweight" OnClick="Logout" 
                          IconStart="@(new Icons.Regular.Size16.ArrowExit())">
                Logout
            </FluentButton>
        </FluentStack>
    </Authorized>
    <NotAuthorized>
        <FluentButton Appearance="Appearance.Accent" OnClick="Login" 
                      IconStart="@(new Icons.Regular.Size16.Person())">
            Login
        </FluentButton>
    </NotAuthorized>
</AuthorizeView>

@code {
    private void Login()
    {
        var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri);
        if (string.IsNullOrEmpty(returnUrl)) returnUrl = "/";
        Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: false);
    }

    private void Logout()
    {
        Navigation.NavigateTo("/logout", forceLoad: false);
    }
}

Key implementation details:

  • @rendermode InteractiveServer: This is essential. <AuthorizeView> needs to access AuthenticationStateProvider, which requires an interactive render mode. Without this, the component renders as static HTML and won't respond to auth state changes.
  • <AuthorizeView>: This component automatically shows/hides content based on authentication state. The context parameter provides access to the User claims principal.
  • Return URL on login: We pass the current page URL so users return to where they were after authenticating.
  • forceLoad: false: We use in-app navigation. The Login.razor and Logout.razor pages will handle the actual HTTP redirects.

Add this component to your MainLayout.razor or header component:<LoginDisplay />

visual of the LoginDisplay

Step 8: Protecting Routes and Pages

With login/logout working, you need to enforce authorization rules. Blazor provides two mechanisms: page-level protection with [Authorize] and inline content protection with <AuthorizeView>.

Updating Routes.razor

First, modify Components/Routes.razor to support authorization-aware routing:

@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization

<FluentDesignTheme StorageName="theme" @rendermode="@InteractiveServer" />

<CascadingAuthenticationState>
    <Router AppAssembly="typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
                <NotAuthorized>
                    @if (context.User.Identity?.IsAuthenticated != true)
                    {
                        <FluentStack Orientation="Orientation.Vertical" VerticalGap="20" 
                                     HorizontalAlignment="HorizontalAlignment.Center" 
                                     Style="margin-top: 100px;">
                            <FluentIcon Value="@(new Icons.Regular.Size48.LockClosed())" Color="Color.Accent" />
                            <h2>Authentication Required</h2>
                            <p>You need to be logged in to access this page.</p>
                            <FluentButton Appearance="Appearance.Accent" 
                                OnClick="@(() => NavigationManager.NavigateTo(
                                    "/login?returnUrl=" + Uri.EscapeDataString(
                                        NavigationManager.ToBaseRelativePath(NavigationManager.Uri)), 
                                    forceLoad: false))">
                                Login
                            </FluentButton>
                        </FluentStack>
                    }
                    else
                    {
                        <FluentStack Orientation="Orientation.Vertical" VerticalGap="20" 
                                     HorizontalAlignment="HorizontalAlignment.Center" 
                                     Style="margin-top: 100px;">
                            <FluentIcon Value="@(new Icons.Regular.Size48.ShieldError())" Color="Color.Error" />
                            <h2>Access Denied</h2>
                            <p>You don't have permission to access this page.</p>
                            <FluentButton Appearance="Appearance.Accent" 
                                OnClick="@(() => NavigationManager.NavigateTo("/", forceLoad: false))">
                                Go to Home
                            </FluentButton>
                        </FluentStack>
                    }
                </NotAuthorized>
            </AuthorizeRouteView>
            <FocusOnNavigate RouteData="routeData" Selector="h1" />
        </Found>
    </Router>
</CascadingAuthenticationState>

@code {
    [Inject] private NavigationManager NavigationManager { get; set; } = default!;
}


What changed?

  1. <CascadingAuthenticationState>: This wraps the entire router and makes authentication state available to all child components. Without it, <AuthorizeView> and [Authorize] attributes won't work.

  2. <AuthorizeRouteView>: Replaces the standard RouteView. This component checks the [Authorize] attribute on routed pages and enforces authorization rules.

  3. <NotAuthorized> with two states: This is subtle but important. The <NotAuthorized> content renders when authorization fails, but there are two scenarios:

    • Not authenticated (context.User.Identity?.IsAuthenticated != true): The user isn't logged in. Show a "Login" button.
    • Authenticated but not authorized (else): The user is logged in but lacks permission (e.g., wrong role). Show an "Access Denied" message.

Protecting Pages with [Authorize]

To require authentication for an entire page, add the [Authorize] attribute:

@page "/posts"
@attribute [Authorize]
@using Microsoft.AspNetCore.Authorization

<PageTitle>My Posts</PageTitle>

<h1>My Posts</h1>

<!-- Your protected content here -->

Now, unauthenticated users who navigate to /posts will see the "Authentication Required" message from Routes.razor, not the page content.

Note: [Authorize] also supports roles and policies (e.g. [Authorize(Roles = "Admin")]) for more granular access control, that's a topic for a future post.


Testing it out:

  1. Run your Aspire app host: dotnet run --project NoteBookmark.AppHost
  2. Navigate to your Blazor app in the browser.
  3. Click "Login"—you should redirect to Keycloak, authenticate, and return.
  4. You'll see "Hello, [your name]" in the header.
  5. Navigate to a page marked [Authorize] without logging in—you'll see the auth required message.
  6. Click "Logout"—you'll sign out of both the app and Keycloak.

Your Blazor app now has full OpenID Connect authentication with Keycloak, with a clean separation between the auth mechanics (Login/Logout pages), UI (LoginDisplay), and enforcement (Routes.razor + [Authorize]).


Conclusion

You've now integrated Keycloak authentication into your .NET Aspire application. The key pieces:

  1. Aspire orchestration: AddKeycloak(), .WithReference(), and .WaitFor() handle container lifecycle and configuration injection.
  2. OIDC pipeline: The standard ASP.NET Core authentication middleware, configured for Keycloak's OIDC endpoints.
  3. HTTP flexibility: Logic to handle HTTP Keycloak in dev while enforcing HTTPS in production.
  4. Persistent data: WithDataVolume() ensures your Keycloak realm config survives restarts.

This pattern scales beyond Keycloak, Aspire's resource model works the same way for databases, message queues, and other services. Once you've mastered .WithReference() and .WaitFor(), you can compose complex distributed systems with confidence.

The full working implementation is available in the NoteBookmark repository, including the AppHost configuration, Blazor components, and Docker Compose files referenced throughout this post.


Useful links