> s_todos = new();
+
+ private readonly IUserContext _userContext;
+
+ public TodoService(IUserContext userContext)
+ {
+ this._userContext = userContext;
+ }
+
+ ///
+ /// Lists all TODO items for the currently authenticated user.
+ ///
+ [Description("Lists all TODO items for the current user.")]
+ public string ListTodos()
+ {
+ var items = s_todos.GetOrAdd(this._userContext.UserId, _ => []);
+
+ return items.IsEmpty
+ ? "You have no TODO items."
+ : string.Join("\n", items.Select((item, i) => $"{i + 1}. {item}"));
+ }
+
+ ///
+ /// Adds a new TODO item for the currently authenticated user.
+ ///
+ [Description("Adds a new TODO item for the current user.")]
+ public string AddTodo([Description("The TODO item to add")] string item)
+ {
+ var items = s_todos.GetOrAdd(this._userContext.UserId, _ => []);
+ items.Add(item);
+
+ return $"Added \"{item}\" to your TODO list.";
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs
new file mode 100644
index 0000000000..39ac1cc6ae
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Security.Claims;
+
+namespace AuthClientServer.AgentService;
+
+///
+/// Provides the authenticated user's identity for the current request.
+///
+public interface IUserContext
+{
+ /// Unique identifier for the current user (e.g. the OIDC "sub" claim).
+ string UserId { get; }
+
+ /// Login name for the current user.
+ string UserName { get; }
+
+ /// Human-readable display name (e.g. "Test User").
+ string DisplayName { get; }
+}
+
+///
+/// Resolves the current user's identity from Keycloak-specific JWT claims.
+/// Keycloak uses sub for the user ID, preferred_username
+/// for the login name, and given_name/family_name for the
+/// display name. Registered as a scoped service so it is resolved once
+/// per request.
+///
+public sealed class KeycloakUserContext : IUserContext
+{
+ public string UserId { get; }
+
+ public string UserName { get; }
+
+ public string DisplayName { get; }
+
+ public KeycloakUserContext(IHttpContextAccessor httpContextAccessor)
+ {
+ ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
+
+ this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
+ ?? user?.FindFirstValue("sub")
+ ?? "anonymous";
+
+ this.UserName = user?.FindFirstValue("preferred_username")
+ ?? user?.FindFirstValue(ClaimTypes.Name)
+ ?? "unknown";
+
+ string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName);
+ string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname);
+ this.DisplayName = (givenName, familyName) switch
+ {
+ (not null, not null) => $"{givenName} {familyName}",
+ (not null, null) => givenName,
+ (null, not null) => familyName,
+ _ => this.UserName,
+ };
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/appsettings.json b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/appsettings.json
new file mode 100644
index 0000000000..c5275372ad
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Auth": {
+ "Authority": "http://localhost:5002/realms/dev",
+ "Audience": "agent-service"
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj
new file mode 100644
index 0000000000..d1c7fec19a
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ $(NoWarn);CS1591
+
+
+
+
+
+
+
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile
new file mode 100644
index 0000000000..06af15686f
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile
@@ -0,0 +1,29 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /repo
+
+# Copy solution-level files for restore
+COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json nuget.config ./
+COPY eng/ eng/
+COPY src/Shared/ src/Shared/
+COPY samples/Directory.Build.props samples/
+
+# Create sentinel file so $(RepoRoot) resolves correctly inside the container.
+# RepoRoot is the parent of the dir containing CODE_OF_CONDUCT.md,
+# and src projects import $(RepoRoot)/dotnet/nuget/nuget-package.props.
+RUN touch /CODE_OF_CONDUCT.md
+
+# Copy project file for restore
+COPY samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/
+
+RUN dotnet restore samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false
+
+# Copy everything and build
+COPY samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/ samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/
+RUN dotnet publish samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:10.0
+WORKDIR /app
+COPY --from=build /app .
+ENV ASPNETCORE_URLS=http://+:8080
+EXPOSE 8080
+ENTRYPOINT ["dotnet", "AuthClientServer.WebClient.dll"]
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml
new file mode 100644
index 0000000000..60db3641b0
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml
@@ -0,0 +1,35 @@
+@page
+@using Microsoft.AspNetCore.Authorization
+@attribute [Authorize]
+@model AuthClientServer.WebClient.Pages.ChatModel
+@{
+ Layout = "_Layout";
+}
+
+Chat with the Agent
+
+
+
+@if (Model.Error is not null)
+{
+
+ Error: @Model.Error
+
+}
+
+@if (Model.Reply is not null)
+{
+
+
Agent (responding to @Model.ReplyUser):
+
@Model.Reply
+
+}
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml.cs
new file mode 100644
index 0000000000..87c9b3e333
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace AuthClientServer.WebClient.Pages;
+
+public class ChatModel : PageModel
+{
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ public ChatModel(IHttpClientFactory httpClientFactory)
+ {
+ this._httpClientFactory = httpClientFactory;
+ }
+
+ [BindProperty]
+ public string? Message { get; set; }
+
+ public string? Reply { get; set; }
+ public string? ReplyUser { get; set; }
+ public string? Error { get; set; }
+
+ public void OnGet()
+ {
+ }
+
+ public async Task OnPostAsync()
+ {
+ if (string.IsNullOrWhiteSpace(this.Message))
+ {
+ return;
+ }
+
+ try
+ {
+ // Get the access token stored during OIDC login
+ string? accessToken = await this.HttpContext.GetTokenAsync("access_token");
+ if (accessToken is null)
+ {
+ this.Error = "No access token available. Please log in again.";
+ return;
+ }
+
+ // Call the AgentService with the Bearer token
+ var client = this._httpClientFactory.CreateClient("AgentService");
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ var payload = JsonSerializer.Serialize(new { message = this.Message });
+ var content = new StringContent(payload, Encoding.UTF8, "application/json");
+
+ var response = await client.PostAsync(new Uri("/chat", UriKind.Relative), content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
+ this.Reply = json.RootElement.GetProperty("reply").GetString();
+ this.ReplyUser = json.RootElement.GetProperty("user").GetString();
+ }
+ else
+ {
+ this.Error = response.StatusCode switch
+ {
+ System.Net.HttpStatusCode.Unauthorized => "Authentication failed (401). Your session may have expired.",
+ System.Net.HttpStatusCode.Forbidden => "Access denied (403). Your account does not have the required 'agent.chat' scope.",
+ _ => $"AgentService returned {(int)response.StatusCode} {response.ReasonPhrase}."
+ };
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Error = $"Failed to contact the AgentService: {ex.Message}";
+ }
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml
new file mode 100644
index 0000000000..248459101c
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml
@@ -0,0 +1,18 @@
+@page
+@model AuthClientServer.WebClient.Pages.IndexModel
+@{
+ Layout = "_Layout";
+}
+
+Welcome
+This sample demonstrates securing an AI agent API with OAuth 2.0 / OpenID Connect.
+
+@if (User.Identity?.IsAuthenticated == true)
+{
+ You are logged in as @User.Identity.Name.
+ Go to Chat →
+}
+else
+{
+ Please log in to chat with the agent.
+}
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs
new file mode 100644
index 0000000000..c35fc7a196
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace AuthClientServer.WebClient.Pages;
+
+public class IndexModel : PageModel
+{
+ public void OnGet()
+ {
+ }
+
+ public IActionResult OnGetLogout()
+ {
+ return SignOut(
+ new AuthenticationProperties { RedirectUri = "/" },
+ CookieAuthenticationDefaults.AuthenticationScheme,
+ OpenIdConnectDefaults.AuthenticationScheme);
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Shared/_Layout.cshtml b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Shared/_Layout.cshtml
new file mode 100644
index 0000000000..c44e993624
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Shared/_Layout.cshtml
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Auth Agent Chat
+
+
+
+
+
+ @RenderBody()
+
+
+
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/_ViewImports.cshtml b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/_ViewImports.cshtml
new file mode 100644
index 0000000000..2c167029a9
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/_ViewImports.cshtml
@@ -0,0 +1,3 @@
+@using Microsoft.AspNetCore.Authentication
+@namespace AuthClientServer.WebClient.Pages
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs
new file mode 100644
index 0000000000..7dc718df6a
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs
@@ -0,0 +1,117 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates an OIDC-authenticated Razor Pages web client
+// that calls a JWT-secured AI agent REST API.
+
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddRazorPages();
+
+// Persist data protection keys so antiforgery tokens survive container rebuilds
+builder.Services.AddDataProtection()
+ .PersistKeysToFileSystem(new DirectoryInfo("/app/keys"));
+
+// ---------------------------------------------------------------------------
+// Authentication: Cookie + OpenID Connect (Keycloak)
+// ---------------------------------------------------------------------------
+string authority = builder.Configuration["Auth:Authority"]
+ ?? throw new InvalidOperationException("Auth:Authority is not configured.");
+
+// Auto-detect Codespaces: derive the public Keycloak URL for browser redirects.
+// Authority stays as localhost (reachable on host networking) for backchannel
+// discovery; only browser-facing redirects use the public URL.
+string? codespaceName = Environment.GetEnvironmentVariable("CODESPACE_NAME");
+string? codespaceDomain = Environment.GetEnvironmentVariable("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN");
+string? publicKeycloakBase = (!string.IsNullOrEmpty(codespaceName) && !string.IsNullOrEmpty(codespaceDomain))
+ ? $"https://{codespaceName}-5002.{codespaceDomain}"
+ : null;
+
+builder.Services
+ .AddAuthentication(options =>
+ {
+ options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
+ })
+ .AddCookie()
+ .AddOpenIdConnect(options =>
+ {
+ options.Authority = authority;
+ options.ClientId = builder.Configuration["Auth:ClientId"]
+ ?? throw new InvalidOperationException("Auth:ClientId is not configured.");
+
+ options.ResponseType = OpenIdConnectResponseType.Code;
+ options.SaveTokens = true;
+ options.GetClaimsFromUserInfoEndpoint = true;
+
+ // Request the agent.chat scope so the access token includes it
+ options.Scope.Clear();
+ options.Scope.Add("openid");
+ options.Scope.Add("profile");
+ options.Scope.Add("email");
+ options.Scope.Add("agent.chat");
+
+ // For local development with HTTP-only Keycloak
+ options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
+
+ // In Codespaces, the tunnel delivers requests to localhost but the
+ // browser must redirect to the public Codespaces URLs. Rewrite the
+ // authorization endpoint and redirect URIs so the browser reaches
+ // Keycloak and the web-client via the public Codespaces URLs.
+ // Issuer validation is disabled because the token is issued via the
+ // public URL (issuer = public hostname) but the discovery doc is
+ // fetched from localhost (issuer = localhost).
+ if (publicKeycloakBase is not null)
+ {
+#pragma warning disable CA5404 // Disabling token validation checks in development environment to allow Codespaces tunnel URL for browser redirects, do not do this in production.
+ options.TokenValidationParameters.ValidateIssuer = false;
+#pragma warning restore CA5404
+
+ // The UserInfo endpoint is on localhost but the token issuer is
+ // the public URL — Keycloak rejects the token. The ID token
+ // already contains the claims we need, so skip the UserInfo call.
+ options.GetClaimsFromUserInfoEndpoint = false;
+
+ string publicBase = $"https://{codespaceName}-8080.{codespaceDomain}";
+ options.Events = new OpenIdConnectEvents
+ {
+ OnRedirectToIdentityProvider = context =>
+ {
+ context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress
+ .Replace("http://localhost:5002", publicKeycloakBase);
+ context.ProtocolMessage.RedirectUri = $"{publicBase}/signin-oidc";
+ return Task.CompletedTask;
+ },
+ OnRedirectToIdentityProviderForSignOut = context =>
+ {
+ context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress
+ .Replace("http://localhost:5002", publicKeycloakBase);
+ context.ProtocolMessage.PostLogoutRedirectUri = $"{publicBase}/signout-callback-oidc";
+ return Task.CompletedTask;
+ },
+ };
+ }
+ });
+
+// ---------------------------------------------------------------------------
+// HttpClient for calling the AgentService — attaches Bearer token
+// ---------------------------------------------------------------------------
+builder.Services.AddHttpClient("AgentService", client =>
+{
+ string baseUrl = builder.Configuration["AgentService:BaseUrl"] ?? "http://localhost:5001";
+ client.BaseAddress = new Uri(baseUrl);
+});
+
+WebApplication app = builder.Build();
+
+app.UseStaticFiles();
+app.UseRouting();
+app.UseAuthentication();
+app.UseAuthorization();
+app.MapRazorPages();
+
+await app.RunAsync();
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/appsettings.json b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/appsettings.json
new file mode 100644
index 0000000000..5372dad530
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Auth": {
+ "Authority": "http://localhost:5002/realms/dev",
+ "ClientId": "web-client"
+ },
+ "AgentService": {
+ "BaseUrl": "http://localhost:5001"
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/README.md b/dotnet/samples/05-end-to-end/AuthClientServer/README.md
new file mode 100644
index 0000000000..0dd98d3abb
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/README.md
@@ -0,0 +1,147 @@
+# Auth Client-Server Sample
+
+This sample demonstrates how to secure an AI agent REST API with standards-based authentication and authorization using OAuth 2.0 / OpenID Connect, JWT Bearer tokens, and policy-based scope enforcement.
+
+While this sample uses Keycloak to avoid complex setup in order to run the sample, Keycloak can easily be replaced with any OIDC compatible provider, including [Microsoft Entra Id](https://www.microsoft.com/security/business/identity-access/microsoft-entra-id).
+
+## Overview
+
+The sample has three components, all launched with a single `docker compose up`:
+
+| Service | Port | Description |
+|---------|------|-------------|
+| **WebClient** | `http://localhost:8080` | Razor Pages web app with OIDC login and a chat UI that calls the AgentService |
+| **AgentService** | `http://localhost:5001` | ASP.NET Minimal API hosting a `ChatClientAgent`, secured with JWT Bearer auth and scope-based policies |
+| **Keycloak** | `http://localhost:5002` | OIDC identity provider, auto-provisioned with realm, clients, scopes, and test users |
+
+```
+┌──────────────┐ OIDC login ┌───────────┐
+│ WebClient │ ◄──────────────────► │ Keycloak │
+│ (Razor app) │ (browser flow) │ (Docker) │
+│ :8080 │ │ :5002 │
+└──────┬───────┘ └─────┬─────┘
+ │ REST + Bearer token │
+ ▼ │
+┌───────────────┐ JWT validation ──────┘
+│ AgentService │ ◄──── (jwks from Keycloak)
+│ (Minimal API) │
+│ :5001 │
+└───────────────┘
+```
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
+
+## Configuring Environment Variables
+
+The AgentService requires an OpenAI-compatible endpoint. Set these environment variables before running:
+
+```bash
+export OPENAI_API_KEY=""
+export OPENAI_MODEL="gpt-4.1-mini"
+```
+
+## Running the Sample
+
+### Option 1: Docker Compose (Recommended)
+
+```bash
+cd samples/AuthClientServer
+docker compose up
+```
+
+This starts Keycloak, the AgentService, and the WebClient. Wait for Keycloak to finish importing the realm (you'll see `Running the server` in the logs).
+
+#### Running in GitHub Codespaces
+
+This sample has been built in such a way that it can be run from GitHub Codespaces.
+The Agent Framework repository has a C# specific dev container, named "C# (.NET)", that is configured for Codespaces.
+
+When running in Codespaces, the sample auto-detects the environment via
+`CODESPACE_NAME` and `GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN` and configures
+Keycloak and the web client accordingly. Just make the required ports public:
+
+```bash
+# Make Keycloak and WebClient ports publicly accessible
+gh codespace ports visibility 5002:public 8080:public -c $CODESPACE_NAME
+
+# Start the containers (Codespaces is auto-detected)
+docker compose up
+```
+
+Then open the Codespaces-forwarded URL for port 8080 (shown in the **Ports** tab) in your browser.
+
+### Option 2: Run Locally
+
+1. Start Keycloak:
+ ```bash
+ docker compose up keycloak
+ ```
+
+2. In a new terminal, start the AgentService:
+ ```bash
+ cd AuthClientServer.AgentService
+ dotnet run --urls "http://localhost:5001"
+ ```
+
+3. In another terminal, start the WebClient:
+ ```bash
+ cd AuthClientServer.WebClient
+ dotnet run --urls "http://localhost:8080"
+ ```
+
+## Using the Sample
+
+1. Open `http://localhost:8080` in your browser
+2. Click **Login** — you'll be redirected to Keycloak
+3. Sign in with one of the pre-configured users:
+ - **`testuser` / `password`** — has the `agent.chat` scope, can chat with the agent
+ - **`viewer` / `password`** — lacks the `agent.chat` scope, will receive a 403 Forbidden when trying to chat
+4. Type a message and click **Send** to chat with the agent
+
+## Pre-Configured Keycloak Realm
+
+The `keycloak/dev-realm.json` file auto-provisions:
+
+| Resource | Details |
+|----------|---------|
+| **Realm** | `dev` |
+| **Client: agent-service** | Confidential client (the API audience) |
+| **Client: web-client** | Public client for the Razor app's OIDC login |
+| **Scope: agent.chat** | Required to call the `/chat` endpoint |
+| **User: testuser** | Has `agent.chat` scope |
+| **User: viewer** | Does not have `agent.chat` scope |
+
+Keycloak admin console: `http://localhost:5002` (login: `admin` / `admin`).
+
+## API Endpoints
+
+### POST /chat (requires `agent.chat` scope)
+
+```bash
+# Get a token for testuser
+TOKEN=$(curl -s -X POST http://localhost:5002/realms/dev/protocol/openid-connect/token \
+ -d "grant_type=password&client_id=web-client&username=testuser&password=password&scope=openid agent.chat" \
+ | jq -r '.access_token')
+
+# Chat with the agent
+curl -X POST http://localhost:5001/chat \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"message": "Hello, what can you help me with?"}'
+```
+
+### GET /me (requires any valid token)
+
+```bash
+curl http://localhost:5001/me -H "Authorization: Bearer $TOKEN"
+```
+
+## Key Concepts Demonstrated
+
+- **JWT Bearer Authentication** — The AgentService validates tokens from Keycloak using OIDC discovery
+- **Policy-Based Authorization** — The `/chat` endpoint requires the `agent.chat` scope in the token
+- **Caller Identity** — The service reads the caller's identity from `HttpContext.User` (ClaimsPrincipal)
+- **OIDC Login Flow** — The WebClient uses OpenID Connect authorization code flow with Keycloak
+- **Token Forwarding** — The WebClient stores the access token and sends it as a Bearer token to the AgentService
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml b/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml
new file mode 100644
index 0000000000..78e36e04bd
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml
@@ -0,0 +1,78 @@
+services:
+ keycloak:
+ image: quay.io/keycloak/keycloak:latest
+ container_name: auth-keycloak
+ environment:
+ - KC_BOOTSTRAP_ADMIN_USERNAME=admin
+ - KC_BOOTSTRAP_ADMIN_PASSWORD=admin
+ - KC_HOSTNAME_STRICT=false
+ - KC_PROXY_HEADERS=xforwarded
+ volumes:
+ - ./keycloak/dev-realm.json:/opt/keycloak/data/import/dev-realm.json
+ command: ["start-dev", "--import-realm"]
+ ports:
+ - "5002:8080"
+ healthcheck:
+ test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/master HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200'"]
+ interval: 10s
+ timeout: 5s
+ retries: 30
+ start_period: 30s
+
+ # One-shot init container that registers the Codespaces redirect URI
+ # with Keycloak after it becomes healthy. Auto-detects Codespaces via
+ # CODESPACE_NAME and GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN env vars.
+ keycloak-init:
+ image: curlimages/curl:latest
+ container_name: auth-keycloak-init
+ environment:
+ - KEYCLOAK_URL=http://keycloak:8080
+ - CODESPACE_NAME=${CODESPACE_NAME:-}
+ - GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-}
+ volumes:
+ - ./keycloak/setup-redirect-uris.sh:/setup-redirect-uris.sh:ro
+ entrypoint: ["sh", "/setup-redirect-uris.sh"]
+ depends_on:
+ keycloak:
+ condition: service_healthy
+
+ agent-service:
+ build:
+ context: ../../..
+ dockerfile: samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Dockerfile
+ container_name: auth-agent-service
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ - Auth__Authority=http://keycloak:8080/realms/dev
+ - Auth__Audience=agent-service
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4.1-mini}
+ ports:
+ - "5001:5001"
+ depends_on:
+ keycloak:
+ condition: service_healthy
+
+ web-client:
+ build:
+ context: ../../..
+ dockerfile: samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile
+ container_name: auth-web-client
+ network_mode: host
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ - Auth__Authority=http://localhost:5002/realms/dev
+ - Auth__ClientId=web-client
+ - AgentService__BaseUrl=http://localhost:5001
+ - CODESPACE_NAME=${CODESPACE_NAME:-}
+ - GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-}
+ volumes:
+ - web-client-keys:/app/keys
+ depends_on:
+ keycloak:
+ condition: service_healthy
+ agent-service:
+ condition: service_started
+
+volumes:
+ web-client-keys:
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json
new file mode 100644
index 0000000000..a59728d276
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json
@@ -0,0 +1,195 @@
+{
+ "realm": "dev",
+ "enabled": true,
+ "sslRequired": "none",
+ "registrationAllowed": false,
+ "roles": {
+ "realm": [
+ {
+ "name": "agent-chat-user",
+ "description": "Grants access to the agent.chat scope"
+ }
+ ]
+ },
+ "scopeMappings": [
+ {
+ "clientScope": "agent.chat",
+ "roles": ["agent-chat-user"]
+ }
+ ],
+ "clientScopes": [
+ {
+ "name": "openid",
+ "description": "OpenID Connect scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "sub",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-sub-mapper",
+ "config": {
+ "introspection.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "name": "profile",
+ "description": "OpenID Connect profile scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "preferred_username",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "config": {
+ "user.attribute": "username",
+ "claim.name": "preferred_username",
+ "jsonType.label": "String",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ },
+ {
+ "name": "given_name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "config": {
+ "user.attribute": "firstName",
+ "claim.name": "given_name",
+ "jsonType.label": "String",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ },
+ {
+ "name": "family_name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "config": {
+ "user.attribute": "lastName",
+ "claim.name": "family_name",
+ "jsonType.label": "String",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "name": "email",
+ "description": "OpenID Connect email scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true"
+ }
+ },
+ {
+ "name": "agent.chat",
+ "description": "Allows chatting with the agent",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true"
+ }
+ },
+ {
+ "name": "agent-service-audience",
+ "description": "Adds the agent-service audience to access tokens",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "agent-service-audience-mapper",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "config": {
+ "included.client.audience": "agent-service",
+ "id.token.claim": "false",
+ "access.token.claim": "true"
+ }
+ }
+ ]
+ }
+ ],
+ "clients": [
+ {
+ "clientId": "agent-service",
+ "enabled": true,
+ "publicClient": false,
+ "secret": "agent-service-secret",
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": false,
+ "standardFlowEnabled": false,
+ "protocol": "openid-connect"
+ },
+ {
+ "clientId": "web-client",
+ "enabled": true,
+ "publicClient": true,
+ "directAccessGrantsEnabled": true,
+ "standardFlowEnabled": true,
+ "fullScopeAllowed": false,
+ "protocol": "openid-connect",
+ "redirectUris": [
+ "http://localhost:8080/*"
+ ],
+ "webOrigins": [
+ "http://localhost:8080"
+ ],
+ "defaultClientScopes": [
+ "openid",
+ "profile",
+ "email",
+ "agent-service-audience"
+ ],
+ "optionalClientScopes": [
+ "agent.chat"
+ ]
+ }
+ ],
+ "users": [
+ {
+ "username": "testuser",
+ "enabled": true,
+ "email": "testuser@example.com",
+ "firstName": "Test",
+ "lastName": "User",
+ "realmRoles": ["agent-chat-user"],
+ "credentials": [
+ {
+ "type": "password",
+ "value": "password",
+ "temporary": false
+ }
+ ]
+ },
+ {
+ "username": "viewer",
+ "enabled": true,
+ "email": "viewer@example.com",
+ "firstName": "View",
+ "lastName": "Only",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "password",
+ "temporary": false
+ }
+ ]
+ }
+ ]
+}
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh
new file mode 100755
index 0000000000..734dcf9996
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+# Adds an extra redirect URI to the Keycloak web-client configuration.
+# Auto-detects GitHub Codespaces via CODESPACE_NAME and
+# GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN environment variables.
+
+set -e
+
+KEYCLOAK_URL="${KEYCLOAK_URL:-http://keycloak:8080}"
+
+# Auto-detect Codespaces
+if [ -n "$CODESPACE_NAME" ] && [ -n "$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" ]; then
+ WEBCLIENT_PUBLIC_URL="https://${CODESPACE_NAME}-8080.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}"
+fi
+
+if [ -z "$WEBCLIENT_PUBLIC_URL" ]; then
+ echo "Not running in Codespaces — skipping redirect URI setup."
+ exit 0
+fi
+
+echo "Configuring Keycloak redirect URIs for: $WEBCLIENT_PUBLIC_URL"
+
+# Get admin token
+TOKEN=$(curl -sf -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \
+ -d "grant_type=password&client_id=admin-cli&username=admin&password=admin" \
+ | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
+
+if [ -z "$TOKEN" ]; then
+ echo "ERROR: Failed to get admin token" >&2
+ exit 1
+fi
+
+# Get web-client UUID
+CLIENT_UUID=$(curl -sf "$KEYCLOAK_URL/admin/realms/dev/clients?clientId=web-client" \
+ -H "Authorization: Bearer $TOKEN" \
+ | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
+
+# Update redirect URIs and web origins
+curl -sf -X PUT "$KEYCLOAK_URL/admin/realms/dev/clients/$CLIENT_UUID" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"redirectUris\": [\"http://localhost:8080/*\", \"${WEBCLIENT_PUBLIC_URL}/*\"],
+ \"webOrigins\": [\"http://localhost:8080\", \"${WEBCLIENT_PUBLIC_URL}\"]
+ }"
+
+echo "Keycloak redirect URIs updated successfully."
From 7ee3d9e9d5661f7e83ce98b3360d628f2ef8dd81 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Fri, 27 Feb 2026 18:04:45 +0000
Subject: [PATCH 2/6] Add fixes to enable running on windows
---
.../AuthClientServer.WebClient/Program.cs | 69 ++++++++++++-------
.../AuthClientServer/docker-compose.yml | 8 ++-
2 files changed, 51 insertions(+), 26 deletions(-)
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs
index 7dc718df6a..8aae7ea7c9 100644
--- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs
@@ -22,14 +22,25 @@
string authority = builder.Configuration["Auth:Authority"]
?? throw new InvalidOperationException("Auth:Authority is not configured.");
-// Auto-detect Codespaces: derive the public Keycloak URL for browser redirects.
-// Authority stays as localhost (reachable on host networking) for backchannel
-// discovery; only browser-facing redirects use the public URL.
+// PublicKeycloakUrl is the browser-facing Keycloak base URL. When the
+// web-client runs inside Docker, Authority points to the internal hostname
+// (e.g. http://keycloak:8080) for backchannel discovery, while
+// PublicKeycloakUrl is what the browser can reach (e.g. http://localhost:5002).
+// When running outside Docker, Authority already IS the public URL and
+// PublicKeycloakUrl is not needed.
+string? publicKeycloakUrl = builder.Configuration["Auth:PublicKeycloakUrl"];
+
+// In Codespaces, override the public URLs with the tunnel endpoints.
string? codespaceName = Environment.GetEnvironmentVariable("CODESPACE_NAME");
string? codespaceDomain = Environment.GetEnvironmentVariable("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN");
-string? publicKeycloakBase = (!string.IsNullOrEmpty(codespaceName) && !string.IsNullOrEmpty(codespaceDomain))
- ? $"https://{codespaceName}-5002.{codespaceDomain}"
- : null;
+bool isCodespaces = !string.IsNullOrEmpty(codespaceName) && !string.IsNullOrEmpty(codespaceDomain);
+if (isCodespaces)
+{
+ publicKeycloakUrl = $"https://{codespaceName}-5002.{codespaceDomain}";
+}
+
+// Derive the internal base URL from Authority for URL rewriting.
+string internalKeycloakBase = new Uri(authority).GetLeftPart(UriPartial.Authority);
builder.Services
.AddAuthentication(options =>
@@ -58,39 +69,51 @@
// For local development with HTTP-only Keycloak
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
- // In Codespaces, the tunnel delivers requests to localhost but the
- // browser must redirect to the public Codespaces URLs. Rewrite the
- // authorization endpoint and redirect URIs so the browser reaches
- // Keycloak and the web-client via the public Codespaces URLs.
- // Issuer validation is disabled because the token is issued via the
- // public URL (issuer = public hostname) but the discovery doc is
- // fetched from localhost (issuer = localhost).
- if (publicKeycloakBase is not null)
+ // When the web-client is inside Docker, the backchannel Authority uses
+ // an internal hostname that differs from the browser-facing URL.
+ // Rewrite the authorization/logout endpoints so the browser is
+ // redirected to the public Keycloak URL, and disable issuer validation
+ // because the token issuer (public URL) won't match the discovery
+ // document issuer (internal URL).
+ if (publicKeycloakUrl is not null)
{
-#pragma warning disable CA5404 // Disabling token validation checks in development environment to allow Codespaces tunnel URL for browser redirects, do not do this in production.
+#pragma warning disable CA5404 // Token issuer validation disabled: backchannel uses internal Docker hostname while tokens are issued via the public URL.
options.TokenValidationParameters.ValidateIssuer = false;
#pragma warning restore CA5404
- // The UserInfo endpoint is on localhost but the token issuer is
- // the public URL — Keycloak rejects the token. The ID token
- // already contains the claims we need, so skip the UserInfo call.
+ // The UserInfo endpoint is on the internal URL but the token
+ // issuer is the public URL — Keycloak rejects the mismatch.
+ // The ID token already contains all needed claims.
options.GetClaimsFromUserInfoEndpoint = false;
- string publicBase = $"https://{codespaceName}-8080.{codespaceDomain}";
+ // In Codespaces the tunnel delivers with Host: localhost, so the
+ // auto-generated redirect_uri is wrong. Override it explicitly.
+ string? publicWebClientBase = isCodespaces
+ ? $"https://{codespaceName}-8080.{codespaceDomain}"
+ : null;
+
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress
- .Replace("http://localhost:5002", publicKeycloakBase);
- context.ProtocolMessage.RedirectUri = $"{publicBase}/signin-oidc";
+ .Replace(internalKeycloakBase, publicKeycloakUrl);
+ if (publicWebClientBase is not null)
+ {
+ context.ProtocolMessage.RedirectUri = $"{publicWebClientBase}/signin-oidc";
+ }
+
return Task.CompletedTask;
},
OnRedirectToIdentityProviderForSignOut = context =>
{
context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress
- .Replace("http://localhost:5002", publicKeycloakBase);
- context.ProtocolMessage.PostLogoutRedirectUri = $"{publicBase}/signout-callback-oidc";
+ .Replace(internalKeycloakBase, publicKeycloakUrl);
+ if (publicWebClientBase is not null)
+ {
+ context.ProtocolMessage.PostLogoutRedirectUri = $"{publicWebClientBase}/signout-callback-oidc";
+ }
+
return Task.CompletedTask;
},
};
diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml b/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml
index 78e36e04bd..7898d71b23 100644
--- a/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml
+++ b/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml
@@ -58,14 +58,16 @@ services:
context: ../../..
dockerfile: samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile
container_name: auth-web-client
- network_mode: host
environment:
- ASPNETCORE_ENVIRONMENT=Development
- - Auth__Authority=http://localhost:5002/realms/dev
+ - Auth__Authority=http://keycloak:8080/realms/dev
+ - Auth__PublicKeycloakUrl=http://localhost:5002
- Auth__ClientId=web-client
- - AgentService__BaseUrl=http://localhost:5001
+ - AgentService__BaseUrl=http://agent-service:5001
- CODESPACE_NAME=${CODESPACE_NAME:-}
- GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-}
+ ports:
+ - "8080:8080"
volumes:
- web-client-keys:/app/keys
depends_on:
From 21e7673fd72e9b7058d5873f0b667d0643878e52 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Fri, 27 Feb 2026 18:37:47 +0000
Subject: [PATCH 3/6] Add launchsettings, add docker-compose to slnx and fix
formatting
---
dotnet/agent-framework-dotnet.slnx | 1 +
.../AuthClientServer.AgentService/Program.cs | 2 +-
.../Properties/launchSettings.json | 12 ++++++++++++
.../AuthClientServer.AgentService/UserContext.cs | 2 +-
.../AuthClientServer.WebClient/Pages/Index.cshtml.cs | 2 +-
.../Properties/launchSettings.json | 12 ++++++++++++
6 files changed, 28 insertions(+), 3 deletions(-)
create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Properties/launchSettings.json
create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Properties/launchSettings.json
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 30c32a8a09..75c4f1dc8a 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -289,6 +289,7 @@