Enhances Keycloak authentication flow#95
Conversation
- Integrated Keycloak via Aspire.Hosting.Keycloak package - Added OpenID Connect authentication to BlazorApp with Keycloak provider - Configured home page as public, all other pages require authentication - Added Login/Logout UI components in top-right corner - Configured id_token_hint for proper logout flow - Added comprehensive Keycloak setup documentation - Updated .gitignore to exclude Development settings and local config files This implements private website access control where only selected users can authenticate through Keycloak. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
a8bb111 to
8f76e81
Compare
Adds Keycloak authentication to the application, securing all pages except the home page. This enhances security by requiring users to log in via Keycloak to access most of the application's features.
Adds initial support for Keycloak authentication to the Blazor app. This includes adding necessary packages and configuring the application to use OpenID Connect for authentication. Additionally, sets up squad related file tracking.
Introduces Keycloak for user authentication, enhancing security with OpenID Connect. Adds Keycloak as an Aspire resource for simplified management and data persistence. Includes documentation for Keycloak setup and configuration, aiding developers in configuring authentication. Adds authorization attributes to Blazor pages, restricting access to authenticated users.
Ensures proper handling of asynchronous operations during sign-out redirect to Keycloak. This change avoids potential deadlocks by awaiting the result of getting the id_token from the HttpContext.
Session: 2026-02-16-docker-compose-docs Requested by: fboucher Changes: - logged session to .ai-team/log/2026-02-16-docker-compose-docs.md - merged 2 decision(s) from inbox into decisions.md - consolidated Keycloak decisions with dual-mode architecture, logout flow, and orchestration details - propagated updates to 2 agent history file(s) (Hicks, Newt) - deleted merged inbox files
Introduces Keycloak for user authentication. Provides Docker Compose deployment documentation and sample environment configuration. The AppHost now supports both development (emulator) and production (docker-compose) modes.
Updates the Docker Compose deployment documentation to reflect the Aspire CLI based deployment workflow, including environment configuration and running instructions. The documentation now describes how to generate docker-compose.yaml using Aspire instead of `aspirate`. It also configures container names in AppHost for clarity.
Updates the docker-compose deployment documentation, providing clarified build steps, parameter explanations, and configuration instructions for a smoother user experience. Removes compose wait from apphost as it doesn't work. Allows overriding RequireHttpsMetadata via configuration for development/docker scenarios
Updates the .NET version badge in README.md to 10.0. Fixes a typo in the Keycloak authentication setup documentation link in README.md.
There was a problem hiding this comment.
Pull request overview
Adds Keycloak-based authentication/authorization to the Blazor app and updates local/container deployment assets to support running with a Keycloak instance (Issue #93).
Changes:
- Configure Cookie + OpenID Connect auth (Keycloak) with login/logout flows and route protection via
AuthorizeRouteView+[Authorize]. - Add UI for login/logout and new
/login+/logoutpages. - Update Aspire/AppHost + docker-compose and add documentation for Keycloak + docker-compose deployment.
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| src/NoteBookmark.BlazorApp/Program.cs | Adds OIDC + cookie auth config, middleware, and minimal auth endpoints |
| src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj | Adds OpenIdConnect package reference |
| src/NoteBookmark.BlazorApp/Components/Shared/LoginDisplay.razor | Header login/logout UI |
| src/NoteBookmark.BlazorApp/Components/Routes.razor | Switches to AuthorizeRouteView and adds NotAuthorized UX |
| src/NoteBookmark.BlazorApp/Components/Pages/* | Marks pages [Authorize] (and some [AllowAnonymous]), adds Login/Logout pages |
| src/NoteBookmark.BlazorApp/Components/Layout/MainLayout.razor | Adds LoginDisplay to header |
| src/NoteBookmark.AppHost/AppHost.cs | Adds Aspire Keycloak resource + docker-compose publishing changes |
| src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj | Upgrades Aspire SDK + adds Keycloak hosting package |
| docker-compose/docker-compose.yaml | Adds Keycloak service + wires Keycloak env vars into blazor service |
| docs/keycloak-setup.md | New Keycloak setup guide |
| docs/docker-compose-deployment.md | New docker-compose deployment guide |
| README.md | Adds links to new docs |
| Directory.Packages.props | Upgrades Aspire versions + adds OpenIdConnect package version |
| .env-sample | Adds env var template for Keycloak + storage endpoints |
| .gitignore / .gitattributes | Ignores local AI/squad files and adds union merge rules |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Authentication endpoints | ||
| app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) => | ||
| { | ||
| var authProperties = new AuthenticationProperties | ||
| { | ||
| RedirectUri = returnUrl ?? "/" |
There was a problem hiding this comment.
/authentication/login forwards returnUrl directly into AuthenticationProperties.RedirectUri without checking that it’s a local URL. This allows an open-redirect if someone supplies an absolute external URL. Validate/sanitize returnUrl (e.g., allow only relative paths starting with /, otherwise fall back to /).
| // Authentication endpoints | |
| app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) => | |
| { | |
| var authProperties = new AuthenticationProperties | |
| { | |
| RedirectUri = returnUrl ?? "/" | |
| static string SanitizeReturnUrl(string? returnUrl) | |
| { | |
| if (string.IsNullOrEmpty(returnUrl)) | |
| { | |
| return "/"; | |
| } | |
| // Reject absolute URIs to prevent open redirects to external sites | |
| if (Uri.TryCreate(returnUrl, UriKind.Absolute, out _)) | |
| { | |
| return "/"; | |
| } | |
| // Only allow local paths starting with a single '/' | |
| if (!returnUrl.StartsWith('/')) | |
| { | |
| return "/"; | |
| } | |
| // Disallow protocol-relative URLs like "//example.com" | |
| if (returnUrl.StartsWith("//")) | |
| { | |
| return "/"; | |
| } | |
| return returnUrl; | |
| } | |
| // Authentication endpoints | |
| app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) => | |
| { | |
| var redirectUri = SanitizeReturnUrl(returnUrl); | |
| var authProperties = new AuthenticationProperties | |
| { | |
| RedirectUri = redirectUri |
| 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); | ||
| } |
There was a problem hiding this comment.
If HttpContextAccessor.HttpContext is null, logout silently does nothing (user stays signed in) and the page renders no guidance. Prefer executing logout via a server endpoint with forceLoad: true (or provide a user-visible fallback/error).
| await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); | ||
| await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); |
There was a problem hiding this comment.
In /authentication/logout, the cookie sign-out happens before the OIDC sign-out. Since the id_token_hint is pulled from saved tokens (typically stored in the auth cookie), signing out the cookie first can clear the token and prevent IdTokenHint from being set. Sign out the OIDC scheme first (with properties), then clear the cookie scheme.
| await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); | |
| await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); | |
| await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); | |
| await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); |
| Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: false); | ||
| } | ||
|
|
||
| private void Logout() | ||
| { | ||
| Navigation.NavigateTo("/logout", forceLoad: false); |
There was a problem hiding this comment.
Navigation to /login and /logout uses forceLoad: false. Because the login/logout implementations rely on server-side auth challenges/sign-outs, they generally need a full page load to execute reliably (and to avoid HttpContext being unavailable once the circuit is interactive). Consider navigating to the server endpoints with forceLoad: true (or otherwise ensuring the auth flow runs on an actual HTTP request).
| Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: false); | |
| } | |
| private void Logout() | |
| { | |
| Navigation.NavigateTo("/logout", forceLoad: false); | |
| Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: true); | |
| } | |
| private void Logout() | |
| { | |
| Navigation.NavigateTo("/logout", forceLoad: true); |
| // Trigger authentication challenge via HttpContext | ||
| var httpContext = HttpContextAccessor.HttpContext; | ||
| if (httpContext != null) | ||
| { | ||
| var authProperties = new AuthenticationProperties | ||
| { | ||
| RedirectUri = returnUrl | ||
| }; | ||
| await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); | ||
| } |
There was a problem hiding this comment.
If HttpContextAccessor.HttpContext is null (which can happen once the component is running interactively), the login flow silently does nothing and leaves the user on a blank page. Prefer redirecting to a dedicated server endpoint with forceLoad: true (or show an error/fallback) so the challenge always runs on an actual HTTP request.
| environment: | ||
| KEYCLOAK_ADMIN: "admin" | ||
| KEYCLOAK_ADMIN_PASSWORD: "${KEYCLOAK_ADMIN_PASSWORD:-admin}" | ||
| KC_HTTP_PORT: "8080" | ||
| KC_HOSTNAME_STRICT: "false" | ||
| KC_HOSTNAME_STRICT_HTTPS: "false" | ||
| KC_HTTP_ENABLED: "true" |
There was a problem hiding this comment.
KEYCLOAK_ADMIN_PASSWORD defaults to admin if the env var isn’t provided. That makes it easy to accidentally deploy an insecure Keycloak instance. Remove the default fallback or at least require an explicit value for non-development usage.
| <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))"> |
There was a problem hiding this comment.
The login button in the NotAuthorized UI navigates with forceLoad: false and builds returnUrl using ToBaseRelativePath(...), which can omit the leading /. This can cause the auth redirect to resolve incorrectly. Normalize returnUrl to an absolute app path and use a full page load for the auth redirect.
| <FluentButton Appearance="Appearance.Accent" OnClick="@(() => NavigationManager.NavigateTo("/login?returnUrl=" + Uri.EscapeDataString(NavigationManager.ToBaseRelativePath(NavigationManager.Uri)), forceLoad: false))"> | |
| <FluentButton Appearance="Appearance.Accent" OnClick="@(() => | |
| { | |
| var relativePath = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); | |
| var returnUrl = string.IsNullOrEmpty(relativePath) ? "/" : "/" + relativePath.TrimStart('/'); | |
| NavigationManager.NavigateTo("/login?returnUrl=" + Uri.EscapeDataString(returnUrl), forceLoad: true); | |
| })"> |
| var uri = new Uri(Navigation.Uri); | ||
| var query = System.Web.HttpUtility.ParseQueryString(uri.Query); | ||
| var returnUrl = query["returnUrl"] ?? "/"; | ||
|
|
||
| // Trigger authentication challenge via HttpContext | ||
| var httpContext = HttpContextAccessor.HttpContext; | ||
| if (httpContext != null) | ||
| { | ||
| var authProperties = new AuthenticationProperties | ||
| { | ||
| RedirectUri = returnUrl | ||
| }; | ||
| await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); |
There was a problem hiding this comment.
The returnUrl query parameter is taken directly from the current URL and used as AuthenticationProperties.RedirectUri without any validation, which introduces an open redirect vulnerability. An attacker can craft a link like /login?returnUrl=https://evil.example so that, after a successful Keycloak authentication, the app redirects the user’s browser to an arbitrary external site, enabling phishing or other attacks. To fix this, ensure returnUrl is constrained to local application paths only (for example, by normalizing and validating it against allowed paths and falling back to / when it is missing or invalid) instead of trusting the raw query string value.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
fix #93
Improves the Keycloak authentication flow by:
id_token_hintto Keycloak.