diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 396a316575..1b1e0daa08 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -58,6 +58,8 @@
+
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 1ab73c2b8b..b35178f011 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -288,6 +288,12 @@
+
+
+
+
+
+
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md
new file mode 100644
index 0000000000..c84dd125c3
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md
@@ -0,0 +1,156 @@
+# Auth Client-Server Sample
+
+This sample demonstrates how to authorize AI agents and their tools using OAuth 2.0 scopes. It shows two levels of access control: an endpoint-level scope (`agent.chat`) that gates access to the agent, and tool-level scopes (`expenses.view`, `expenses.approve`) that control what the agent can do on behalf of each user.
+
+While this sample uses Keycloak to avoid complex setup in order to run the sample, Keycloak can easily be replaced with any OIDC compatible provider, including [Microsoft Entra Id](https://www.microsoft.com/security/business/identity-access/microsoft-entra-id).
+
+## Overview
+
+The sample has three components, all launched with a single `docker compose up`:
+
+| Service | Port | Description |
+|---------|------|-------------|
+| **WebClient** | `http://localhost:8080` | Razor Pages web app with OIDC login and a chat UI that calls the AgentService |
+| **AgentService** | `http://localhost:5001` | ASP.NET Minimal API hosting an expense approval agent with scope-authorized tools |
+| **Keycloak** | `http://localhost:5002` | OIDC identity provider, auto-provisioned with realm, clients, scopes, and test users |
+
+```
+┌──────────────┐ OIDC login ┌───────────┐
+│ WebClient │ ◄──────────────────► │ Keycloak │
+│ (Razor app) │ (browser flow) │ (Docker) │
+│ :8080 │ │ :5002 │
+└──────┬───────┘ └─────┬─────┘
+ │ REST + Bearer token │
+ ▼ │
+┌───────────────┐ JWT validation ──────┘
+│ AgentService │ ◄──── (jwks from Keycloak)
+│ (Minimal API) │
+│ :5001 │
+└───────────────┘
+```
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
+
+## Configuring Environment Variables
+
+The AgentService requires an OpenAI-compatible endpoint. Set these environment variables before running:
+
+```bash
+export OPENAI_API_KEY=""
+export OPENAI_MODEL="gpt-4.1-mini"
+```
+
+## Running the Sample
+
+### Option 1: Docker Compose (Recommended)
+
+```bash
+cd dotnet/samples/05-end-to-end/AspNetAgentAuthorization
+docker compose up
+```
+
+This starts Keycloak, the AgentService, and the WebClient. Wait for Keycloak to finish importing the realm (you'll see `Running the server` in the logs).
+
+#### Running in GitHub Codespaces
+
+This sample has been built in such a way that it can be run from GitHub Codespaces.
+The Agent Framework repository has a C# specific dev container, named "C# (.NET)", that is configured for Codespaces.
+
+When running in Codespaces, the sample auto-detects the environment via
+`CODESPACE_NAME` and `GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN` and configures
+Keycloak and the web client accordingly. Just make the required ports public:
+
+```bash
+# Make Keycloak and WebClient ports publicly accessible
+gh codespace ports visibility 5002:public 8080:public -c $CODESPACE_NAME
+
+# Start the containers (Codespaces is auto-detected)
+docker compose up
+```
+
+Then open the Codespaces-forwarded URL for port 8080 (shown in the **Ports** tab) in your browser.
+
+### Option 2: Run Locally
+
+1. Start Keycloak:
+ ```bash
+ docker compose up keycloak
+ ```
+
+2. In a new terminal, start the AgentService:
+ ```bash
+ cd Service
+ dotnet run --urls "http://localhost:5001"
+ ```
+
+3. In another terminal, start the WebClient:
+ ```bash
+ cd RazorWebClient
+ dotnet run --urls "http://localhost:8080"
+ ```
+
+## Using the Sample
+
+1. Open `http://localhost:8080` in your browser
+2. Click **Login** — you'll be redirected to Keycloak
+3. Sign in with one of the pre-configured users:
+ - **`testuser` / `password`** — can chat, view expenses, and approve expenses (up to €1,000)
+ - **`viewer` / `password`** — can chat and view expenses, but **cannot approve** them
+4. Try asking the agent:
+ - _"Show me the pending expenses"_ — both users can do this
+ - _"Approve expense #1"_ — only `testuser` can do this; `viewer` will be denied
+ - _"Approve expense #3"_ — even `testuser` will be denied (€4,500 exceeds the €1,000 limit)
+
+## Pre-Configured Keycloak Realm
+
+The `keycloak/dev-realm.json` file auto-provisions:
+
+| Resource | Details |
+|----------|---------|
+| **Realm** | `dev` |
+| **Client: agent-service** | Confidential client (the API audience) |
+| **Client: web-client** | Public client for the Razor app's OIDC login |
+| **Scope: agent.chat** | Required to call the `/chat` endpoint |
+| **Scope: expenses.view** | Required to list pending expenses |
+| **Scope: expenses.approve** | Required to approve expenses |
+| **User: testuser** | Has `agent.chat`, `expenses.view`, and `expenses.approve` scopes |
+| **User: viewer** | Has `agent.chat` and `expenses.view` scopes (no approval) |
+
+### Pre-Seeded Expenses
+
+The service starts with five demo expenses:
+
+| # | Description | Amount | Status |
+|---|-------------|--------|--------|
+| 1 | Conference travel — Berlin | €850 | Pending |
+| 2 | Team dinner — Q4 celebration | €320 | Pending |
+| 3 | Cloud infrastructure — annual renewal | €4,500 | Pending (over limit) |
+| 4 | Office supplies — ergonomic keyboards | €675 | Pending |
+| 5 | Client gift baskets — holiday season | €980 | Pending |
+
+Keycloak admin console: `http://localhost:5002` (login: `admin` / `admin`).
+
+## API Endpoints
+
+### POST /chat (requires `agent.chat` scope)
+
+```bash
+# Get a token for testuser
+TOKEN=$(curl -s -X POST http://localhost:5002/realms/dev/protocol/openid-connect/token \
+ -d "grant_type=password&client_id=web-client&username=testuser&password=password&scope=openid agent.chat expenses.view expenses.approve" \
+ | jq -r '.access_token')
+
+# Chat with the agent
+curl -X POST http://localhost:5001/chat \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"message": "Show me the pending expenses"}'
+```
+
+## Key Concepts Demonstrated
+
+- **Endpoint-Level Authorization** — The `/chat` endpoint requires the `agent.chat` scope, gating access to the agent itself
+- **Tool-Level Authorization** — Each agent tool checks its own scope (`expenses.view`, `expenses.approve`) at runtime, so different users have different capabilities within the same chat session
+- **Scope-Based Role Mapping** — Keycloak realm roles map to OAuth scopes, allowing administrators to control which users can access which agent capabilities
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile
new file mode 100644
index 0000000000..8e15ba2425
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile
@@ -0,0 +1,29 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /repo
+
+# Copy solution-level files for restore
+COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json nuget.config ./
+COPY eng/ eng/
+COPY src/Shared/ src/Shared/
+COPY samples/Directory.Build.props samples/
+
+# Create sentinel file so $(RepoRoot) resolves correctly inside the container.
+# RepoRoot is the parent of the dir containing CODE_OF_CONDUCT.md,
+# and src projects import $(RepoRoot)/dotnet/nuget/nuget-package.props.
+RUN touch /CODE_OF_CONDUCT.md
+
+# Copy project file for restore
+COPY samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/
+
+RUN dotnet restore samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj -p:TargetFramework=net10.0 -p:TreatWarningsAsErrors=false
+
+# Copy everything and build
+COPY samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/ samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/
+RUN dotnet publish samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/RazorWebClient.csproj -c Release -f net10.0 -o /app -p:TreatWarningsAsErrors=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:10.0
+WORKDIR /app
+COPY --from=build /app .
+ENV ASPNETCORE_URLS=http://+:8080
+EXPOSE 8080
+ENTRYPOINT ["dotnet", "RazorWebClient.dll"]
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml
new file mode 100644
index 0000000000..edccf4c34e
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml
@@ -0,0 +1,35 @@
+@page
+@using Microsoft.AspNetCore.Authorization
+@attribute [Authorize]
+@model AspNetAgentAuthorization.RazorWebClient.Pages.ChatModel
+@{
+ Layout = "_Layout";
+}
+
+
Chat with the Agent
+
+
+
+@if (Model.Error is not null)
+{
+
+ Error: @Model.Error
+
+}
+
+@if (Model.Reply is not null)
+{
+
+
Agent (responding to @Model.ReplyUser):
+
@Model.Reply
+
+}
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml.cs
new file mode 100644
index 0000000000..5326e7ae9d
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace AspNetAgentAuthorization.RazorWebClient.Pages;
+
+public class ChatModel : PageModel
+{
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ public ChatModel(IHttpClientFactory httpClientFactory)
+ {
+ this._httpClientFactory = httpClientFactory;
+ }
+
+ [BindProperty]
+ public string? Message { get; set; }
+
+ public string? Reply { get; set; }
+ public string? ReplyUser { get; set; }
+ public string? Error { get; set; }
+
+ public void OnGet()
+ {
+ }
+
+ public async Task OnPostAsync()
+ {
+ if (string.IsNullOrWhiteSpace(this.Message))
+ {
+ return;
+ }
+
+ try
+ {
+ // Get the access token stored during OIDC login
+ string? accessToken = await this.HttpContext.GetTokenAsync("access_token");
+ if (accessToken is null)
+ {
+ this.Error = "No access token available. Please log in again.";
+ return;
+ }
+
+ // Call the AgentService with the Bearer token
+ var client = this._httpClientFactory.CreateClient("AgentService");
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ var payload = JsonSerializer.Serialize(new { message = this.Message });
+ var content = new StringContent(payload, Encoding.UTF8, "application/json");
+
+ var response = await client.PostAsync(new Uri("/chat", UriKind.Relative), content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
+ this.Reply = json.RootElement.GetProperty("reply").GetString();
+ this.ReplyUser = json.RootElement.GetProperty("user").GetString();
+ }
+ else
+ {
+ this.Error = response.StatusCode switch
+ {
+ System.Net.HttpStatusCode.Unauthorized => "Authentication failed (401). Your session may have expired.",
+ System.Net.HttpStatusCode.Forbidden => "Access denied (403). Your account does not have the required 'agent.chat' scope.",
+ _ => $"AgentService returned {(int)response.StatusCode} {response.ReasonPhrase}."
+ };
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Error = $"Failed to contact the AgentService: {ex.Message}";
+ }
+ }
+}
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml
new file mode 100644
index 0000000000..ab1d7cb1dc
--- /dev/null
+++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml
@@ -0,0 +1,18 @@
+@page
+@model AspNetAgentAuthorization.RazorWebClient.Pages.IndexModel
+@{
+ Layout = "_Layout";
+}
+
+
Welcome
+
This sample demonstrates securing an AI agent API with OAuth 2.0 / OpenID Connect.