From c4c0ba6f4da87727d496c1df623193198555be82 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:16:13 +0000 Subject: [PATCH 1/6] Add sample demonstrating authentication and user access in agent tools --- dotnet/Directory.Packages.props | 2 + dotnet/agent-framework-dotnet.slnx | 5 + .../AuthClientServer.AgentService.csproj | 20 ++ .../AuthClientServer.AgentService/Dockerfile | 34 +++ .../AuthClientServer.AgentService/Program.cs | 145 +++++++++++++ .../TodoService.cs | 47 +++++ .../UserContext.cs | 59 ++++++ .../appsettings.json | 12 ++ .../AuthClientServer.WebClient.csproj | 15 ++ .../AuthClientServer.WebClient/Dockerfile | 29 +++ .../Pages/Chat.cshtml | 35 ++++ .../Pages/Chat.cshtml.cs | 79 +++++++ .../Pages/Index.cshtml | 18 ++ .../Pages/Index.cshtml.cs | 24 +++ .../Pages/Shared/_Layout.cshtml | 35 ++++ .../Pages/_ViewImports.cshtml | 3 + .../AuthClientServer.WebClient/Program.cs | 117 +++++++++++ .../appsettings.json | 15 ++ .../05-end-to-end/AuthClientServer/README.md | 147 +++++++++++++ .../AuthClientServer/docker-compose.yml | 78 +++++++ .../AuthClientServer/keycloak/dev-realm.json | 195 ++++++++++++++++++ .../keycloak/setup-redirect-uris.sh | 46 +++++ 22 files changed, 1160 insertions(+) create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Dockerfile create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/TodoService.cs create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/appsettings.json create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml.cs create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Shared/_Layout.cshtml create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/_ViewImports.cshtml create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/appsettings.json create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/README.md create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json create mode 100755 dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh 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 2bb11b01b8..358a000b63 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -287,6 +287,11 @@ + + + + + diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj new file mode 100644 index 0000000000..40b91fcd86 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + $(NoWarn);CS1591 + + + + + + + + + + + + diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Dockerfile b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Dockerfile new file mode 100644 index 0000000000..11a30976e3 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/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/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ + +RUN dotnet restore samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false + +# Copy everything and build +COPY src/ src/ +COPY samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ +RUN dotnet publish samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.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", "AuthClientServer.AgentService.dll"] diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs new file mode 100644 index 0000000000..3c732bab42 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to secure an AI agent REST API with +// JWT Bearer authentication and policy-based scope authorization. + +using System.Security.Claims; +using System.Text.Json.Serialization; +using AuthClientServer.AgentService; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Agents.AI; +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)); + +// --------------------------------------------------------------------------- +// CORS: allow the WebClient origin +// --------------------------------------------------------------------------- +builder.Services.AddCors(options => + options.AddDefaultPolicy(policy => + policy.WithOrigins("http://localhost:8080") + .AllowAnyHeader() + .AllowAnyMethod())); + +// --------------------------------------------------------------------------- +// Create the AI agent with TODO 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 todoService = sp.GetRequiredService(); + + return new OpenAIClient(apiKey) + .GetChatClient(model) + .AsIChatClient() + .AsAIAgent( + name: "AuthDemoAgent", + instructions: "You are a helpful assistant that can manage the user's TODO list. " + + "Use the available tools to list and add TODO items when asked. " + + "Keep responses concise.", + tools: + [ + AIFunctionFactory.Create(todoService.ListTodos), + AIFunctionFactory.Create(todoService.AddTodo), + ]); +}); + +WebApplication app = builder.Build(); + +app.UseCors(); +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)); +}); + +// --------------------------------------------------------------------------- +// GET /me — returns the caller's identity (any authenticated user) +// --------------------------------------------------------------------------- +app.MapGet("/me", [Authorize] (ClaimsPrincipal user) => +{ + var claims = user.Claims.Select(c => new ClaimInfo(c.Type, c.Value)); + return Results.Ok(claims); +}); + +await app.RunAsync(); + +// --------------------------------------------------------------------------- +// Request / Response models +// --------------------------------------------------------------------------- +internal sealed record ChatRequest(string Message); +internal sealed record ChatResponse(string Reply, string User); +internal sealed record ClaimInfo(string Type, string Value); + +[JsonSerializable(typeof(ChatRequest))] +[JsonSerializable(typeof(ChatResponse))] +[JsonSerializable(typeof(IEnumerable))] +internal sealed partial class SampleServiceSerializerContext : JsonSerializerContext; diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/TodoService.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/TodoService.cs new file mode 100644 index 0000000000..198235b403 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/TodoService.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.ComponentModel; + +namespace AuthClientServer.AgentService; + +/// +/// Manages per-user TODO lists. Uses to identify +/// the current caller without coupling to HTTP or claim-parsing details. +/// +public sealed class TodoService +{ + private static readonly ConcurrentDictionary> s_todos = new(); + + private readonly IUserContext _userContext; + + public TodoService(IUserContext userContext) + { + this._userContext = userContext; + } + + /// + /// Lists all TODO items for the currently authenticated user. + /// + [Description("Lists all TODO items for the current user.")] + public string ListTodos() + { + var items = s_todos.GetOrAdd(this._userContext.UserId, _ => []); + + return items.IsEmpty + ? "You have no TODO items." + : string.Join("\n", items.Select((item, i) => $"{i + 1}. {item}")); + } + + /// + /// Adds a new TODO item for the currently authenticated user. + /// + [Description("Adds a new TODO item for the current user.")] + public string AddTodo([Description("The TODO item to add")] string item) + { + var items = s_todos.GetOrAdd(this._userContext.UserId, _ => []); + items.Add(item); + + return $"Added \"{item}\" to your TODO list."; + } +} diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs new file mode 100644 index 0000000000..39ac1cc6ae --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Security.Claims; + +namespace AuthClientServer.AgentService; + +/// +/// Provides the authenticated user's identity for the current request. +/// +public interface IUserContext +{ + /// Unique identifier for the current user (e.g. the OIDC "sub" claim). + string UserId { get; } + + /// Login name for the current user. + string UserName { get; } + + /// Human-readable display name (e.g. "Test User"). + string DisplayName { get; } +} + +/// +/// Resolves the current user's identity from Keycloak-specific JWT claims. +/// Keycloak uses sub for the user ID, preferred_username +/// for the login name, and given_name/family_name for the +/// display name. Registered as a scoped service so it is resolved once +/// per request. +/// +public sealed class KeycloakUserContext : IUserContext +{ + public string UserId { get; } + + public string UserName { get; } + + public string DisplayName { get; } + + public KeycloakUserContext(IHttpContextAccessor httpContextAccessor) + { + ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User; + + this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user?.FindFirstValue("sub") + ?? "anonymous"; + + this.UserName = user?.FindFirstValue("preferred_username") + ?? user?.FindFirstValue(ClaimTypes.Name) + ?? "unknown"; + + string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName); + string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname); + this.DisplayName = (givenName, familyName) switch + { + (not null, not null) => $"{givenName} {familyName}", + (not null, null) => givenName, + (null, not null) => familyName, + _ => this.UserName, + }; + } +} diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/appsettings.json b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/appsettings.json new file mode 100644 index 0000000000..c5275372ad --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Auth": { + "Authority": "http://localhost:5002/realms/dev", + "Audience": "agent-service" + } +} diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj new file mode 100644 index 0000000000..d1c7fec19a --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + $(NoWarn);CS1591 + + + + + + + diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile new file mode 100644 index 0000000000..06af15686f --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile @@ -0,0 +1,29 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /repo + +# Copy solution-level files for restore +COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json nuget.config ./ +COPY eng/ eng/ +COPY src/Shared/ src/Shared/ +COPY samples/Directory.Build.props samples/ + +# Create sentinel file so $(RepoRoot) resolves correctly inside the container. +# RepoRoot is the parent of the dir containing CODE_OF_CONDUCT.md, +# and src projects import $(RepoRoot)/dotnet/nuget/nuget-package.props. +RUN touch /CODE_OF_CONDUCT.md + +# Copy project file for restore +COPY samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/ + +RUN dotnet restore samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false + +# Copy everything and build +COPY samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/ samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/ +RUN dotnet publish samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "AuthClientServer.WebClient.dll"] diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml new file mode 100644 index 0000000000..60db3641b0 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml @@ -0,0 +1,35 @@ +@page +@using Microsoft.AspNetCore.Authorization +@attribute [Authorize] +@model AuthClientServer.WebClient.Pages.ChatModel +@{ + Layout = "_Layout"; +} + +

