.NET: AuthN & AuthZ sample with asp.net service and web client (#4354)

* Add sample demonstrating authentication and user access in agent tools

* Add fixes to enable running on windows

* Add launchsettings, add docker-compose to slnx and fix formatting

* Switch to Expenses rather than todo based sample and address PR comments

* Rename sample

* Fix formatting
This commit is contained in:
westey
2026-03-02 18:41:14 +00:00
committed by GitHub
Unverified
parent 8a18f39b36
commit f6b0610a6c
24 changed files with 1315 additions and 0 deletions
+2
View File
@@ -58,6 +58,8 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
<!-- Microsoft.AspNetCore.* -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
<!-- Microsoft.Extensions.* -->
+6
View File
@@ -287,6 +287,12 @@
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/AspNetAgentAuthorization/">
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml" />
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/README.md" />
<Project Path="samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj" />
<Project Path="samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj" />
</Folder>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path=".gitignore" />
@@ -0,0 +1,156 @@
# Auth Client-Server Sample
This sample demonstrates how to authorize AI agents and their tools using OAuth 2.0 scopes. It shows two levels of access control: an endpoint-level scope (`agent.chat`) that gates access to the agent, and tool-level scopes (`expenses.view`, `expenses.approve`) that control what the agent can do on behalf of each user.
While this sample uses Keycloak to avoid complex setup in order to run the sample, Keycloak can easily be replaced with any OIDC compatible provider, including [Microsoft Entra Id](https://www.microsoft.com/security/business/identity-access/microsoft-entra-id).
## Overview
The sample has three components, all launched with a single `docker compose up`:
| Service | Port | Description |
|---------|------|-------------|
| **WebClient** | `http://localhost:8080` | Razor Pages web app with OIDC login and a chat UI that calls the AgentService |
| **AgentService** | `http://localhost:5001` | ASP.NET Minimal API hosting an expense approval agent with scope-authorized tools |
| **Keycloak** | `http://localhost:5002` | OIDC identity provider, auto-provisioned with realm, clients, scopes, and test users |
```
┌──────────────┐ OIDC login ┌───────────┐
│ WebClient │ ◄──────────────────► │ Keycloak │
│ (Razor app) │ (browser flow) │ (Docker) │
│ :8080 │ │ :5002 │
└──────┬───────┘ └─────┬─────┘
│ REST + Bearer token │
▼ │
┌───────────────┐ JWT validation ──────┘
│ AgentService │ ◄──── (jwks from Keycloak)
│ (Minimal API) │
│ :5001 │
└───────────────┘
```
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
## Configuring Environment Variables
The AgentService requires an OpenAI-compatible endpoint. Set these environment variables before running:
```bash
export OPENAI_API_KEY="<your-openai-api-key>"
export OPENAI_MODEL="gpt-4.1-mini"
```
## Running the Sample
### Option 1: Docker Compose (Recommended)
```bash
cd dotnet/samples/05-end-to-end/AspNetAgentAuthorization
docker compose up
```
This starts Keycloak, the AgentService, and the WebClient. Wait for Keycloak to finish importing the realm (you'll see `Running the server` in the logs).
#### Running in GitHub Codespaces
This sample has been built in such a way that it can be run from GitHub Codespaces.
The Agent Framework repository has a C# specific dev container, named "C# (.NET)", that is configured for Codespaces.
When running in Codespaces, the sample auto-detects the environment via
`CODESPACE_NAME` and `GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN` and configures
Keycloak and the web client accordingly. Just make the required ports public:
```bash
# Make Keycloak and WebClient ports publicly accessible
gh codespace ports visibility 5002:public 8080:public -c $CODESPACE_NAME
# Start the containers (Codespaces is auto-detected)
docker compose up
```
Then open the Codespaces-forwarded URL for port 8080 (shown in the **Ports** tab) in your browser.
### Option 2: Run Locally
1. Start Keycloak:
```bash
docker compose up keycloak
```
2. In a new terminal, start the AgentService:
```bash
cd Service
dotnet run --urls "http://localhost:5001"
```
3. In another terminal, start the WebClient:
```bash
cd RazorWebClient
dotnet run --urls "http://localhost:8080"
```
## Using the Sample
1. Open `http://localhost:8080` in your browser
2. Click **Login** — you'll be redirected to Keycloak
3. Sign in with one of the pre-configured users:
- **`testuser` / `password`** — can chat, view expenses, and approve expenses (up to €1,000)
- **`viewer` / `password`** — can chat and view expenses, but **cannot approve** them
4. Try asking the agent:
- _"Show me the pending expenses"_ — both users can do this
- _"Approve expense #1"_ — only `testuser` can do this; `viewer` will be denied
- _"Approve expense #3"_ — even `testuser` will be denied (€4,500 exceeds the €1,000 limit)
## Pre-Configured Keycloak Realm
The `keycloak/dev-realm.json` file auto-provisions:
| Resource | Details |
|----------|---------|
| **Realm** | `dev` |
| **Client: agent-service** | Confidential client (the API audience) |
| **Client: web-client** | Public client for the Razor app's OIDC login |
| **Scope: agent.chat** | Required to call the `/chat` endpoint |
| **Scope: expenses.view** | Required to list pending expenses |
| **Scope: expenses.approve** | Required to approve expenses |
| **User: testuser** | Has `agent.chat`, `expenses.view`, and `expenses.approve` scopes |
| **User: viewer** | Has `agent.chat` and `expenses.view` scopes (no approval) |
### Pre-Seeded Expenses
The service starts with five demo expenses:
| # | Description | Amount | Status |
|---|-------------|--------|--------|
| 1 | Conference travel — Berlin | €850 | Pending |
| 2 | Team dinner — Q4 celebration | €320 | Pending |
| 3 | Cloud infrastructure — annual renewal | €4,500 | Pending (over limit) |
| 4 | Office supplies — ergonomic keyboards | €675 | Pending |
| 5 | Client gift baskets — holiday season | €980 | Pending |
Keycloak admin console: `http://localhost:5002` (login: `admin` / `admin`).
## API Endpoints
### POST /chat (requires `agent.chat` scope)
```bash
# Get a token for testuser
TOKEN=$(curl -s -X POST http://localhost:5002/realms/dev/protocol/openid-connect/token \
-d "grant_type=password&client_id=web-client&username=testuser&password=password&scope=openid agent.chat expenses.view expenses.approve" \
| jq -r '.access_token')
# Chat with the agent
curl -X POST http://localhost:5001/chat \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "Show me the pending expenses"}'
```
## Key Concepts Demonstrated
- **Endpoint-Level Authorization** — The `/chat` endpoint requires the `agent.chat` scope, gating access to the agent itself
- **Tool-Level Authorization** — Each agent tool checks its own scope (`expenses.view`, `expenses.approve`) at runtime, so different users have different capabilities within the same chat session
- **Scope-Based Role Mapping** — Keycloak realm roles map to OAuth scopes, allowing administrators to control which users can access which agent capabilities
@@ -0,0 +1,29 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /repo
# Copy solution-level files for restore
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json nuget.config ./
COPY eng/ eng/
COPY src/Shared/ src/Shared/
COPY samples/Directory.Build.props samples/
# Create sentinel file so $(RepoRoot) resolves correctly inside the container.
# RepoRoot is the parent of the dir containing CODE_OF_CONDUCT.md,
# and src projects import $(RepoRoot)/dotnet/nuget/nuget-package.props.
RUN touch /CODE_OF_CONDUCT.md
# Copy project file for restore
COPY samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/
RUN dotnet restore samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false
# Copy everything and build
COPY samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/ samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/
RUN dotnet publish samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "RazorWebClient.dll"]
@@ -0,0 +1,35 @@
@page
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@model AspNetAgentAuthorization.RazorWebClient.Pages.ChatModel
@{
Layout = "_Layout";
}
<h1>Chat with the Agent</h1>
<form method="post">
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<input type="text" name="message" value="@Model.Message" placeholder="Type your message..."
style="flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px;" />
<button type="submit"
style="padding: 10px 20px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
Send
</button>
</div>
</form>
@if (Model.Error is not null)
{
<div style="background: #fee; border: 1px solid #fcc; border-radius: 4px; padding: 12px; margin-bottom: 12px; color: #c00;">
<strong>Error:</strong> @Model.Error
</div>
}
@if (Model.Reply is not null)
{
<div style="background: #f0f7ff; border: 1px solid #cce0ff; border-radius: 4px; padding: 12px; margin-bottom: 12px;">
<div style="font-size: 12px; color: #666; margin-bottom: 4px;">Agent (responding to @Model.ReplyUser):</div>
<div>@Model.Reply</div>
</div>
}
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AspNetAgentAuthorization.RazorWebClient.Pages;
public class ChatModel : PageModel
{
private readonly IHttpClientFactory _httpClientFactory;
public ChatModel(IHttpClientFactory httpClientFactory)
{
this._httpClientFactory = httpClientFactory;
}
[BindProperty]
public string? Message { get; set; }
public string? Reply { get; set; }
public string? ReplyUser { get; set; }
public string? Error { get; set; }
public void OnGet()
{
}
public async Task OnPostAsync()
{
if (string.IsNullOrWhiteSpace(this.Message))
{
return;
}
try
{
// Get the access token stored during OIDC login
string? accessToken = await this.HttpContext.GetTokenAsync("access_token");
if (accessToken is null)
{
this.Error = "No access token available. Please log in again.";
return;
}
// Call the AgentService with the Bearer token
var client = this._httpClientFactory.CreateClient("AgentService");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var payload = JsonSerializer.Serialize(new { message = this.Message });
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await client.PostAsync(new Uri("/chat", UriKind.Relative), content);
if (response.IsSuccessStatusCode)
{
using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
this.Reply = json.RootElement.GetProperty("reply").GetString();
this.ReplyUser = json.RootElement.GetProperty("user").GetString();
}
else
{
this.Error = response.StatusCode switch
{
System.Net.HttpStatusCode.Unauthorized => "Authentication failed (401). Your session may have expired.",
System.Net.HttpStatusCode.Forbidden => "Access denied (403). Your account does not have the required 'agent.chat' scope.",
_ => $"AgentService returned {(int)response.StatusCode} {response.ReasonPhrase}."
};
}
}
catch (Exception ex)
{
this.Error = $"Failed to contact the AgentService: {ex.Message}";
}
}
}
@@ -0,0 +1,18 @@
@page
@model AspNetAgentAuthorization.RazorWebClient.Pages.IndexModel
@{
Layout = "_Layout";
}
<h1>Welcome</h1>
<p>This sample demonstrates securing an AI agent API with OAuth 2.0 / OpenID Connect.</p>
@if (User.Identity?.IsAuthenticated == true)
{
<p>You are logged in as <strong>@User.Identity.Name</strong>.</p>
<p><a href="/Chat">Go to Chat →</a></p>
}
else
{
<p>Please <a href="/Chat">log in</a> to chat with the agent.</p>
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AspNetAgentAuthorization.RazorWebClient.Pages;
public class IndexModel : PageModel
{
public void OnGet()
{
}
public IActionResult OnGetLogout()
{
return this.SignOut(
new AuthenticationProperties { RedirectUri = "/" },
CookieAuthenticationDefaults.AuthenticationScheme,
OpenIdConnectDefaults.AuthenticationScheme);
}
}
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auth Agent Chat</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
nav { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #ddd; margin-bottom: 20px; }
nav a { text-decoration: none; color: #0066cc; margin-left: 10px; }
.user-info { color: #666; }
.container { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
h1 { color: #333; }
</style>
</head>
<body>
<nav>
<strong>🤖 Auth Agent Chat</strong>
<div>
@if (User.Identity?.IsAuthenticated == true)
{
<span class="user-info">@User.Identity.Name</span>
<a href="/Index?handler=Logout">Logout</a>
}
else
{
<a href="/Chat">Login</a>
}
</div>
</nav>
<div class="container">
@RenderBody()
</div>
</body>
</html>
@@ -0,0 +1,3 @@
@using Microsoft.AspNetCore.Authentication
@namespace AspNetAgentAuthorization.RazorWebClient.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,142 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates an OIDC-authenticated Razor Pages web client
// that calls a JWT-secured AI agent REST API.
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
// Persist data protection keys so antiforgery tokens survive container rebuilds
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/app/keys"));
// ---------------------------------------------------------------------------
// Authentication: Cookie + OpenID Connect (Keycloak)
// ---------------------------------------------------------------------------
string authority = builder.Configuration["Auth:Authority"]
?? throw new InvalidOperationException("Auth:Authority is not configured.");
// PublicKeycloakUrl is the browser-facing Keycloak base URL. When the
// web-client runs inside Docker, Authority points to the internal hostname
// (e.g. http://keycloak:8080) for backchannel discovery, while
// PublicKeycloakUrl is what the browser can reach (e.g. http://localhost:5002).
// When running outside Docker, Authority already IS the public URL and
// PublicKeycloakUrl is not needed.
string? publicKeycloakUrl = builder.Configuration["Auth:PublicKeycloakUrl"];
// In Codespaces, override the public URLs with the tunnel endpoints.
string? codespaceName = Environment.GetEnvironmentVariable("CODESPACE_NAME");
string? codespaceDomain = Environment.GetEnvironmentVariable("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN");
bool isCodespaces = !string.IsNullOrEmpty(codespaceName) && !string.IsNullOrEmpty(codespaceDomain);
if (isCodespaces)
{
publicKeycloakUrl = $"https://{codespaceName}-5002.{codespaceDomain}";
}
// Derive the internal base URL from Authority for URL rewriting.
string internalKeycloakBase = new Uri(authority).GetLeftPart(UriPartial.Authority);
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = authority;
options.ClientId = builder.Configuration["Auth:ClientId"]
?? throw new InvalidOperationException("Auth:ClientId is not configured.");
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
// Request scopes so the access token includes them
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("agent.chat");
options.Scope.Add("expenses.view");
options.Scope.Add("expenses.approve");
// For local development with HTTP-only Keycloak
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
// When the web-client is inside Docker, the backchannel Authority uses
// an internal hostname that differs from the browser-facing URL.
// Rewrite the authorization/logout endpoints so the browser is
// redirected to the public Keycloak URL, and disable issuer validation
// because the token issuer (public URL) won't match the discovery
// document issuer (internal URL).
if (publicKeycloakUrl is not null)
{
#pragma warning disable CA5404 // Token issuer validation disabled: backchannel uses internal Docker hostname while tokens are issued via the public URL.
options.TokenValidationParameters.ValidateIssuer = false;
#pragma warning restore CA5404
// The UserInfo endpoint is on the internal URL but the token
// issuer is the public URL — Keycloak rejects the mismatch.
// The ID token already contains all needed claims.
options.GetClaimsFromUserInfoEndpoint = false;
// In Codespaces the tunnel delivers with Host: localhost, so the
// auto-generated redirect_uri is wrong. Override it explicitly.
string? publicWebClientBase = isCodespaces
? $"https://{codespaceName}-8080.{codespaceDomain}"
: null;
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress
.Replace(internalKeycloakBase, publicKeycloakUrl);
if (publicWebClientBase is not null)
{
context.ProtocolMessage.RedirectUri = $"{publicWebClientBase}/signin-oidc";
}
return Task.CompletedTask;
},
OnRedirectToIdentityProviderForSignOut = context =>
{
context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress
.Replace(internalKeycloakBase, publicKeycloakUrl);
if (publicWebClientBase is not null)
{
context.ProtocolMessage.PostLogoutRedirectUri = $"{publicWebClientBase}/signout-callback-oidc";
}
return Task.CompletedTask;
},
};
}
});
// ---------------------------------------------------------------------------
// HttpClient for calling the AgentService — attaches Bearer token
// ---------------------------------------------------------------------------
builder.Services.AddHttpClient("AgentService", client =>
{
string baseUrl = builder.Configuration["AgentService:BaseUrl"] ?? "http://localhost:5001";
client.BaseAddress = new Uri(baseUrl);
});
WebApplication app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
await app.RunAsync();
@@ -0,0 +1,12 @@
{
"profiles": {
"RazorWebClient": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:58080;http://localhost:8080"
}
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
</ItemGroup>
</Project>
@@ -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"
}
}
@@ -0,0 +1,34 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /repo
# Copy solution-level files for restore
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json nuget.config ./
COPY eng/ eng/
COPY nuget/ nuget/
COPY src/Shared/ src/Shared/
COPY samples/Directory.Build.props samples/
# Create sentinel file so $(RepoRoot) resolves correctly inside the container.
# RepoRoot is the parent of the dir containing CODE_OF_CONDUCT.md,
# and src projects import $(RepoRoot)/dotnet/nuget/nuget-package.props.
RUN touch /CODE_OF_CONDUCT.md && mkdir -p /dotnet/nuget && cp /repo/nuget/* /dotnet/nuget/
# Copy project files for restore
COPY src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj src/Microsoft.Agents.AI.Abstractions/
COPY src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj src/Microsoft.Agents.AI/
COPY src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj src/Microsoft.Agents.AI.OpenAI/
COPY samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj samples/05-end-to-end/AspNetAgentAuthorization/Service/
RUN dotnet restore samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false
# Copy everything and build
COPY src/ src/
COPY samples/05-end-to-end/AspNetAgentAuthorization/Service/ samples/05-end-to-end/AspNetAgentAuthorization/Service/
RUN dotnet publish samples/05-end-to-end/AspNetAgentAuthorization/Service/Service.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:5001
EXPOSE 5001
ENTRYPOINT ["dotnet", "Service.dll"]
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.ComponentModel;
namespace AspNetAgentAuthorization.Service;
/// <summary>
/// Represents an expense awaiting approval.
/// </summary>
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; }
}
/// <summary>
/// Manages expense approvals. Pre-seeded with demo data so there are
/// expenses to review immediately. Uses <see cref="IUserContext"/> to
/// identify the caller and enforce scope-based permissions.
/// </summary>
public sealed class ExpenseService
{
/// <summary>Maximum amount (EUR) that can be approved.</summary>
private const decimal ApprovalLimit = 1000m;
private static readonly ConcurrentDictionary<int, Expense> s_expenses = new(
new Dictionary<int, Expense>
{
[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;
}
/// <summary>
/// Lists all pending expenses awaiting approval.
/// </summary>
[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})"));
}
/// <summary>
/// Approves a pending expense by its ID.
/// </summary>
[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.";
}
}
@@ -0,0 +1,125 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to authorize AI agent tools using OAuth 2.0
// scopes. The /chat endpoint requires the "agent.chat" scope, and each tool
// checks its own scope (expenses.view, expenses.approve) at runtime.
using System.Security.Claims;
using System.Text.Json.Serialization;
using AspNetAgentAuthorization.Service;
using Microsoft.Agents.AI;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.AI;
using OpenAI;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// ---------------------------------------------------------------------------
// Authentication: JWT Bearer tokens validated against the OIDC provider
// ---------------------------------------------------------------------------
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"]
?? throw new InvalidOperationException("Auth:Authority is not configured.");
options.Audience = builder.Configuration["Auth:Audience"]
?? throw new InvalidOperationException("Auth:Audience is not configured.");
// For local development with HTTP-only Keycloak
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
options.TokenValidationParameters.ValidateAudience = true;
options.TokenValidationParameters.ValidateLifetime = true;
// In Codespaces, tokens are issued with the public tunnel URL as
// issuer (Keycloak sees X-Forwarded-Host from the tunnel) but the
// agent-service discovers Keycloak via the internal Docker hostname.
// Disable issuer validation in development to handle this mismatch.
options.TokenValidationParameters.ValidateIssuer = !builder.Environment.IsDevelopment();
});
// ---------------------------------------------------------------------------
// Authorization: policy requiring the "agent.chat" scope
// ---------------------------------------------------------------------------
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AgentChat", policy =>
policy.RequireAuthenticatedUser()
.RequireAssertion(context =>
{
// Keycloak puts scopes in the "scope" claim (space-delimited)
var scopeClaim = context.User.FindFirstValue("scope");
if (scopeClaim is not null)
{
var scopes = scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (scopes.Contains("agent.chat", StringComparer.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}));
// ---------------------------------------------------------------------------
// Configure JSON serialization
// ---------------------------------------------------------------------------
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(SampleServiceSerializerContext.Default));
// ---------------------------------------------------------------------------
// Create the AI agent with expense approval tools, registered in DI
// ---------------------------------------------------------------------------
string apiKey = builder.Configuration["OPENAI_API_KEY"]
?? throw new InvalidOperationException("Set the OPENAI_API_KEY environment variable.");
string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini";
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IUserContext, KeycloakUserContext>();
builder.Services.AddScoped<ExpenseService>();
builder.Services.AddScoped<AIAgent>(sp =>
{
var expenseService = sp.GetRequiredService<ExpenseService>();
return new OpenAIClient(apiKey)
.GetChatClient(model)
.AsIChatClient()
.AsAIAgent(
name: "ExpenseApprovalAgent",
instructions: "You are an expense approval assistant. You can list pending expenses "
+ "and approve them if the user has the required permissions and approval limit. "
+ "Keep responses concise.",
tools:
[
AIFunctionFactory.Create(expenseService.ListPendingExpenses),
AIFunctionFactory.Create(expenseService.ApproveExpense),
]);
});
WebApplication app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// ---------------------------------------------------------------------------
// POST /chat — requires the "agent.chat" scope
// ---------------------------------------------------------------------------
app.MapPost("/chat", [Authorize(Policy = "AgentChat")] async (ChatRequest request, IUserContext userContext, AIAgent agent) =>
{
var response = await agent.RunAsync(request.Message);
return Results.Ok(new ChatResponse(response.Text, userContext.DisplayName));
});
await app.RunAsync();
// ---------------------------------------------------------------------------
// Request / Response models
// ---------------------------------------------------------------------------
internal sealed record ChatRequest(string Message);
internal sealed record ChatResponse(string Reply, string User);
[JsonSerializable(typeof(ChatRequest))]
[JsonSerializable(typeof(ChatResponse))]
internal sealed partial class SampleServiceSerializerContext : JsonSerializerContext;
@@ -0,0 +1,12 @@
{
"profiles": {
"Service": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:55001;http://localhost:5001"
}
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Security.Claims;
namespace AspNetAgentAuthorization.Service;
/// <summary>
/// Provides the authenticated user's identity for the current request.
/// </summary>
public interface IUserContext
{
/// <summary>Unique identifier for the current user (e.g. the OIDC "sub" claim).</summary>
string UserId { get; }
/// <summary>Login name for the current user.</summary>
string UserName { get; }
/// <summary>Human-readable display name (e.g. "Test User").</summary>
string DisplayName { get; }
/// <summary>OAuth scopes granted in the current access token.</summary>
IReadOnlySet<string> Scopes { get; }
}
/// <summary>
/// Resolves the current user's identity from Keycloak-specific JWT claims.
/// Keycloak uses <c>sub</c> for the user ID, <c>preferred_username</c>
/// for the login name, <c>given_name</c>/<c>family_name</c> for the
/// display name, and <c>scope</c> (space-delimited) for granted scopes.
/// Registered as a scoped service so it is resolved once per request.
/// </summary>
public sealed class KeycloakUserContext : IUserContext
{
public string UserId { get; }
public string UserName { get; }
public string DisplayName { get; }
public IReadOnlySet<string> Scopes { get; }
public KeycloakUserContext(IHttpContextAccessor httpContextAccessor)
{
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
?? user?.FindFirstValue("sub")
?? "anonymous";
this.UserName = user?.FindFirstValue("preferred_username")
?? user?.FindFirstValue(ClaimTypes.Name)
?? "unknown";
string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName);
string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname);
this.DisplayName = (givenName, familyName) switch
{
(not null, not null) => $"{givenName} {familyName}",
(not null, null) => givenName,
(null, not null) => familyName,
_ => this.UserName,
};
string? scopeClaim = user?.FindFirstValue("scope");
this.Scopes = scopeClaim is not null
? new HashSet<string>(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Auth": {
"Authority": "http://localhost:5002/realms/dev",
"Audience": "agent-service"
}
}
@@ -0,0 +1,80 @@
services:
keycloak:
image: quay.io/keycloak/keycloak:latest
container_name: auth-keycloak
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
- KC_HOSTNAME_STRICT=false
- KC_PROXY_HEADERS=xforwarded
volumes:
- ./keycloak/dev-realm.json:/opt/keycloak/data/import/dev-realm.json
command: ["start-dev", "--import-realm"]
ports:
- "5002:8080"
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/master HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200'"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
# One-shot init container that registers the Codespaces redirect URI
# with Keycloak after it becomes healthy. Auto-detects Codespaces via
# CODESPACE_NAME and GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN env vars.
keycloak-init:
image: curlimages/curl:latest
container_name: auth-keycloak-init
environment:
- KEYCLOAK_URL=http://keycloak:8080
- CODESPACE_NAME=${CODESPACE_NAME:-}
- GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-}
volumes:
- ./keycloak/setup-redirect-uris.sh:/setup-redirect-uris.sh:ro
entrypoint: ["sh", "/setup-redirect-uris.sh"]
depends_on:
keycloak:
condition: service_healthy
agent-service:
build:
context: ../../..
dockerfile: samples/05-end-to-end/AspNetAgentAuthorization/Service/Dockerfile
container_name: auth-agent-service
environment:
- ASPNETCORE_ENVIRONMENT=Development
- Auth__Authority=http://keycloak:8080/realms/dev
- Auth__Audience=agent-service
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4.1-mini}
ports:
- "5001:5001"
depends_on:
keycloak:
condition: service_healthy
web-client:
build:
context: ../../..
dockerfile: samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile
container_name: auth-web-client
environment:
- ASPNETCORE_ENVIRONMENT=Development
- Auth__Authority=http://keycloak:8080/realms/dev
- Auth__PublicKeycloakUrl=http://localhost:5002
- Auth__ClientId=web-client
- AgentService__BaseUrl=http://agent-service:5001
- CODESPACE_NAME=${CODESPACE_NAME:-}
- GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-}
ports:
- "8080:8080"
volumes:
- web-client-keys:/app/keys
depends_on:
keycloak:
condition: service_healthy
agent-service:
condition: service_started
volumes:
web-client-keys:
@@ -0,0 +1,232 @@
{
"realm": "dev",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"roles": {
"realm": [
{
"name": "agent-chat-user",
"description": "Grants access to the agent.chat scope"
},
{
"name": "expenses-viewer",
"description": "Grants access to the expenses.view scope"
},
{
"name": "expenses-approver",
"description": "Grants access to the expenses.approve scope"
}
]
},
"scopeMappings": [
{
"clientScope": "agent.chat",
"roles": ["agent-chat-user"]
},
{
"clientScope": "expenses.view",
"roles": ["expenses-viewer"]
},
{
"clientScope": "expenses.approve",
"roles": ["expenses-approver"]
}
],
"clientScopes": [
{
"name": "openid",
"description": "OpenID Connect scope",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true"
},
"protocolMappers": [
{
"name": "sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-sub-mapper",
"config": {
"introspection.token.claim": "true",
"access.token.claim": "true"
}
}
]
},
{
"name": "profile",
"description": "OpenID Connect profile scope",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true"
},
"protocolMappers": [
{
"name": "preferred_username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "username",
"claim.name": "preferred_username",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "given_name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "firstName",
"claim.name": "given_name",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "family_name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "lastName",
"claim.name": "family_name",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
},
{
"name": "email",
"description": "OpenID Connect email scope",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true"
}
},
{
"name": "agent.chat",
"description": "Allows chatting with the agent",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
}
},
{
"name": "expenses.view",
"description": "Allows viewing pending expenses",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
}
},
{
"name": "expenses.approve",
"description": "Allows approving pending expenses",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
}
},
{
"name": "agent-service-audience",
"description": "Adds the agent-service audience to access tokens",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "agent-service-audience-mapper",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.client.audience": "agent-service",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
],
"clients": [
{
"clientId": "agent-service",
"enabled": true,
"publicClient": false,
"secret": "agent-service-secret",
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"standardFlowEnabled": false,
"protocol": "openid-connect"
},
{
"clientId": "web-client",
"enabled": true,
"publicClient": true,
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"fullScopeAllowed": false,
"protocol": "openid-connect",
"redirectUris": [
"http://localhost:8080/*"
],
"webOrigins": [
"http://localhost:8080"
],
"defaultClientScopes": [
"openid",
"profile",
"email",
"agent-service-audience"
],
"optionalClientScopes": [
"agent.chat",
"expenses.view",
"expenses.approve"
]
}
],
"users": [
{
"username": "testuser",
"enabled": true,
"email": "testuser@example.com",
"firstName": "Test",
"lastName": "User",
"realmRoles": ["agent-chat-user", "expenses-viewer", "expenses-approver"],
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
]
},
{
"username": "viewer",
"enabled": true,
"email": "viewer@example.com",
"firstName": "View",
"lastName": "Only",
"realmRoles": ["agent-chat-user", "expenses-viewer"],
"credentials": [
{
"type": "password",
"value": "password",
"temporary": false
}
]
}
]
}
@@ -0,0 +1,50 @@
#!/bin/bash
# Adds an extra redirect URI to the Keycloak web-client configuration.
# Auto-detects GitHub Codespaces via CODESPACE_NAME and
# GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN environment variables.
set -e
KEYCLOAK_URL="${KEYCLOAK_URL:-http://keycloak:8080}"
# Auto-detect Codespaces
if [ -n "$CODESPACE_NAME" ] && [ -n "$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" ]; then
WEBCLIENT_PUBLIC_URL="https://${CODESPACE_NAME}-8080.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}"
fi
if [ -z "$WEBCLIENT_PUBLIC_URL" ]; then
echo "Not running in Codespaces — skipping redirect URI setup."
exit 0
fi
echo "Configuring Keycloak redirect URIs for: $WEBCLIENT_PUBLIC_URL"
# Get admin token
TOKEN=$(curl -sf -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \
-d "grant_type=password&client_id=admin-cli&username=admin&password=admin" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
if [ -z "$TOKEN" ]; then
echo "ERROR: Failed to get admin token" >&2
exit 1
fi
# Get web-client UUID
CLIENT_UUID=$(curl -sf "$KEYCLOAK_URL/admin/realms/dev/clients?clientId=web-client" \
-H "Authorization: Bearer $TOKEN" \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
if [ -z "$CLIENT_UUID" ]; then
echo "ERROR: Failed to find web-client UUID" >&2
exit 1
fi
# Update redirect URIs and web origins
curl -sf -X PUT "$KEYCLOAK_URL/admin/realms/dev/clients/$CLIENT_UUID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"redirectUris\": [\"http://localhost:8080/*\", \"${WEBCLIENT_PUBLIC_URL}/*\"],
\"webOrigins\": [\"http://localhost:8080\", \"${WEBCLIENT_PUBLIC_URL}\"]
}"
echo "Keycloak redirect URIs updated successfully."