mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
8a18f39b36
commit
f6b0610a6c
@@ -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.* -->
|
||||
|
||||
@@ -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"]
|
||||
+35
@@ -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>
|
||||
}
|
||||
+79
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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>
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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>
|
||||
+3
@@ -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();
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"RazorWebClient": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:58080;http://localhost:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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;
|
||||
+12
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+50
@@ -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."
|
||||
Reference in New Issue
Block a user