Skip to content

Enhances Keycloak authentication flow#95

Merged
fboucher merged 14 commits intov-nextfrom
feature/keycloak-authentication
Feb 17, 2026
Merged

Enhances Keycloak authentication flow#95
fboucher merged 14 commits intov-nextfrom
feature/keycloak-authentication

Conversation

@fboucher
Copy link
Owner

fix #93

Improves the Keycloak authentication flow by:

  • Upgrading Aspire packages to version 13.1.1.
  • Configuring logout to properly pass id_token_hint to Keycloak.
  • Redirecting users to the originally requested URL after login.
  • Adding documentation for Keycloak setup.

- 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>
@fboucher fboucher force-pushed the feature/keycloak-authentication branch from a8bb111 to 8f76e81 Compare February 16, 2026 19:44
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.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + /logout pages.
  • 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.

Comment on lines +139 to +144
// Authentication endpoints
app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) =>
{
var authProperties = new AuthenticationProperties
{
RedirectUri = returnUrl ?? "/"
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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 /).

Suggested change
// 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

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +20
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);
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +156
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +34
Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: false);
}

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

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +25
// Trigger authentication challenge via HttpContext
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
{
var authProperties = new AuthenticationProperties
{
RedirectUri = returnUrl
};
await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +14
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"
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
<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))">
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +24
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);
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
fboucher and others added 3 commits February 17, 2026 07:22
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>
@github-actions
Copy link

Code Coverage

Package Line Rate Branch Rate Health
NoteBookmark.Domain 83% 72%
NoteBookmark.ServiceDefaults 96% 75%
NoteBookmark.Api 81% 64%
NoteBookmark.Domain 4% 2%
NoteBookmark.AIServices 84% 0%
NoteBookmark.Domain 83% 72%
NoteBookmark.ServiceDefaults 96% 75%
NoteBookmark.Api 81% 64%
NoteBookmark.Domain 4% 2%
NoteBookmark.AIServices 84% 0%
Summary 53% (1368 / 2072) 34% (284 / 552)

@fboucher fboucher merged commit 140b60a into v-next Feb 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments