-
Notifications
You must be signed in to change notification settings - Fork 1.3k
.NET: AuthN & AuthZ sample with asp.net service and web client #4354
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+1,315
−0
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
c4c0ba6
Add sample demonstrating authentication and user access in agent tools
westey-m bea93ed
Merge branch 'main' into client-server-auth-sample
westey-m 7ee3d9e
Add fixes to enable running on windows
westey-m 21e7673
Add launchsettings, add docker-compose to slnx and fix formatting
westey-m 752e47f
Switch to Expenses rather than todo based sample and address PR comments
westey-m 1add4ce
Rename sample
westey-m 7cc1366
Merge branch 'main' into client-server-auth-sample
westey-m 778308c
Fix formatting
westey-m File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
156 changes: 156 additions & 0 deletions
156
dotnet/samples/05-end-to-end/AspNetAgentAuthorization/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
29 changes: 29 additions & 0 deletions
29
dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Dockerfile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
35 changes: 35 additions & 0 deletions
35
dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| } |
79 changes: 79 additions & 0 deletions
79
dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Chat.cshtml.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}"; | ||
| } | ||
| } | ||
| } |
18 changes: 18 additions & 0 deletions
18
dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| } |
24 changes: 24 additions & 0 deletions
24
dotnet/samples/05-end-to-end/AspNetAgentAuthorization/RazorWebClient/Pages/Index.cshtml.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.