Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -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.* -->
Expand Down
6 changes: 6 additions & 0 deletions dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,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" />
Expand Down
156 changes: 156 additions & 0 deletions dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -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>
}
Original file line number Diff line number Diff line change
@@ -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}";
}
}
}
Original file line number Diff line number Diff line change
@@ -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>
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading