diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 396a316575..1b1e0daa08 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -58,6 +58,8 @@ + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 1ab73c2b8b..b35178f011 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -288,6 +288,12 @@ + + + + + + diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md new file mode 100644 index 0000000000..c84dd125c3 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md @@ -0,0 +1,156 @@ +# Auth Client-Server Sample + +This sample demonstrates how to authorize AI agents and their tools using OAuth 2.0 scopes. It shows two levels of access control: an endpoint-level scope (`agent.chat`) that gates access to the agent, and tool-level scopes (`expenses.view`, `expenses.approve`) that control what the agent can do on behalf of each user. + +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 an expense approval agent with scope-authorized tools | +| **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 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."