"
+export OPENAI_MODEL="gpt-4.1-mini"
+```
+
+## Running the Sample
+
+### Option 1: Docker Compose (Recommended)
+
+```bash
+cd dotnet/samples/05-end-to-end/AspNetAgentAuthorization
+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 Service
+ dotnet run --urls "http://localhost:5001"
+ ```
+
+3. In another terminal, start the WebClient:
+ ```bash
+ cd RazorWebClient
+ 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`** — can chat, view expenses, and approve expenses (up to €1,000)
+ - **`viewer` / `password`** — can chat and view expenses, but **cannot approve** them
+4. Try asking the agent:
+ - _"Show me the pending expenses"_ — both users can do this
+ - _"Approve expense #1"_ — only `testuser` can do this; `viewer` will be denied
+ - _"Approve expense #3"_ — even `testuser` will be denied (€4,500 exceeds the €1,000 limit)
+
+## 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 |
+| **Scope: expenses.view** | Required to list pending expenses |
+| **Scope: expenses.approve** | Required to approve expenses |
+| **User: testuser** | Has `agent.chat`, `expenses.view`, and `expenses.approve` scopes |
+| **User: viewer** | Has `agent.chat` and `expenses.view` scopes (no approval) |
+
+### Pre-Seeded Expenses
+
+The service starts with five demo expenses:
+
+| # | Description | Amount | Status |
+|---|-------------|--------|--------|
+| 1 | Conference travel — Berlin | €850 | Pending |
+| 2 | Team dinner — Q4 celebration | €320 | Pending |
+| 3 | Cloud infrastructure — annual renewal | €4,500 | Pending (over limit) |
+| 4 | Office supplies — ergonomic keyboards | €675 | Pending |
+| 5 | Client gift baskets — holiday season | €980 | Pending |
+
+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 expenses.view expenses.approve" \
+ | 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": "Show me the pending expenses"}'
+```
+
+## Key Concepts Demonstrated
+
+- **Endpoint-Level Authorization** — The `/chat` endpoint requires the `agent.chat` scope, gating access to the agent itself
+- **Tool-Level Authorization** — Each agent tool checks its own scope (`expenses.view`, `expenses.approve`) at runtime, so different users have different capabilities within the same chat session
+- **Scope-Based Role Mapping** — Keycloak realm roles map to OAuth scopes, allowing administrators to control which users can access which agent capabilities
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile
new file mode 100644
index 0000000000..8e15ba2425
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/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/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/
+
+RUN dotnet restore samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false
+
+# Copy everything and build
+COPY samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/ samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/
+RUN dotnet publish samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.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", "RazorWebClient.dll"]
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml
new file mode 100644
index 0000000000..edccf4c34e
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml
@@ -0,0 +1,35 @@
+@page
+@using Microsoft.AspNetCore.Authorization
+@attribute [Authorize]
+@model AspNetAgentAuthorization.RazorWebClient.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/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml.cs
new file mode 100644
index 0000000000..5326e7ae9d
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/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 AspNetAgentAuthorization.RazorWebClient.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/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml
new file mode 100644
index 0000000000..ab1d7cb1dc
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml
@@ -0,0 +1,18 @@
+@page
+@model AspNetAgentAuthorization.RazorWebClient.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/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml.cs
new file mode 100644
index 0000000000..2547fb6fce
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/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 AspNetAgentAuthorization.RazorWebClient.Pages;
+
+public class IndexModel : PageModel
+{
+ public void OnGet()
+ {
+ }
+
+ public IActionResult OnGetLogout()
+ {
+ return this.SignOut(
+ new AuthenticationProperties { RedirectUri = "/" },
+ CookieAuthenticationDefaults.AuthenticationScheme,
+ OpenIdConnectDefaults.AuthenticationScheme);
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Shared/_Layout.cshtml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Shared/_Layout.cshtml
new file mode 100644
index 0000000000..c44e993624
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Shared/_Layout.cshtml
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Auth Agent Chat
+
+
+
+
+
+ @RenderBody()
+
+
+
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/_ViewImports.cshtml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/_ViewImports.cshtml
new file mode 100644
index 0000000000..71c71463de
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/_ViewImports.cshtml
@@ -0,0 +1,3 @@
+@using Microsoft.AspNetCore.Authentication
+@namespace AspNetAgentAuthorization.RazorWebClient.Pages
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Program.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Program.cs
new file mode 100644
index 0000000000..67fb3063e6
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Program.cs
@@ -0,0 +1,142 @@
+// 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.");
+
+// 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");
+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 =>
+ {
+ 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 scopes so the access token includes them
+ options.Scope.Clear();
+ options.Scope.Add("openid");
+ options.Scope.Add("profile");
+ options.Scope.Add("email");
+ options.Scope.Add("agent.chat");
+ options.Scope.Add("expenses.view");
+ options.Scope.Add("expenses.approve");
+
+ // For local development with HTTP-only Keycloak
+ options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
+
+ // 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 // 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 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;
+
+ // 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(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(internalKeycloakBase, publicKeycloakUrl);
+ if (publicWebClientBase is not null)
+ {
+ context.ProtocolMessage.PostLogoutRedirectUri = $"{publicWebClientBase}/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/AspNetAgentAuthorization/RazorWebClient/Properties/launchSettings.json b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Properties/launchSettings.json
new file mode 100644
index 0000000000..28c3cf0be6
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "RazorWebClient": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:58080;http://localhost:8080"
+ }
+ }
+}
\ No newline at end of file
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj
new file mode 100644
index 0000000000..d1c7fec19a
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ $(NoWarn);CS1591
+
+
+
+
+
+
+
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/appsettings.json b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/appsettings.json
new file mode 100644
index 0000000000..5372dad530
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/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/AspNetAgentAuthorization/Service/Dockerfile b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Dockerfile
new file mode 100644
index 0000000000..69517af95d
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Dockerfile
@@ -0,0 +1,34 @@
+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 nuget/ nuget/
+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 && mkdir -p /dotnet/nuget && cp /repo/nuget/* /dotnet/nuget/
+
+# Copy project files for restore
+COPY src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj src/Microsoft.Agents.AI.Abstractions/
+COPY src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj src/Microsoft.Agents.AI/
+COPY src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj src/Microsoft.Agents.AI.OpenAI/
+COPY samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj samples/05-end-to-end/AspNetAgentAuthorization/Service/
+
+RUN dotnet restore samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false
+
+# Copy everything and build
+COPY src/ src/
+COPY samples/05-end-to-end/AspNetAgentAuthorization/Service/ samples/05-end-to-end/AspNetAgentAuthorization/Service/
+RUN dotnet publish samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.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://+:5001
+EXPOSE 5001
+ENTRYPOINT ["dotnet", "Service.dll"]
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/ExpenseService.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/ExpenseService.cs
new file mode 100644
index 0000000000..d02ab8d409
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/ExpenseService.cs
@@ -0,0 +1,110 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Concurrent;
+using System.ComponentModel;
+
+namespace AspNetAgentAuthorization.Service;
+
+///
+/// Represents an expense awaiting approval.
+///
+public sealed class Expense
+{
+ public int Id { get; init; }
+
+ public string Description { get; init; } = string.Empty;
+
+ public decimal Amount { get; init; }
+
+ public string Submitter { get; init; } = string.Empty;
+
+ public string Status { get; set; } = "Pending";
+
+ public string? ApprovedBy { get; set; }
+}
+
+///
+/// Manages expense approvals. Pre-seeded with demo data so there are
+/// expenses to review immediately. Uses to
+/// identify the caller and enforce scope-based permissions.
+///
+public sealed class ExpenseService
+{
+ /// Maximum amount (EUR) that can be approved.
+ private const decimal ApprovalLimit = 1000m;
+
+ private static readonly ConcurrentDictionary s_expenses = new(
+ new Dictionary
+ {
+ [1] = new() { Id = 1, Description = "Conference travel — Berlin", Amount = 850m, Submitter = "Alice" },
+ [2] = new() { Id = 2, Description = "Team dinner — Q4 celebration", Amount = 320m, Submitter = "Bob" },
+ [3] = new() { Id = 3, Description = "Cloud infrastructure — annual renewal", Amount = 4500m, Submitter = "Carol" },
+ [4] = new() { Id = 4, Description = "Office supplies — ergonomic keyboards", Amount = 675m, Submitter = "Dave" },
+ [5] = new() { Id = 5, Description = "Client gift baskets — holiday season", Amount = 980m, Submitter = "Eve" },
+ });
+
+ private readonly IUserContext _userContext;
+
+ public ExpenseService(IUserContext userContext)
+ {
+ this._userContext = userContext;
+ }
+
+ ///
+ /// Lists all pending expenses awaiting approval.
+ ///
+ [Description("Lists all pending expenses awaiting approval. Requires the expenses.view scope.")]
+ public string ListPendingExpenses()
+ {
+ if (!this._userContext.Scopes.Contains("expenses.view"))
+ {
+ return "Access denied. You do not have the expenses.view scope.";
+ }
+
+ var pending = s_expenses.Values
+ .Where(e => e.Status == "Pending")
+ .OrderBy(e => e.Id)
+ .ToList();
+
+ if (pending.Count == 0)
+ {
+ return "No pending expenses.";
+ }
+
+ return string.Join("\n", pending.Select(e =>
+ $"#{e.Id}: {e.Description} — €{e.Amount:N2} (submitted by {e.Submitter})"));
+ }
+
+ ///
+ /// Approves a pending expense by its ID.
+ ///
+ [Description("Approves a pending expense by its ID. Requires the expenses.approve scope.")]
+ public string ApproveExpense([Description("The ID of the expense to approve")] int expenseId)
+ {
+ if (!this._userContext.Scopes.Contains("expenses.approve"))
+ {
+ return "Access denied. You do not have the expenses.approve scope.";
+ }
+
+ if (!s_expenses.TryGetValue(expenseId, out var expense))
+ {
+ return $"Expense #{expenseId} not found.";
+ }
+
+ if (expense.Status != "Pending")
+ {
+ return $"Expense #{expenseId} has already been approved.";
+ }
+
+ if (expense.Amount > ApprovalLimit)
+ {
+ return $"Cannot approve expense #{expenseId} (€{expense.Amount:N2}). " +
+ $"Amount exceeds the €{ApprovalLimit:N2} approval limit.";
+ }
+
+ expense.Status = "Approved";
+ expense.ApprovedBy = this._userContext.DisplayName;
+
+ return $"Expense #{expenseId} (\"{expense.Description}\", €{expense.Amount:N2}) has been approved.";
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs
new file mode 100644
index 0000000000..b4a5d00a9a
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs
@@ -0,0 +1,125 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to authorize AI agent tools using OAuth 2.0
+// scopes. The /chat endpoint requires the "agent.chat" scope, and each tool
+// checks its own scope (expenses.view, expenses.approve) at runtime.
+
+using System.Security.Claims;
+using System.Text.Json.Serialization;
+using AspNetAgentAuthorization.Service;
+using Microsoft.Agents.AI;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.AI;
+using OpenAI;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+// ---------------------------------------------------------------------------
+// Authentication: JWT Bearer tokens validated against the OIDC provider
+// ---------------------------------------------------------------------------
+builder.Services
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.Authority = builder.Configuration["Auth:Authority"]
+ ?? throw new InvalidOperationException("Auth:Authority is not configured.");
+ options.Audience = builder.Configuration["Auth:Audience"]
+ ?? throw new InvalidOperationException("Auth:Audience is not configured.");
+
+ // For local development with HTTP-only Keycloak
+ options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
+
+ options.TokenValidationParameters.ValidateAudience = true;
+ options.TokenValidationParameters.ValidateLifetime = true;
+
+ // In Codespaces, tokens are issued with the public tunnel URL as
+ // issuer (Keycloak sees X-Forwarded-Host from the tunnel) but the
+ // agent-service discovers Keycloak via the internal Docker hostname.
+ // Disable issuer validation in development to handle this mismatch.
+ options.TokenValidationParameters.ValidateIssuer = !builder.Environment.IsDevelopment();
+ });
+
+// ---------------------------------------------------------------------------
+// Authorization: policy requiring the "agent.chat" scope
+// ---------------------------------------------------------------------------
+builder.Services.AddAuthorizationBuilder()
+ .AddPolicy("AgentChat", policy =>
+ policy.RequireAuthenticatedUser()
+ .RequireAssertion(context =>
+ {
+ // Keycloak puts scopes in the "scope" claim (space-delimited)
+ var scopeClaim = context.User.FindFirstValue("scope");
+ if (scopeClaim is not null)
+ {
+ var scopes = scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (scopes.Contains("agent.chat", StringComparer.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }));
+
+// ---------------------------------------------------------------------------
+// Configure JSON serialization
+// ---------------------------------------------------------------------------
+builder.Services.ConfigureHttpJsonOptions(options =>
+ options.SerializerOptions.TypeInfoResolverChain.Add(SampleServiceSerializerContext.Default));
+
+// ---------------------------------------------------------------------------
+// Create the AI agent with expense approval tools, registered in DI
+// ---------------------------------------------------------------------------
+string apiKey = builder.Configuration["OPENAI_API_KEY"]
+ ?? throw new InvalidOperationException("Set the OPENAI_API_KEY environment variable.");
+string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini";
+
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped(sp =>
+{
+ var expenseService = sp.GetRequiredService();
+
+ return new OpenAIClient(apiKey)
+ .GetChatClient(model)
+ .AsIChatClient()
+ .AsAIAgent(
+ name: "ExpenseApprovalAgent",
+ instructions: "You are an expense approval assistant. You can list pending expenses "
+ + "and approve them if the user has the required permissions and approval limit. "
+ + "Keep responses concise.",
+ tools:
+ [
+ AIFunctionFactory.Create(expenseService.ListPendingExpenses),
+ AIFunctionFactory.Create(expenseService.ApproveExpense),
+ ]);
+});
+
+WebApplication app = builder.Build();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+// ---------------------------------------------------------------------------
+// POST /chat — requires the "agent.chat" scope
+// ---------------------------------------------------------------------------
+app.MapPost("/chat", [Authorize(Policy = "AgentChat")] async (ChatRequest request, IUserContext userContext, AIAgent agent) =>
+{
+ var response = await agent.RunAsync(request.Message);
+
+ return Results.Ok(new ChatResponse(response.Text, userContext.DisplayName));
+});
+
+await app.RunAsync();
+
+// ---------------------------------------------------------------------------
+// Request / Response models
+// ---------------------------------------------------------------------------
+internal sealed record ChatRequest(string Message);
+internal sealed record ChatResponse(string Reply, string User);
+
+[JsonSerializable(typeof(ChatRequest))]
+[JsonSerializable(typeof(ChatResponse))]
+internal sealed partial class SampleServiceSerializerContext : JsonSerializerContext;
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Properties/launchSettings.json b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Properties/launchSettings.json
new file mode 100644
index 0000000000..6366505896
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "Service": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:55001;http://localhost:5001"
+ }
+ }
+}
\ No newline at end of file
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj
new file mode 100644
index 0000000000..40b91fcd86
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ $(NoWarn);CS1591
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs
new file mode 100644
index 0000000000..34f4fe8956
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Security.Claims;
+
+namespace AspNetAgentAuthorization.Service;
+
+///
+/// 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; }
+
+ /// OAuth scopes granted in the current access token.
+ IReadOnlySet Scopes { 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, given_name/family_name for the
+/// display name, and scope (space-delimited) for granted scopes.
+/// 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 IReadOnlySet Scopes { 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,
+ };
+
+ string? scopeClaim = user?.FindFirstValue("scope");
+ this.Scopes = scopeClaim is not null
+ ? new HashSet(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase)
+ : new HashSet(StringComparer.OrdinalIgnoreCase);
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/appsettings.json b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/appsettings.json
new file mode 100644
index 0000000000..c5275372ad
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/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/AspNetAgentAuthorization/docker-compose.yml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml
new file mode 100644
index 0000000000..eb9e356e72
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml
@@ -0,0 +1,80 @@
+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/AspNetAgentAuthorization/Service/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/AspNetAgentAuthorization/RazorWebClient/Dockerfile
+ container_name: auth-web-client
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ - Auth__Authority=http://keycloak:8080/realms/dev
+ - Auth__PublicKeycloakUrl=http://localhost:5002
+ - Auth__ClientId=web-client
+ - 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:
+ keycloak:
+ condition: service_healthy
+ agent-service:
+ condition: service_started
+
+volumes:
+ web-client-keys:
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/dev-realm.json b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/dev-realm.json
new file mode 100644
index 0000000000..41e8ce3038
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/dev-realm.json
@@ -0,0 +1,232 @@
+{
+ "realm": "dev",
+ "enabled": true,
+ "sslRequired": "none",
+ "registrationAllowed": false,
+ "roles": {
+ "realm": [
+ {
+ "name": "agent-chat-user",
+ "description": "Grants access to the agent.chat scope"
+ },
+ {
+ "name": "expenses-viewer",
+ "description": "Grants access to the expenses.view scope"
+ },
+ {
+ "name": "expenses-approver",
+ "description": "Grants access to the expenses.approve scope"
+ }
+ ]
+ },
+ "scopeMappings": [
+ {
+ "clientScope": "agent.chat",
+ "roles": ["agent-chat-user"]
+ },
+ {
+ "clientScope": "expenses.view",
+ "roles": ["expenses-viewer"]
+ },
+ {
+ "clientScope": "expenses.approve",
+ "roles": ["expenses-approver"]
+ }
+ ],
+ "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": "expenses.view",
+ "description": "Allows viewing pending expenses",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true"
+ }
+ },
+ {
+ "name": "expenses.approve",
+ "description": "Allows approving pending expenses",
+ "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",
+ "expenses.view",
+ "expenses.approve"
+ ]
+ }
+ ],
+ "users": [
+ {
+ "username": "testuser",
+ "enabled": true,
+ "email": "testuser@example.com",
+ "firstName": "Test",
+ "lastName": "User",
+ "realmRoles": ["agent-chat-user", "expenses-viewer", "expenses-approver"],
+ "credentials": [
+ {
+ "type": "password",
+ "value": "password",
+ "temporary": false
+ }
+ ]
+ },
+ {
+ "username": "viewer",
+ "enabled": true,
+ "email": "viewer@example.com",
+ "firstName": "View",
+ "lastName": "Only",
+ "realmRoles": ["agent-chat-user", "expenses-viewer"],
+ "credentials": [
+ {
+ "type": "password",
+ "value": "password",
+ "temporary": false
+ }
+ ]
+ }
+ ]
+}
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/setup-redirect-uris.sh b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/setup-redirect-uris.sh
new file mode 100755
index 0000000000..b49cfc4e80
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/setup-redirect-uris.sh
@@ -0,0 +1,50 @@
+#!/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')
+
+if [ -z "$CLIENT_UUID" ]; then
+ echo "ERROR: Failed to find web-client UUID" >&2
+ exit 1
+fi
+# 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."