Chat with the Agent

+ +
+
+ + +
+
+ +@if (Model.Error is not null) +{ +
+ Error: @Model.Error +
+} + +@if (Model.Reply is not null) +{ +
+
Agent (responding to @Model.ReplyUser):
+
@Model.Reply
+
+} diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml.cs new file mode 100644 index 0000000000..87c9b3e333 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace AuthClientServer.WebClient.Pages; + +public class ChatModel : PageModel +{ + private readonly IHttpClientFactory _httpClientFactory; + + public ChatModel(IHttpClientFactory httpClientFactory) + { + this._httpClientFactory = httpClientFactory; + } + + [BindProperty] + public string? Message { get; set; } + + public string? Reply { get; set; } + public string? ReplyUser { get; set; } + public string? Error { get; set; } + + public void OnGet() + { + } + + public async Task OnPostAsync() + { + if (string.IsNullOrWhiteSpace(this.Message)) + { + return; + } + + try + { + // Get the access token stored during OIDC login + string? accessToken = await this.HttpContext.GetTokenAsync("access_token"); + if (accessToken is null) + { + this.Error = "No access token available. Please log in again."; + return; + } + + // Call the AgentService with the Bearer token + var client = this._httpClientFactory.CreateClient("AgentService"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var payload = JsonSerializer.Serialize(new { message = this.Message }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + + var response = await client.PostAsync(new Uri("/chat", UriKind.Relative), content); + + if (response.IsSuccessStatusCode) + { + using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + this.Reply = json.RootElement.GetProperty("reply").GetString(); + this.ReplyUser = json.RootElement.GetProperty("user").GetString(); + } + else + { + this.Error = response.StatusCode switch + { + System.Net.HttpStatusCode.Unauthorized => "Authentication failed (401). Your session may have expired.", + System.Net.HttpStatusCode.Forbidden => "Access denied (403). Your account does not have the required 'agent.chat' scope.", + _ => $"AgentService returned {(int)response.StatusCode} {response.ReasonPhrase}." + }; + } + } + catch (Exception ex) + { + this.Error = $"Failed to contact the AgentService: {ex.Message}"; + } + } +} diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml new file mode 100644 index 0000000000..248459101c --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml @@ -0,0 +1,18 @@ +@page +@model AuthClientServer.WebClient.Pages.IndexModel +@{ + Layout = "_Layout"; +} + +

Welcome

+

This sample demonstrates securing an AI agent API with OAuth 2.0 / OpenID Connect.

+ +@if (User.Identity?.IsAuthenticated == true) +{ +

You are logged in as @User.Identity.Name.

+

Go to Chat →

+} +else +{ +

Please log in to chat with the agent.

+} diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs new file mode 100644 index 0000000000..c35fc7a196 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace AuthClientServer.WebClient.Pages; + +public class IndexModel : PageModel +{ + public void OnGet() + { + } + + public IActionResult OnGetLogout() + { + return SignOut( + new AuthenticationProperties { RedirectUri = "/" }, + CookieAuthenticationDefaults.AuthenticationScheme, + OpenIdConnectDefaults.AuthenticationScheme); + } +} diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Shared/_Layout.cshtml b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000000..c44e993624 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Shared/_Layout.cshtml @@ -0,0 +1,35 @@ + + + + + + Auth Agent Chat + + + + +
+ @RenderBody() +
+ + diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/_ViewImports.cshtml b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/_ViewImports.cshtml new file mode 100644 index 0000000000..2c167029a9 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using Microsoft.AspNetCore.Authentication +@namespace AuthClientServer.WebClient.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs new file mode 100644 index 0000000000..7dc718df6a --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates an OIDC-authenticated Razor Pages web client +// that calls a JWT-secured AI agent REST API. + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorPages(); + +// Persist data protection keys so antiforgery tokens survive container rebuilds +builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo("/app/keys")); + +// --------------------------------------------------------------------------- +// Authentication: Cookie + OpenID Connect (Keycloak) +// --------------------------------------------------------------------------- +string authority = builder.Configuration["Auth:Authority"] + ?? throw new InvalidOperationException("Auth:Authority is not configured."); + +// Auto-detect Codespaces: derive the public Keycloak URL for browser redirects. +// Authority stays as localhost (reachable on host networking) for backchannel +// discovery; only browser-facing redirects use the public URL. +string? codespaceName = Environment.GetEnvironmentVariable("CODESPACE_NAME"); +string? codespaceDomain = Environment.GetEnvironmentVariable("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN"); +string? publicKeycloakBase = (!string.IsNullOrEmpty(codespaceName) && !string.IsNullOrEmpty(codespaceDomain)) + ? $"https://{codespaceName}-5002.{codespaceDomain}" + : null; + +builder.Services + .AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .AddCookie() + .AddOpenIdConnect(options => + { + options.Authority = authority; + options.ClientId = builder.Configuration["Auth:ClientId"] + ?? throw new InvalidOperationException("Auth:ClientId is not configured."); + + options.ResponseType = OpenIdConnectResponseType.Code; + options.SaveTokens = true; + options.GetClaimsFromUserInfoEndpoint = true; + + // Request the agent.chat scope so the access token includes it + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("email"); + options.Scope.Add("agent.chat"); + + // For local development with HTTP-only Keycloak + options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); + + // In Codespaces, the tunnel delivers requests to localhost but the + // browser must redirect to the public Codespaces URLs. Rewrite the + // authorization endpoint and redirect URIs so the browser reaches + // Keycloak and the web-client via the public Codespaces URLs. + // Issuer validation is disabled because the token is issued via the + // public URL (issuer = public hostname) but the discovery doc is + // fetched from localhost (issuer = localhost). + if (publicKeycloakBase is not null) + { +#pragma warning disable CA5404 // Disabling token validation checks in development environment to allow Codespaces tunnel URL for browser redirects, do not do this in production. + options.TokenValidationParameters.ValidateIssuer = false; +#pragma warning restore CA5404 + + // The UserInfo endpoint is on localhost but the token issuer is + // the public URL — Keycloak rejects the token. The ID token + // already contains the claims we need, so skip the UserInfo call. + options.GetClaimsFromUserInfoEndpoint = false; + + string publicBase = $"https://{codespaceName}-8080.{codespaceDomain}"; + options.Events = new OpenIdConnectEvents + { + OnRedirectToIdentityProvider = context => + { + context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress + .Replace("http://localhost:5002", publicKeycloakBase); + context.ProtocolMessage.RedirectUri = $"{publicBase}/signin-oidc"; + return Task.CompletedTask; + }, + OnRedirectToIdentityProviderForSignOut = context => + { + context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress + .Replace("http://localhost:5002", publicKeycloakBase); + context.ProtocolMessage.PostLogoutRedirectUri = $"{publicBase}/signout-callback-oidc"; + return Task.CompletedTask; + }, + }; + } + }); + +// --------------------------------------------------------------------------- +// HttpClient for calling the AgentService — attaches Bearer token +// --------------------------------------------------------------------------- +builder.Services.AddHttpClient("AgentService", client => +{ + string baseUrl = builder.Configuration["AgentService:BaseUrl"] ?? "http://localhost:5001"; + client.BaseAddress = new Uri(baseUrl); +}); + +WebApplication app = builder.Build(); + +app.UseStaticFiles(); +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapRazorPages(); + +await app.RunAsync(); diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/appsettings.json b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/appsettings.json new file mode 100644 index 0000000000..5372dad530 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Auth": { + "Authority": "http://localhost:5002/realms/dev", + "ClientId": "web-client" + }, + "AgentService": { + "BaseUrl": "http://localhost:5001" + } +} diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/README.md b/dotnet/samples/05-end-to-end/AuthClientServer/README.md new file mode 100644 index 0000000000..0dd98d3abb --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/README.md @@ -0,0 +1,147 @@ +# Auth Client-Server Sample + +This sample demonstrates how to secure an AI agent REST API with standards-based authentication and authorization using OAuth 2.0 / OpenID Connect, JWT Bearer tokens, and policy-based scope enforcement. + +While this sample uses Keycloak to avoid complex setup in order to run the sample, Keycloak can easily be replaced with any OIDC compatible provider, including [Microsoft Entra Id](https://www.microsoft.com/security/business/identity-access/microsoft-entra-id). + +## Overview + +The sample has three components, all launched with a single `docker compose up`: + +| Service | Port | Description | +|---------|------|-------------| +| **WebClient** | `http://localhost:8080` | Razor Pages web app with OIDC login and a chat UI that calls the AgentService | +| **AgentService** | `http://localhost:5001` | ASP.NET Minimal API hosting a `ChatClientAgent`, secured with JWT Bearer auth and scope-based policies | +| **Keycloak** | `http://localhost:5002` | OIDC identity provider, auto-provisioned with realm, clients, scopes, and test users | + +``` +┌──────────────┐ OIDC login ┌───────────┐ +│ WebClient │ ◄──────────────────► │ Keycloak │ +│ (Razor app) │ (browser flow) │ (Docker) │ +│ :8080 │ │ :5002 │ +└──────┬───────┘ └─────┬─────┘ + │ REST + Bearer token │ + ▼ │ +┌───────────────┐ JWT validation ──────┘ +│ AgentService │ ◄──── (jwks from Keycloak) +│ (Minimal API) │ +│ :5001 │ +└───────────────┘ +``` + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) and Docker Compose + +## Configuring Environment Variables + +The AgentService requires an OpenAI-compatible endpoint. Set these environment variables before running: + +```bash +export OPENAI_API_KEY="" +export OPENAI_MODEL="gpt-4.1-mini" +``` + +## Running the Sample + +### Option 1: Docker Compose (Recommended) + +```bash +cd samples/AuthClientServer +docker compose up +``` + +This starts Keycloak, the AgentService, and the WebClient. Wait for Keycloak to finish importing the realm (you'll see `Running the server` in the logs). + +#### Running in GitHub Codespaces + +This sample has been built in such a way that it can be run from GitHub Codespaces. +The Agent Framework repository has a C# specific dev container, named "C# (.NET)", that is configured for Codespaces. + +When running in Codespaces, the sample auto-detects the environment via +`CODESPACE_NAME` and `GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN` and configures +Keycloak and the web client accordingly. Just make the required ports public: + +```bash +# Make Keycloak and WebClient ports publicly accessible +gh codespace ports visibility 5002:public 8080:public -c $CODESPACE_NAME + +# Start the containers (Codespaces is auto-detected) +docker compose up +``` + +Then open the Codespaces-forwarded URL for port 8080 (shown in the **Ports** tab) in your browser. + +### Option 2: Run Locally + +1. Start Keycloak: + ```bash + docker compose up keycloak + ``` + +2. In a new terminal, start the AgentService: + ```bash + cd AuthClientServer.AgentService + dotnet run --urls "http://localhost:5001" + ``` + +3. In another terminal, start the WebClient: + ```bash + cd AuthClientServer.WebClient + dotnet run --urls "http://localhost:8080" + ``` + +## Using the Sample + +1. Open `http://localhost:8080` in your browser +2. Click **Login** — you'll be redirected to Keycloak +3. Sign in with one of the pre-configured users: + - **`testuser` / `password`** — has the `agent.chat` scope, can chat with the agent + - **`viewer` / `password`** — lacks the `agent.chat` scope, will receive a 403 Forbidden when trying to chat +4. Type a message and click **Send** to chat with the agent + +## Pre-Configured Keycloak Realm + +The `keycloak/dev-realm.json` file auto-provisions: + +| Resource | Details | +|----------|---------| +| **Realm** | `dev` | +| **Client: agent-service** | Confidential client (the API audience) | +| **Client: web-client** | Public client for the Razor app's OIDC login | +| **Scope: agent.chat** | Required to call the `/chat` endpoint | +| **User: testuser** | Has `agent.chat` scope | +| **User: viewer** | Does not have `agent.chat` scope | + +Keycloak admin console: `http://localhost:5002` (login: `admin` / `admin`). + +## API Endpoints + +### POST /chat (requires `agent.chat` scope) + +```bash +# Get a token for testuser +TOKEN=$(curl -s -X POST http://localhost:5002/realms/dev/protocol/openid-connect/token \ + -d "grant_type=password&client_id=web-client&username=testuser&password=password&scope=openid agent.chat" \ + | jq -r '.access_token') + +# Chat with the agent +curl -X POST http://localhost:5001/chat \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"message": "Hello, what can you help me with?"}' +``` + +### GET /me (requires any valid token) + +```bash +curl http://localhost:5001/me -H "Authorization: Bearer $TOKEN" +``` + +## Key Concepts Demonstrated + +- **JWT Bearer Authentication** — The AgentService validates tokens from Keycloak using OIDC discovery +- **Policy-Based Authorization** — The `/chat` endpoint requires the `agent.chat` scope in the token +- **Caller Identity** — The service reads the caller's identity from `HttpContext.User` (ClaimsPrincipal) +- **OIDC Login Flow** — The WebClient uses OpenID Connect authorization code flow with Keycloak +- **Token Forwarding** — The WebClient stores the access token and sends it as a Bearer token to the AgentService diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml b/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml new file mode 100644 index 0000000000..78e36e04bd --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml @@ -0,0 +1,78 @@ +services: + keycloak: + image: quay.io/keycloak/keycloak:latest + container_name: auth-keycloak + environment: + - KC_BOOTSTRAP_ADMIN_USERNAME=admin + - KC_BOOTSTRAP_ADMIN_PASSWORD=admin + - KC_HOSTNAME_STRICT=false + - KC_PROXY_HEADERS=xforwarded + volumes: + - ./keycloak/dev-realm.json:/opt/keycloak/data/import/dev-realm.json + command: ["start-dev", "--import-realm"] + ports: + - "5002:8080" + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/master HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200'"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + + # One-shot init container that registers the Codespaces redirect URI + # with Keycloak after it becomes healthy. Auto-detects Codespaces via + # CODESPACE_NAME and GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN env vars. + keycloak-init: + image: curlimages/curl:latest + container_name: auth-keycloak-init + environment: + - KEYCLOAK_URL=http://keycloak:8080 + - CODESPACE_NAME=${CODESPACE_NAME:-} + - GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-} + volumes: + - ./keycloak/setup-redirect-uris.sh:/setup-redirect-uris.sh:ro + entrypoint: ["sh", "/setup-redirect-uris.sh"] + depends_on: + keycloak: + condition: service_healthy + + agent-service: + build: + context: ../../.. + dockerfile: samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Dockerfile + container_name: auth-agent-service + environment: + - ASPNETCORE_ENVIRONMENT=Development + - Auth__Authority=http://keycloak:8080/realms/dev + - Auth__Audience=agent-service + - OPENAI_API_KEY=${OPENAI_API_KEY} + - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4.1-mini} + ports: + - "5001:5001" + depends_on: + keycloak: + condition: service_healthy + + web-client: + build: + context: ../../.. + dockerfile: samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile + container_name: auth-web-client + network_mode: host + environment: + - ASPNETCORE_ENVIRONMENT=Development + - Auth__Authority=http://localhost:5002/realms/dev + - Auth__ClientId=web-client + - AgentService__BaseUrl=http://localhost:5001 + - CODESPACE_NAME=${CODESPACE_NAME:-} + - GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-} + volumes: + - web-client-keys:/app/keys + depends_on: + keycloak: + condition: service_healthy + agent-service: + condition: service_started + +volumes: + web-client-keys: diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json new file mode 100644 index 0000000000..a59728d276 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json @@ -0,0 +1,195 @@ +{ + "realm": "dev", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "roles": { + "realm": [ + { + "name": "agent-chat-user", + "description": "Grants access to the agent.chat scope" + } + ] + }, + "scopeMappings": [ + { + "clientScope": "agent.chat", + "roles": ["agent-chat-user"] + } + ], + "clientScopes": [ + { + "name": "openid", + "description": "OpenID Connect scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + }, + "protocolMappers": [ + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "name": "profile", + "description": "OpenID Connect profile scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + }, + "protocolMappers": [ + { + "name": "preferred_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "username", + "claim.name": "preferred_username", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "given_name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "firstName", + "claim.name": "given_name", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "family_name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "lastName", + "claim.name": "family_name", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "name": "email", + "description": "OpenID Connect email scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + } + }, + { + "name": "agent.chat", + "description": "Allows chatting with the agent", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "name": "agent-service-audience", + "description": "Adds the agent-service audience to access tokens", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "agent-service-audience-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "agent-service", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + } + ], + "clients": [ + { + "clientId": "agent-service", + "enabled": true, + "publicClient": false, + "secret": "agent-service-secret", + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "standardFlowEnabled": false, + "protocol": "openid-connect" + }, + { + "clientId": "web-client", + "enabled": true, + "publicClient": true, + "directAccessGrantsEnabled": true, + "standardFlowEnabled": true, + "fullScopeAllowed": false, + "protocol": "openid-connect", + "redirectUris": [ + "http://localhost:8080/*" + ], + "webOrigins": [ + "http://localhost:8080" + ], + "defaultClientScopes": [ + "openid", + "profile", + "email", + "agent-service-audience" + ], + "optionalClientScopes": [ + "agent.chat" + ] + } + ], + "users": [ + { + "username": "testuser", + "enabled": true, + "email": "testuser@example.com", + "firstName": "Test", + "lastName": "User", + "realmRoles": ["agent-chat-user"], + "credentials": [ + { + "type": "password", + "value": "password", + "temporary": false + } + ] + }, + { + "username": "viewer", + "enabled": true, + "email": "viewer@example.com", + "firstName": "View", + "lastName": "Only", + "credentials": [ + { + "type": "password", + "value": "password", + "temporary": false + } + ] + } + ] +} diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh new file mode 100755 index 0000000000..734dcf9996 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Adds an extra redirect URI to the Keycloak web-client configuration. +# Auto-detects GitHub Codespaces via CODESPACE_NAME and +# GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN environment variables. + +set -e + +KEYCLOAK_URL="${KEYCLOAK_URL:-http://keycloak:8080}" + +# Auto-detect Codespaces +if [ -n "$CODESPACE_NAME" ] && [ -n "$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" ]; then + WEBCLIENT_PUBLIC_URL="https://${CODESPACE_NAME}-8080.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}" +fi + +if [ -z "$WEBCLIENT_PUBLIC_URL" ]; then + echo "Not running in Codespaces — skipping redirect URI setup." + exit 0 +fi + +echo "Configuring Keycloak redirect URIs for: $WEBCLIENT_PUBLIC_URL" + +# Get admin token +TOKEN=$(curl -sf -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \ + -d "grant_type=password&client_id=admin-cli&username=admin&password=admin" \ + | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') + +if [ -z "$TOKEN" ]; then + echo "ERROR: Failed to get admin token" >&2 + exit 1 +fi + +# Get web-client UUID +CLIENT_UUID=$(curl -sf "$KEYCLOAK_URL/admin/realms/dev/clients?clientId=web-client" \ + -H "Authorization: Bearer $TOKEN" \ + | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') + +# Update redirect URIs and web origins +curl -sf -X PUT "$KEYCLOAK_URL/admin/realms/dev/clients/$CLIENT_UUID" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"redirectUris\": [\"http://localhost:8080/*\", \"${WEBCLIENT_PUBLIC_URL}/*\"], + \"webOrigins\": [\"http://localhost:8080\", \"${WEBCLIENT_PUBLIC_URL}\"] + }" + +echo "Keycloak redirect URIs updated successfully." From 7ee3d9e9d5661f7e83ce98b3360d628f2ef8dd81 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:04:45 +0000 Subject: [PATCH 2/6] Add fixes to enable running on windows --- .../AuthClientServer.WebClient/Program.cs | 69 ++++++++++++------- .../AuthClientServer/docker-compose.yml | 8 ++- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs index 7dc718df6a..8aae7ea7c9 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs @@ -22,14 +22,25 @@ string authority = builder.Configuration["Auth:Authority"] ?? throw new InvalidOperationException("Auth:Authority is not configured."); -// Auto-detect Codespaces: derive the public Keycloak URL for browser redirects. -// Authority stays as localhost (reachable on host networking) for backchannel -// discovery; only browser-facing redirects use the public URL. +// PublicKeycloakUrl is the browser-facing Keycloak base URL. When the +// web-client runs inside Docker, Authority points to the internal hostname +// (e.g. http://keycloak:8080) for backchannel discovery, while +// PublicKeycloakUrl is what the browser can reach (e.g. http://localhost:5002). +// When running outside Docker, Authority already IS the public URL and +// PublicKeycloakUrl is not needed. +string? publicKeycloakUrl = builder.Configuration["Auth:PublicKeycloakUrl"]; + +// In Codespaces, override the public URLs with the tunnel endpoints. string? codespaceName = Environment.GetEnvironmentVariable("CODESPACE_NAME"); string? codespaceDomain = Environment.GetEnvironmentVariable("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN"); -string? publicKeycloakBase = (!string.IsNullOrEmpty(codespaceName) && !string.IsNullOrEmpty(codespaceDomain)) - ? $"https://{codespaceName}-5002.{codespaceDomain}" - : null; +bool isCodespaces = !string.IsNullOrEmpty(codespaceName) && !string.IsNullOrEmpty(codespaceDomain); +if (isCodespaces) +{ + publicKeycloakUrl = $"https://{codespaceName}-5002.{codespaceDomain}"; +} + +// Derive the internal base URL from Authority for URL rewriting. +string internalKeycloakBase = new Uri(authority).GetLeftPart(UriPartial.Authority); builder.Services .AddAuthentication(options => @@ -58,39 +69,51 @@ // For local development with HTTP-only Keycloak options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); - // In Codespaces, the tunnel delivers requests to localhost but the - // browser must redirect to the public Codespaces URLs. Rewrite the - // authorization endpoint and redirect URIs so the browser reaches - // Keycloak and the web-client via the public Codespaces URLs. - // Issuer validation is disabled because the token is issued via the - // public URL (issuer = public hostname) but the discovery doc is - // fetched from localhost (issuer = localhost). - if (publicKeycloakBase is not null) + // When the web-client is inside Docker, the backchannel Authority uses + // an internal hostname that differs from the browser-facing URL. + // Rewrite the authorization/logout endpoints so the browser is + // redirected to the public Keycloak URL, and disable issuer validation + // because the token issuer (public URL) won't match the discovery + // document issuer (internal URL). + if (publicKeycloakUrl is not null) { -#pragma warning disable CA5404 // Disabling token validation checks in development environment to allow Codespaces tunnel URL for browser redirects, do not do this in production. +#pragma warning disable CA5404 // Token issuer validation disabled: backchannel uses internal Docker hostname while tokens are issued via the public URL. options.TokenValidationParameters.ValidateIssuer = false; #pragma warning restore CA5404 - // The UserInfo endpoint is on localhost but the token issuer is - // the public URL — Keycloak rejects the token. The ID token - // already contains the claims we need, so skip the UserInfo call. + // The UserInfo endpoint is on the internal URL but the token + // issuer is the public URL — Keycloak rejects the mismatch. + // The ID token already contains all needed claims. options.GetClaimsFromUserInfoEndpoint = false; - string publicBase = $"https://{codespaceName}-8080.{codespaceDomain}"; + // In Codespaces the tunnel delivers with Host: localhost, so the + // auto-generated redirect_uri is wrong. Override it explicitly. + string? publicWebClientBase = isCodespaces + ? $"https://{codespaceName}-8080.{codespaceDomain}" + : null; + options.Events = new OpenIdConnectEvents { OnRedirectToIdentityProvider = context => { context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress - .Replace("http://localhost:5002", publicKeycloakBase); - context.ProtocolMessage.RedirectUri = $"{publicBase}/signin-oidc"; + .Replace(internalKeycloakBase, publicKeycloakUrl); + if (publicWebClientBase is not null) + { + context.ProtocolMessage.RedirectUri = $"{publicWebClientBase}/signin-oidc"; + } + return Task.CompletedTask; }, OnRedirectToIdentityProviderForSignOut = context => { context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress - .Replace("http://localhost:5002", publicKeycloakBase); - context.ProtocolMessage.PostLogoutRedirectUri = $"{publicBase}/signout-callback-oidc"; + .Replace(internalKeycloakBase, publicKeycloakUrl); + if (publicWebClientBase is not null) + { + context.ProtocolMessage.PostLogoutRedirectUri = $"{publicWebClientBase}/signout-callback-oidc"; + } + return Task.CompletedTask; }, }; diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml b/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml index 78e36e04bd..7898d71b23 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml +++ b/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml @@ -58,14 +58,16 @@ services: context: ../../.. dockerfile: samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile container_name: auth-web-client - network_mode: host environment: - ASPNETCORE_ENVIRONMENT=Development - - Auth__Authority=http://localhost:5002/realms/dev + - Auth__Authority=http://keycloak:8080/realms/dev + - Auth__PublicKeycloakUrl=http://localhost:5002 - Auth__ClientId=web-client - - AgentService__BaseUrl=http://localhost:5001 + - AgentService__BaseUrl=http://agent-service:5001 - CODESPACE_NAME=${CODESPACE_NAME:-} - GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-} + ports: + - "8080:8080" volumes: - web-client-keys:/app/keys depends_on: From 21e7673fd72e9b7058d5873f0b667d0643878e52 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:37:47 +0000 Subject: [PATCH 3/6] Add launchsettings, add docker-compose to slnx and fix formatting --- dotnet/agent-framework-dotnet.slnx | 1 + .../AuthClientServer.AgentService/Program.cs | 2 +- .../Properties/launchSettings.json | 12 ++++++++++++ .../AuthClientServer.AgentService/UserContext.cs | 2 +- .../AuthClientServer.WebClient/Pages/Index.cshtml.cs | 2 +- .../Properties/launchSettings.json | 12 ++++++++++++ 6 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Properties/launchSettings.json create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Properties/launchSettings.json diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 30c32a8a09..75c4f1dc8a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -289,6 +289,7 @@
+ diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs index 3c732bab42..a4be16934d 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs @@ -6,9 +6,9 @@ using System.Security.Claims; using System.Text.Json.Serialization; using AuthClientServer.AgentService; +using Microsoft.Agents.AI; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; -using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI; diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Properties/launchSettings.json b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Properties/launchSettings.json new file mode 100644 index 0000000000..17854dc5a9 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "AuthClientServer.AgentService": { + "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/AuthClientServer/AuthClientServer.AgentService/UserContext.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs index 39ac1cc6ae..bc6540d688 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Security.Claims; diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs index c35fc7a196..722d6ed860 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs @@ -16,7 +16,7 @@ public void OnGet() public IActionResult OnGetLogout() { - return SignOut( + return this.SignOut( new AuthenticationProperties { RedirectUri = "/" }, CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme); diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Properties/launchSettings.json b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Properties/launchSettings.json new file mode 100644 index 0000000000..b86fb0e1d2 --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "AuthClientServer.WebClient": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:58080;http://localhost:8080" + } + } +} \ No newline at end of file From 752e47f71090de66a2dc83a684332db3a7730ec3 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:34:06 +0000 Subject: [PATCH 4/6] Switch to Expenses rather than todo based sample and address PR comments --- .../ExpenseService.cs | 110 ++++++++++++++++++ .../AuthClientServer.AgentService/Program.cs | 42 ++----- .../TodoService.cs | 47 -------- .../UserContext.cs | 16 ++- .../AuthClientServer.WebClient/Program.cs | 4 +- .../05-end-to-end/AuthClientServer/README.md | 51 ++++---- .../AuthClientServer/keycloak/dev-realm.json | 41 ++++++- .../keycloak/setup-redirect-uris.sh | 4 + 8 files changed, 210 insertions(+), 105 deletions(-) create mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ExpenseService.cs delete mode 100644 dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/TodoService.cs diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ExpenseService.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ExpenseService.cs new file mode 100644 index 0000000000..dd46c53fda --- /dev/null +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ExpenseService.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.ComponentModel; + +namespace AuthClientServer.AgentService; + +/// +/// 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/AuthClientServer/AuthClientServer.AgentService/Program.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs index a4be16934d..17c0b23100 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs @@ -1,7 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates how to secure an AI agent REST API with -// JWT Bearer authentication and policy-based scope authorization. +// 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; @@ -68,16 +69,7 @@ options.SerializerOptions.TypeInfoResolverChain.Add(SampleServiceSerializerContext.Default)); // --------------------------------------------------------------------------- -// CORS: allow the WebClient origin -// --------------------------------------------------------------------------- -builder.Services.AddCors(options => - options.AddDefaultPolicy(policy => - policy.WithOrigins("http://localhost:8080") - .AllowAnyHeader() - .AllowAnyMethod())); - -// --------------------------------------------------------------------------- -// Create the AI agent with TODO tools, registered in DI +// 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."); @@ -85,29 +77,28 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(sp => { - var todoService = sp.GetRequiredService(); + var expenseService = sp.GetRequiredService(); return new OpenAIClient(apiKey) .GetChatClient(model) .AsIChatClient() .AsAIAgent( - name: "AuthDemoAgent", - instructions: "You are a helpful assistant that can manage the user's TODO list. " - + "Use the available tools to list and add TODO items when asked. " + 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(todoService.ListTodos), - AIFunctionFactory.Create(todoService.AddTodo), + AIFunctionFactory.Create(expenseService.ListPendingExpenses), + AIFunctionFactory.Create(expenseService.ApproveExpense), ]); }); WebApplication app = builder.Build(); -app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); @@ -121,15 +112,6 @@ return Results.Ok(new ChatResponse(response.Text, userContext.DisplayName)); }); -// --------------------------------------------------------------------------- -// GET /me — returns the caller's identity (any authenticated user) -// --------------------------------------------------------------------------- -app.MapGet("/me", [Authorize] (ClaimsPrincipal user) => -{ - var claims = user.Claims.Select(c => new ClaimInfo(c.Type, c.Value)); - return Results.Ok(claims); -}); - await app.RunAsync(); // --------------------------------------------------------------------------- @@ -137,9 +119,7 @@ // --------------------------------------------------------------------------- internal sealed record ChatRequest(string Message); internal sealed record ChatResponse(string Reply, string User); -internal sealed record ClaimInfo(string Type, string Value); [JsonSerializable(typeof(ChatRequest))] [JsonSerializable(typeof(ChatResponse))] -[JsonSerializable(typeof(IEnumerable))] internal sealed partial class SampleServiceSerializerContext : JsonSerializerContext; diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/TodoService.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/TodoService.cs deleted file mode 100644 index 198235b403..0000000000 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/TodoService.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Concurrent; -using System.ComponentModel; - -namespace AuthClientServer.AgentService; - -/// -/// Manages per-user TODO lists. Uses to identify -/// the current caller without coupling to HTTP or claim-parsing details. -/// -public sealed class TodoService -{ - private static readonly ConcurrentDictionary> s_todos = new(); - - private readonly IUserContext _userContext; - - public TodoService(IUserContext userContext) - { - this._userContext = userContext; - } - - /// - /// Lists all TODO items for the currently authenticated user. - /// - [Description("Lists all TODO items for the current user.")] - public string ListTodos() - { - var items = s_todos.GetOrAdd(this._userContext.UserId, _ => []); - - return items.IsEmpty - ? "You have no TODO items." - : string.Join("\n", items.Select((item, i) => $"{i + 1}. {item}")); - } - - /// - /// Adds a new TODO item for the currently authenticated user. - /// - [Description("Adds a new TODO item for the current user.")] - public string AddTodo([Description("The TODO item to add")] string item) - { - var items = s_todos.GetOrAdd(this._userContext.UserId, _ => []); - items.Add(item); - - return $"Added \"{item}\" to your TODO list."; - } -} diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs index bc6540d688..b39ef666a7 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs @@ -17,14 +17,17 @@ public interface IUserContext /// 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, and given_name/family_name for the -/// display name. Registered as a scoped service so it is resolved once -/// per request. +/// 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 { @@ -34,6 +37,8 @@ public sealed class KeycloakUserContext : IUserContext public string DisplayName { get; } + public IReadOnlySet Scopes { get; } + public KeycloakUserContext(IHttpContextAccessor httpContextAccessor) { ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User; @@ -55,5 +60,10 @@ public KeycloakUserContext(IHttpContextAccessor httpContextAccessor) (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/AuthClientServer/AuthClientServer.WebClient/Program.cs b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs index 8aae7ea7c9..67fb3063e6 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs +++ b/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs @@ -59,12 +59,14 @@ options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; - // Request the agent.chat scope so the access token includes it + // 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(); diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/README.md b/dotnet/samples/05-end-to-end/AuthClientServer/README.md index 0dd98d3abb..21f4c25521 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/README.md +++ b/dotnet/samples/05-end-to-end/AuthClientServer/README.md @@ -1,6 +1,6 @@ # Auth Client-Server Sample -This sample demonstrates how to secure an AI agent REST API with standards-based authentication and authorization using OAuth 2.0 / OpenID Connect, JWT Bearer tokens, and policy-based scope enforcement. +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). @@ -11,7 +11,7 @@ The sample has three components, all launched with a single `docker compose up`: | Service | Port | Description | |---------|------|-------------| | **WebClient** | `http://localhost:8080` | Razor Pages web app with OIDC login and a chat UI that calls the AgentService | -| **AgentService** | `http://localhost:5001` | ASP.NET Minimal API hosting a `ChatClientAgent`, secured with JWT Bearer auth and scope-based policies | +| **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 | ``` @@ -47,7 +47,7 @@ export OPENAI_MODEL="gpt-4.1-mini" ### Option 1: Docker Compose (Recommended) ```bash -cd samples/AuthClientServer +cd dotnet/samples/05-end-to-end/AuthClientServer docker compose up ``` @@ -96,9 +96,12 @@ Then open the Codespaces-forwarded URL for port 8080 (shown in the **Ports** tab 1. Open `http://localhost:8080` in your browser 2. Click **Login** — you'll be redirected to Keycloak 3. Sign in with one of the pre-configured users: - - **`testuser` / `password`** — has the `agent.chat` scope, can chat with the agent - - **`viewer` / `password`** — lacks the `agent.chat` scope, will receive a 403 Forbidden when trying to chat -4. Type a message and click **Send** to chat with the agent + - **`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 @@ -110,8 +113,22 @@ The `keycloak/dev-realm.json` file auto-provisions: | **Client: agent-service** | Confidential client (the API audience) | | **Client: web-client** | Public client for the Razor app's OIDC login | | **Scope: agent.chat** | Required to call the `/chat` endpoint | -| **User: testuser** | Has `agent.chat` scope | -| **User: viewer** | Does not have `agent.chat` scope | +| **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`). @@ -122,26 +139,18 @@ Keycloak admin console: `http://localhost:5002` (login: `admin` / `admin`). ```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" \ + -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": "Hello, what can you help me with?"}' -``` - -### GET /me (requires any valid token) - -```bash -curl http://localhost:5001/me -H "Authorization: Bearer $TOKEN" + -d '{"message": "Show me the pending expenses"}' ``` ## Key Concepts Demonstrated -- **JWT Bearer Authentication** — The AgentService validates tokens from Keycloak using OIDC discovery -- **Policy-Based Authorization** — The `/chat` endpoint requires the `agent.chat` scope in the token -- **Caller Identity** — The service reads the caller's identity from `HttpContext.User` (ClaimsPrincipal) -- **OIDC Login Flow** — The WebClient uses OpenID Connect authorization code flow with Keycloak -- **Token Forwarding** — The WebClient stores the access token and sends it as a Bearer token to the AgentService +- **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/AuthClientServer/keycloak/dev-realm.json b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json index a59728d276..41e8ce3038 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json +++ b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json @@ -8,6 +8,14 @@ { "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" } ] }, @@ -15,6 +23,14 @@ { "clientScope": "agent.chat", "roles": ["agent-chat-user"] + }, + { + "clientScope": "expenses.view", + "roles": ["expenses-viewer"] + }, + { + "clientScope": "expenses.approve", + "roles": ["expenses-approver"] } ], "clientScopes": [ @@ -103,6 +119,24 @@ "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", @@ -157,7 +191,9 @@ "agent-service-audience" ], "optionalClientScopes": [ - "agent.chat" + "agent.chat", + "expenses.view", + "expenses.approve" ] } ], @@ -168,7 +204,7 @@ "email": "testuser@example.com", "firstName": "Test", "lastName": "User", - "realmRoles": ["agent-chat-user"], + "realmRoles": ["agent-chat-user", "expenses-viewer", "expenses-approver"], "credentials": [ { "type": "password", @@ -183,6 +219,7 @@ "email": "viewer@example.com", "firstName": "View", "lastName": "Only", + "realmRoles": ["agent-chat-user", "expenses-viewer"], "credentials": [ { "type": "password", diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh index 734dcf9996..b49cfc4e80 100755 --- a/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh +++ b/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh @@ -34,6 +34,10 @@ CLIENT_UUID=$(curl -sf "$KEYCLOAK_URL/admin/realms/dev/clients?clientId=web-clie -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" \ From 1add4ce1d08056a35b50e9bb05c89e299777a621 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:00:46 +0000 Subject: [PATCH 5/6] Rename sample --- dotnet/agent-framework-dotnet.slnx | 10 +++++----- .../README.md | 6 +++--- .../RazorWebClient}/Dockerfile | 10 +++++----- .../RazorWebClient}/Pages/Chat.cshtml | 2 +- .../RazorWebClient}/Pages/Chat.cshtml.cs | 2 +- .../RazorWebClient}/Pages/Index.cshtml | 2 +- .../RazorWebClient}/Pages/Index.cshtml.cs | 2 +- .../RazorWebClient}/Pages/Shared/_Layout.cshtml | 0 .../RazorWebClient}/Pages/_ViewImports.cshtml | 2 +- .../RazorWebClient}/Program.cs | 0 .../RazorWebClient}/Properties/launchSettings.json | 2 +- .../RazorWebClient/RazorWebClient.csproj} | 0 .../RazorWebClient}/appsettings.json | 0 .../Service}/Dockerfile | 10 +++++----- .../Service}/ExpenseService.cs | 2 +- .../Service}/Program.cs | 2 +- .../Service}/Properties/launchSettings.json | 2 +- .../Service/Service.csproj} | 0 .../Service}/UserContext.cs | 2 +- .../Service}/appsettings.json | 0 .../docker-compose.yml | 4 ++-- .../keycloak/dev-realm.json | 0 .../keycloak/setup-redirect-uris.sh | 0 23 files changed, 30 insertions(+), 30 deletions(-) rename dotnet/samples/05-end-to-end/{AuthClientServer => AspNetAgentAuthorization}/README.md (98%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient => AspNetAgentAuthorization/RazorWebClient}/Dockerfile (50%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient => AspNetAgentAuthorization/RazorWebClient}/Pages/Chat.cshtml (94%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient => AspNetAgentAuthorization/RazorWebClient}/Pages/Chat.cshtml.cs (97%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient => AspNetAgentAuthorization/RazorWebClient}/Pages/Index.cshtml (85%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient => AspNetAgentAuthorization/RazorWebClient}/Pages/Index.cshtml.cs (91%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient => AspNetAgentAuthorization/RazorWebClient}/Pages/Shared/_Layout.cshtml (100%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient => AspNetAgentAuthorization/RazorWebClient}/Pages/_ViewImports.cshtml (62%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient => AspNetAgentAuthorization/RazorWebClient}/Program.cs (100%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient => AspNetAgentAuthorization/RazorWebClient}/Properties/launchSettings.json (87%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj => AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj} (100%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.WebClient => AspNetAgentAuthorization/RazorWebClient}/appsettings.json (100%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.AgentService => AspNetAgentAuthorization/Service}/Dockerfile (60%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.AgentService => AspNetAgentAuthorization/Service}/ExpenseService.cs (98%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.AgentService => AspNetAgentAuthorization/Service}/Program.cs (99%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.AgentService => AspNetAgentAuthorization/Service}/Properties/launchSettings.json (86%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj => AspNetAgentAuthorization/Service/Service.csproj} (100%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.AgentService => AspNetAgentAuthorization/Service}/UserContext.cs (98%) rename dotnet/samples/05-end-to-end/{AuthClientServer/AuthClientServer.AgentService => AspNetAgentAuthorization/Service}/appsettings.json (100%) rename dotnet/samples/05-end-to-end/{AuthClientServer => AspNetAgentAuthorization}/docker-compose.yml (93%) rename dotnet/samples/05-end-to-end/{AuthClientServer => AspNetAgentAuthorization}/keycloak/dev-realm.json (100%) rename dotnet/samples/05-end-to-end/{AuthClientServer => AspNetAgentAuthorization}/keycloak/setup-redirect-uris.sh (100%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 75c4f1dc8a..b35178f011 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -288,11 +288,11 @@ - - - - - + + + + + diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/README.md b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md similarity index 98% rename from dotnet/samples/05-end-to-end/AuthClientServer/README.md rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md index 21f4c25521..c84dd125c3 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/README.md +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md @@ -47,7 +47,7 @@ export OPENAI_MODEL="gpt-4.1-mini" ### Option 1: Docker Compose (Recommended) ```bash -cd dotnet/samples/05-end-to-end/AuthClientServer +cd dotnet/samples/05-end-to-end/AspNetAgentAuthorization docker compose up ``` @@ -81,13 +81,13 @@ Then open the Codespaces-forwarded URL for port 8080 (shown in the **Ports** tab 2. In a new terminal, start the AgentService: ```bash - cd AuthClientServer.AgentService + cd Service dotnet run --urls "http://localhost:5001" ``` 3. In another terminal, start the WebClient: ```bash - cd AuthClientServer.WebClient + cd RazorWebClient dotnet run --urls "http://localhost:8080" ``` diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile similarity index 50% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile index 06af15686f..8e15ba2425 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile @@ -13,17 +13,17 @@ COPY samples/Directory.Build.props samples/ RUN touch /CODE_OF_CONDUCT.md # Copy project file for restore -COPY samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/ +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/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false +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/AuthClientServer/AuthClientServer.WebClient/ samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/ -RUN dotnet publish samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false +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", "AuthClientServer.WebClient.dll"] +ENTRYPOINT ["dotnet", "RazorWebClient.dll"] diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml similarity index 94% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml index 60db3641b0..edccf4c34e 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml @@ -1,7 +1,7 @@ @page @using Microsoft.AspNetCore.Authorization @attribute [Authorize] -@model AuthClientServer.WebClient.Pages.ChatModel +@model AspNetAgentAuthorization.RazorWebClient.Pages.ChatModel @{ Layout = "_Layout"; } diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml.cs similarity index 97% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml.cs rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml.cs index 87c9b3e333..5326e7ae9d 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Chat.cshtml.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -namespace AuthClientServer.WebClient.Pages; +namespace AspNetAgentAuthorization.RazorWebClient.Pages; public class ChatModel : PageModel { diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml similarity index 85% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml index 248459101c..ab1d7cb1dc 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml @@ -1,5 +1,5 @@ @page -@model AuthClientServer.WebClient.Pages.IndexModel +@model AspNetAgentAuthorization.RazorWebClient.Pages.IndexModel @{ Layout = "_Layout"; } diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml.cs similarity index 91% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml.cs index 722d6ed860..2547fb6fce 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Index.cshtml.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -namespace AuthClientServer.WebClient.Pages; +namespace AspNetAgentAuthorization.RazorWebClient.Pages; public class IndexModel : PageModel { diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Shared/_Layout.cshtml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Shared/_Layout.cshtml similarity index 100% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/Shared/_Layout.cshtml rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Shared/_Layout.cshtml diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/_ViewImports.cshtml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/_ViewImports.cshtml similarity index 62% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/_ViewImports.cshtml rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/_ViewImports.cshtml index 2c167029a9..71c71463de 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Pages/_ViewImports.cshtml +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/_ViewImports.cshtml @@ -1,3 +1,3 @@ @using Microsoft.AspNetCore.Authentication -@namespace AuthClientServer.WebClient.Pages +@namespace AspNetAgentAuthorization.RazorWebClient.Pages @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Program.cs similarity index 100% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Program.cs rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Program.cs diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Properties/launchSettings.json b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Properties/launchSettings.json similarity index 87% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Properties/launchSettings.json rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Properties/launchSettings.json index b86fb0e1d2..28c3cf0be6 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Properties/launchSettings.json +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Properties/launchSettings.json @@ -1,6 +1,6 @@ { "profiles": { - "AuthClientServer.WebClient": { + "RazorWebClient": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj similarity index 100% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/AuthClientServer.WebClient.csproj rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/appsettings.json b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/appsettings.json similarity index 100% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/appsettings.json rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/appsettings.json diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Dockerfile b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Dockerfile similarity index 60% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Dockerfile rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Dockerfile index 11a30976e3..69517af95d 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Dockerfile +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Dockerfile @@ -17,18 +17,18 @@ RUN touch /CODE_OF_CONDUCT.md && mkdir -p /dotnet/nuget && cp /repo/nuget/* /dot 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/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ +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/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false +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/AuthClientServer/AuthClientServer.AgentService/ samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ -RUN dotnet publish samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false +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", "AuthClientServer.AgentService.dll"] +ENTRYPOINT ["dotnet", "Service.dll"] diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ExpenseService.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/ExpenseService.cs similarity index 98% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ExpenseService.cs rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/ExpenseService.cs index dd46c53fda..41f98ce4c2 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/ExpenseService.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/ExpenseService.cs @@ -3,7 +3,7 @@ using System.Collections.Concurrent; using System.ComponentModel; -namespace AuthClientServer.AgentService; +namespace AspNetAgentAuthorization.Service; /// /// Represents an expense awaiting approval. diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs similarity index 99% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs index 17c0b23100..b4a5d00a9a 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Program.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs @@ -6,7 +6,7 @@ using System.Security.Claims; using System.Text.Json.Serialization; -using AuthClientServer.AgentService; +using AspNetAgentAuthorization.Service; using Microsoft.Agents.AI; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Properties/launchSettings.json b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Properties/launchSettings.json similarity index 86% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Properties/launchSettings.json rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Properties/launchSettings.json index 17854dc5a9..6366505896 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Properties/launchSettings.json +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Properties/launchSettings.json @@ -1,6 +1,6 @@ { "profiles": { - "AuthClientServer.AgentService": { + "Service": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj similarity index 100% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/AuthClientServer.AgentService.csproj rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs similarity index 98% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs index b39ef666a7..34f4fe8956 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/UserContext.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs @@ -2,7 +2,7 @@ using System.Security.Claims; -namespace AuthClientServer.AgentService; +namespace AspNetAgentAuthorization.Service; /// /// Provides the authenticated user's identity for the current request. diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/appsettings.json b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/appsettings.json similarity index 100% rename from dotnet/samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/appsettings.json rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/appsettings.json diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml similarity index 93% rename from dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml index 7898d71b23..eb9e356e72 100644 --- a/dotnet/samples/05-end-to-end/AuthClientServer/docker-compose.yml +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml @@ -39,7 +39,7 @@ services: agent-service: build: context: ../../.. - dockerfile: samples/05-end-to-end/AuthClientServer/AuthClientServer.AgentService/Dockerfile + dockerfile: samples/05-end-to-end/AspNetAgentAuthorization/Service/Dockerfile container_name: auth-agent-service environment: - ASPNETCORE_ENVIRONMENT=Development @@ -56,7 +56,7 @@ services: web-client: build: context: ../../.. - dockerfile: samples/05-end-to-end/AuthClientServer/AuthClientServer.WebClient/Dockerfile + dockerfile: samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile container_name: auth-web-client environment: - ASPNETCORE_ENVIRONMENT=Development diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/dev-realm.json similarity index 100% rename from dotnet/samples/05-end-to-end/AuthClientServer/keycloak/dev-realm.json rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/dev-realm.json diff --git a/dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/setup-redirect-uris.sh similarity index 100% rename from dotnet/samples/05-end-to-end/AuthClientServer/keycloak/setup-redirect-uris.sh rename to dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/setup-redirect-uris.sh From 778308cf4591e09eefc4fbcd9f77d4d1e4cf6087 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:22:39 +0000 Subject: [PATCH 6/6] Fix formatting --- .../AspNetAgentAuthorization/Service/ExpenseService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/ExpenseService.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/ExpenseService.cs index 41f98ce4c2..d02ab8d409 100644 --- a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/ExpenseService.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/ExpenseService.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Concurrent; using System.ComponentModel;