diff --git a/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/.plan-log.md b/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/.plan-log.md new file mode 100644 index 0000000..b2b1603 --- /dev/null +++ b/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/.plan-log.md @@ -0,0 +1,8 @@ +--- +title: "Self-Issued Client Builder Shorthand — Planning Log" +ms.date: 2026-05-28 +--- + +## Status: ALL PHASES COMPLETE + +All 4 phases implemented. API: `SelfIssued()`, `WithSelfIssuedToken()`, `WithPersonServer()`. Samples, docs, and GuidedTour updated. 593 tests pass. diff --git a/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/before-after-summary.md b/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/before-after-summary.md new file mode 100644 index 0000000..dc2ac91 --- /dev/null +++ b/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/before-after-summary.md @@ -0,0 +1,362 @@ +--- +title: "Before / After — Fluent API Migration Summary" +description: Side-by-side comparison of old vs new API patterns and affected files +ms.date: 2026-05-28 +--- + +## Self-Issued Identity (Phases 1 + 5) + +### Before + +```csharp +AAuthClientBuilder.SelfIssued(key, issuer, subject, kid) + .WithPersonServer(ps) + .WithChallengeHandling() + .Build(); +``` + +### After + +```csharp +AAuthClientBuilder.SelfIssuing(key) + .As(issuer, subject) + .WithKid(kid) + .WithPersonServer(ps) + .WithChallengeHandling() + .Build(); +``` + +### Files Modified + +| File | Change | +|------|--------| +| [src/AAuth/HttpSig/AAuthClientBuilder.cs](../../../src/AAuth/HttpSig/AAuthClientBuilder.cs) | Added `SelfIssuing()` factory, marked `SelfIssued()` as `[Obsolete]` | +| [src/AAuth/HttpSig/SelfIssuingBuilder.cs](../../../src/AAuth/HttpSig/SelfIssuingBuilder.cs) | New file — fluent sub-builder with `.As()`, `.WithKid()`, delegation methods | +| [samples/Orchestrator/Program.cs](../../../samples/Orchestrator/Program.cs) | `SelfIssued(key, url, id, kid)` → `SelfIssuing(key).As(url, id).WithKid(kid)` | +| [samples/SampleApp/Components/Pages/Jwt.razor](../../../samples/SampleApp/Components/Pages/Jwt.razor) | Same pattern in both execution code and display snippet | +| [samples/SampleApp/Components/Pages/CallChain.razor](../../../samples/SampleApp/Components/Pages/CallChain.razor) | Same pattern + Orchestrator handler snippet | +| [samples/SampleApp/Components/Pages/Deferred.razor](../../../samples/SampleApp/Components/Pages/Deferred.razor) | Same pattern in execution code and display snippet | +| [samples/GuidedTour/CodeSnippets.cs](../../../samples/GuidedTour/CodeSnippets.cs) | `CallChainConvenience` snippet | +| [docs/README.md](../../../docs/README.md) | API table: added `SelfIssuing()` and `Enrolled()` | +| [docs/getting-started.md](../../../docs/getting-started.md) | Self-issued examples (2 occurrences) | +| [docs/signing-modes/agent-token-jwt.md](../../../docs/signing-modes/agent-token-jwt.md) | Primary example | +| [docs/workflows/ps-asserted-access.md](../../../docs/workflows/ps-asserted-access.md) | Self-issued code example | +| [docs/workflows/bootstrap-enrollment.md](../../../docs/workflows/bootstrap-enrollment.md) | Self-issued section | +| [README.md](../../../README.md) | Quick-start snippet | +| [tests/AAuth.Tests/HttpSig/SelfIssuingBuilderTests.cs](../../../tests/AAuth.Tests/HttpSig/SelfIssuingBuilderTests.cs) | New file — 12 tests | + +--- + +## AP-Enrolled Client (Phase 6) + +### Before + +```csharp +new AAuthClientBuilder(key) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) + .Build()) + .WithChallengeHandling("https://ps.example") + .Build(); +``` + +### After + +```csharp +AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(refreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) + .WithChallengeHandling("https://ps.example") + .Build(); +``` + +### Files Modified + +| File | Change | +|------|--------| +| [src/AAuth/HttpSig/AAuthClientBuilder.cs](../../../src/AAuth/HttpSig/AAuthClientBuilder.cs) | Added `Enrolled()` factory | +| [src/AAuth/HttpSig/EnrolledBuilder.cs](../../../src/AAuth/HttpSig/EnrolledBuilder.cs) | New file — fluent sub-builder with `.RefreshingFrom()`, `.WithKeyStore()`, `.WithRefreshMode()` | +| [samples/GuidedTour/CodeSnippets.cs](../../../samples/GuidedTour/CodeSnippets.cs) | `SignedGetJwt`, `TokenExchangeDirect`, `FullAutomatic` snippets | +| [samples/SampleApp/Components/Pages/Jwt.razor](../../../samples/SampleApp/Components/Pages/Jwt.razor) | Display code panel | +| [samples/SampleApp/Components/Pages/CallChain.razor](../../../samples/SampleApp/Components/Pages/CallChain.razor) | Agent code panel | +| [samples/SampleApp/Components/Pages/Deferred.razor](../../../samples/SampleApp/Components/Pages/Deferred.razor) | Display code panel | +| [docs/getting-started.md](../../../docs/getting-started.md) | AP-enrolled examples (3 occurrences) | +| [docs/workflows/bootstrap-enrollment.md](../../../docs/workflows/bootstrap-enrollment.md) | Runtime, single-key refresh, two-key refresh sections | +| [tests/AAuth.Tests/HttpSig/EnrolledBuilderTests.cs](../../../tests/AAuth.Tests/HttpSig/EnrolledBuilderTests.cs) | New file — 10 tests | + +--- + +## Two-Key Refresh (via Enrolled) + +### Before + +```csharp +new AAuthClientBuilder(key) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) + .WithRefreshMode(RefreshMode.TwoKey, apIssuer) + .Build()) + .Build(); +``` + +### After + +```csharp +AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(refreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) + .WithRefreshMode(RefreshMode.TwoKey, apIssuer) + .Build(); +``` + +### Files Modified + +| File | Change | +|------|--------| +| [docs/workflows/bootstrap-enrollment.md](../../../docs/workflows/bootstrap-enrollment.md) | Two-key refresh section | + +--- + +## Unified Resource Pipeline (Phase 7) + +### Before + +```csharp +app.MapAAuthResourceWellKnown(new AAuthResourceMetadataOptions { ... }); +app.UseAAuthVerification(new AAuthVerificationOptions { ... }); +app.UseAAuthChallenge(new ChallengeOptions { ... }); +``` + +### After + +```csharp +// DI registration (existing — unchanged) +builder.Services.AddAAuthResource(options => { ... }); + +// Single call replaces all three middleware registrations +app.MapAAuthResource(); + +// Or with inline options: +app.MapAAuthResource(opts => +{ + opts.RequireIssuerVerification = true; + opts.AccessMode = AAuthAccessMode.RequireAuthToken; + opts.TrustedAuthTokenIssuers = new HashSet { "https://ps.example" }; +}); +``` + +### Files Created + +| File | Change | +|------|--------| +| [src/AAuth/DependencyInjection/AAuthApplicationBuilderExtensions.cs](../../../src/AAuth/DependencyInjection/AAuthApplicationBuilderExtensions.cs) | Added `MapAAuthResource()` extension method | +| [src/AAuth/DependencyInjection/AAuthResourcePipelineOptions.cs](../../../src/AAuth/DependencyInjection/AAuthResourcePipelineOptions.cs) | New file — options class for the unified pipeline | + +--- + +## Call-Chaining (Intermediary) + +### Before + +```csharp +using var client = new AAuthClientBuilder(key) + .WithTokenRefresh(refreshFunc) + .WithCallChaining(ctx) + .Build(); +``` + +### After + +```csharp +using var client = AAuthClientBuilder.SelfIssuing(key) + .As(orchestratorUrl, agentId) + .WithPersonServer(psUrl) + .WithCallChaining(ctx) + .Build(); +``` + +### Files Modified + +| File | Change | +|------|--------| +| [samples/Orchestrator/Program.cs](../../../samples/Orchestrator/Program.cs) | Handler code | +| [samples/SampleApp/Components/Pages/CallChain.razor](../../../samples/SampleApp/Components/Pages/CallChain.razor) | Orchestrator handler display snippet | +| [samples/GuidedTour/CodeSnippets.cs](../../../samples/GuidedTour/CodeSnippets.cs) | `CallChainConvenience` snippet | + +--- + +## Summary of New Public API Surface + +| Entry Point | Returns | Purpose | +|-------------|---------|---------| +| `AAuthClientBuilder.SelfIssuing(key)` | `SelfIssuingBuilder` | Self-issued identity (hosted services) | +| `AAuthClientBuilder.Enrolled(key)` | `EnrolledBuilder` | AP-enrolled agents | +| `app.MapAAuthResource()` | `WebApplication` | Unified resource middleware pipeline | + +| Sub-Builder Method | On | Effect | +|---|---|---| +| `.As(issuer, subject)` | `SelfIssuingBuilder` | Sets iss/sub for the minted JWT | +| `.WithKid(kid)` | `SelfIssuingBuilder` | Custom key ID (defaults to JWK thumbprint) | +| `.RefreshingFrom(endpoint, handle)` | `EnrolledBuilder` | Sets AP refresh endpoint and local key handle | +| `.WithKeyStore(keyStore)` | `EnrolledBuilder` | Custom key store (defaults to `FileKeyStore.Default()`) | +| `.WithRefreshMode(mode, apIssuer)` | `EnrolledBuilder` | Single-key or two-key refresh | + +Both sub-builders delegate to `AAuthClientBuilder` for terminal methods (`.WithPersonServer()`, `.WithChallengeHandling()`, `.WithCallChaining()`, `.Build()`). + +--- + +## Backward Compatibility + +- `AAuthClientBuilder.SelfIssued(key, iss, sub, kid)` remains but is marked `[Obsolete]` +- `WithTokenRefresh(ITokenRefresher)` is unchanged — escape hatch for custom refreshers +- `new AAuthClientBuilder(key).UseHwk()/.UseJwksUri()/.UseJktJwt()` unchanged (identity-based modes) +- Individual middleware (`UseAAuthVerification`, `UseAAuthChallenge`, `MapAAuthWellKnown`) unchanged for per-path customization + +--- + +## Constants & Extension Methods (Phase 9) + +### Before — Bare String Literals & Manual Casts + +```csharp +// Resource endpoint — verbose, cast-heavy, magic strings +app.MapGet("/", (HttpContext ctx) => +{ + var parsed = (SignatureKeyParser.ParsedSignatureKeyInfo) + ctx.Items[AAuthVerificationMiddleware.ParsedInfoItemKey]!; + var typ = (string?)parsed.Header?["typ"]; + + if (typ == "aa-agent+jwt") + { + ctx.Response.Headers["AAuth-Requirement"] = ...; + return Results.Unauthorized(); + } +}); +``` + +### After — Extension Methods & Constants + +```csharp +// Resource endpoint — typed access, discoverable constants +app.MapGet("/", (HttpContext ctx) => +{ + var parsed = ctx.GetAAuthParsedKey()!; + var typ = (string?)parsed.Header?["typ"]; + + if (typ == AAuthConstants.TokenTypes.AgentToken) + { + ctx.Response.Headers[AAuthConstants.Headers.AAuthRequirement] = ...; + return Results.Unauthorized(); + } + + // Or use the higher-level typed result: + var result = ctx.GetAAuthVerification()!; + if (result.TokenType == AAuthTokenType.AgentToken) { ... } +}); +``` + +### Files Created + +| File | Purpose | +|------|---------| +| [src/AAuth/AAuthConstants.cs](../../../src/AAuth/AAuthConstants.cs) | Centralized protocol constants (Headers, Schemes, TokenTypes, DwkFiles) | +| [src/AAuth/AAuthTokenType.cs](../../../src/AAuth/AAuthTokenType.cs) | Token type enum + string↔enum extensions | +| [src/AAuth/Server/AAuthHttpContextExtensions.cs](../../../src/AAuth/Server/AAuthHttpContextExtensions.cs) | `GetAAuthVerification()`, `GetAAuthParsedKey()`, `GetAAuthResult()` | + +### SDK Files Updated (bare literals → constants) + +| File | Change | +|------|--------| +| [src/AAuth/Server/AAuthVerificationMiddleware.cs](../../../src/AAuth/Server/AAuthVerificationMiddleware.cs) | `"Signature"` → `AAuthConstants.Headers.Signature`, scheme strings → `AAuthConstants.Schemes.*`, `"AAuth-Error"` → constant, `AAuthVerificationResult.TokenType` now `AAuthTokenType` enum | +| [src/AAuth/Server/AAuthChallengeMiddleware.cs](../../../src/AAuth/Server/AAuthChallengeMiddleware.cs) | `"AAuth-Error"` → constant, `"hwk" or "jwks_uri"` → scheme constants, token type comparisons use `AAuthTokenType` enum | +| [src/AAuth/HttpSig/AAuthSigningHandler.cs](../../../src/AAuth/HttpSig/AAuthSigningHandler.cs) | `"Signature"`, `"Signature-Input"` → `AAuthConstants.Headers.*` | +| [src/AAuth/HttpSig/SignatureKeyParser.cs](../../../src/AAuth/HttpSig/SignatureKeyParser.cs) | Switch arms use `AAuthConstants.Schemes.*` | +| [src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs](../../../src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs) | Switch arms use `AAuthConstants.Schemes.*` | +| [src/AAuth/Agent/ChallengeHandler.cs](../../../src/AAuth/Agent/ChallengeHandler.cs) | Header filtering uses `AAuthConstants.Headers.*` | +| [src/AAuth/Agent/NamingJwtBuilder.cs](../../../src/AAuth/Agent/NamingJwtBuilder.cs) | `"naming+jwt"` → `AAuthConstants.TokenTypes.NamingJwt` | +| [src/AAuth/Discovery/ServerMetadata.cs](../../../src/AAuth/Discovery/ServerMetadata.cs) | DWK paths → `AAuthConstants.DwkFiles.*` | + +### Samples Updated (extension methods) + +| File | Change | +|------|--------| +| [samples/WhoAmI/Program.cs](../../../samples/WhoAmI/Program.cs) | 4× `(ParsedSignatureKeyInfo)ctx.Items[...]!` → `ctx.GetAAuthParsedKey()!` | +| [samples/MockPersonServer/Program.cs](../../../samples/MockPersonServer/Program.cs) | 1× same pattern | +| [samples/SampleApp/Components/Pages/CallChain.razor](../../../samples/SampleApp/Components/Pages/CallChain.razor) | Display snippet uses extension + `AAuthConstants.TokenTypes.AgentToken` | +| [samples/SampleApp/Components/Pages/Hwk.razor](../../../samples/SampleApp/Components/Pages/Hwk.razor) | Display snippet uses `ctx.GetAAuthParsedKey()!` | +| [samples/SampleApp/Components/Pages/JwksUri.razor](../../../samples/SampleApp/Components/Pages/JwksUri.razor) | Display snippet uses `ctx.GetAAuthParsedKey()!` | + +### Tests Added + +| File | Tests | +|------|-------| +| [tests/AAuth.Tests/AAuthConstantsTests.cs](../../../tests/AAuth.Tests/AAuthConstantsTests.cs) | 10 tests — verify constants match existing builder fields | +| [tests/AAuth.Tests/AAuthTokenTypeTests.cs](../../../tests/AAuth.Tests/AAuthTokenTypeTests.cs) | 13 tests — enum parse/format round-trips | +| [tests/AAuth.Tests/Server/AAuthHttpContextExtensionsTests.cs](../../../tests/AAuth.Tests/Server/AAuthHttpContextExtensionsTests.cs) | 6 tests — null when middleware not run, correct when set | + +--- + +## Full New Public API Surface (Phase 9) + +| Type | Member | Purpose | +|------|--------|---------| +| `AAuthConstants.Headers` | `.Signature`, `.SignatureInput`, `.SignatureKey`, `.AAuthError`, `.AAuthRequirement`, `.AAuthMission`, `.AAuthCapabilities` | HTTP header name constants | +| `AAuthConstants.Schemes` | `.Jwt`, `.Hwk`, `.JktJwt`, `.JwksUri` | Signature-Key scheme identifiers | +| `AAuthConstants.TokenTypes` | `.AgentToken`, `.AuthToken`, `.ResourceToken`, `.NamingJwt` | JWT typ header values | +| `AAuthConstants.DwkFiles` | `.Agent`, `.Person`, `.Access`, `.Resource` | Well-known DWK file names | +| `AAuthTokenType` | enum | Type-safe token type (Unknown, AgentToken, AuthToken, ResourceToken, NamingJwt) | +| `AAuthTokenTypeExtensions` | `.ToHeaderValue()`, `.ParseTokenType(string?)` | String↔enum conversion | +| `AAuthHttpContextExtensions` | `.GetAAuthVerification()`, `.GetAAuthParsedKey()`, `.GetAAuthResult()` | Typed HttpContext access | +| `AAuthVerificationResult.TokenType` | `AAuthTokenType` | Changed from `string?` to enum | + +--- + +## Phase 10: Resource-Side Response Helpers + +### Before (challenge a caller) + +```csharp +ctx.Response.Headers[AAuthConstants.Headers.AAuthRequirement] = + AAuthRequirementHeader.FormatAuthToken(resourceToken); +return Results.Json(new { error = "auth_token_required" }, + statusCode: StatusCodes.Status401Unauthorized); +``` + +### After + +```csharp +return ctx.ChallengeAAuth(resourceToken); +``` + +### Before (set error header) + +```csharp +ctx.Response.Headers[AAuthConstants.Headers.AAuthError] = "something went wrong"; +``` + +### After + +```csharp +ctx.SetAAuthError("something went wrong"); +``` + +### Before (read token type) + +```csharp +var result = ctx.Features.Get(); +var tokenType = result?.TokenType ?? AAuthTokenType.Unknown; +``` + +### After + +```csharp +var tokenType = ctx.GetAAuthTokenType(); +``` + +### New API Surface (Phase 10) + +| Type | Member | Purpose | +|------|--------|---------| +| `AAuthHttpContextExtensions` | `.ChallengeAAuth(string resourceToken)` | Sets `AAuth-Requirement` header + returns 401 IResult | +| `AAuthHttpContextExtensions` | `.SetAAuthError(string message)` | Sets `AAuth-Error` response header | +| `AAuthHttpContextExtensions` | `.GetAAuthTokenType()` | Returns `AAuthTokenType` from verification result | diff --git a/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/implementation-plan.md b/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/implementation-plan.md new file mode 100644 index 0000000..b582680 --- /dev/null +++ b/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/implementation-plan.md @@ -0,0 +1,636 @@ +--- +title: "Client Builder Fluent Shorthand — Implementation Plan" +description: Phased plan for fluent shorthand methods across the AAuth SDK builder surface +ms.date: 2026-05-28 +--- + +> **Phases 1–4:** COMPLETE (self-issued shorthand) +> **Phases 5–10:** COMPLETE (fluent refactor + AP-enrolled + resource shorthand + constants/extensions + response helpers) + +--- + +## Phase 1: Core API — `AAuthClientBuilder.SelfIssued()` + `WithSelfIssuedToken()` ✅ + +*(Completed — see git log)* + +### Definition of Done + +- [x] `AAuthClientBuilder.SelfIssued()` compiles and is public +- [x] `AAuthClientBuilder.WithSelfIssuedToken()` compiles and is public +- [x] `AAuthClientBuilder.WithPersonServer()` compiles and is public +- [x] Unit tests pass (17 test cases) +- [x] Existing test suite passes (no regressions) +- [x] IntelliSense XML docs present on all new public members + +--- + +## Phase 2: Update Samples ✅ + +*(Completed — see git log)* + +### Definition of Done + +- [x] All samples compile (`dotnet build`) +- [x] No sample uses raw `AgentTokenBuilder` inside a `WithTokenRefresh` callback +- [x] Orchestrator sample removes the `SelfIssueAgentToken()` helper method + +--- + +## Phase 3: Update Documentation ✅ + +*(Completed — see git log)* + +### Definition of Done + +- [x] All doc code examples use shorthand as primary pattern +- [x] Verbose form preserved in "Advanced" sections +- [x] `docs/README.md` API table updated + +--- + +## Phase 4: Update GuidedTour ✅ + +*(Completed — see git log)* + +### Definition of Done + +- [x] GuidedTour compiles +- [x] Code snippets show the new shorthand + +--- + +## Phase 5: Fluent Refactor — `SelfIssuing(key).As(iss, sub)` ✅ + +Replace the positional-params `SelfIssued(key, iss, sub, kid)` with a fluent sub-builder +that makes the self-issuing (refresh) behavior obvious. + +### API Surface + +```csharp +// New entry point (verb form signals ongoing token minting) +public static SelfIssuingBuilder SelfIssuing(IAAuthKey key) + +// Sub-builder +public sealed class SelfIssuingBuilder +{ + public SelfIssuingBuilder As(string issuer, string subject) + public SelfIssuingBuilder WithKid(string kid) + + // Delegation back to AAuthClientBuilder + public AAuthClientBuilder WithPersonServer(string personServer) + public AAuthClientBuilder WithChallengeHandling() + public AAuthClientBuilder WithChallengeHandling(string personServer) + public AAuthClientBuilder WithChallengeHandling(Action configure) + public AAuthClientBuilder WithCallChaining(HttpContext ctx) + public AAuthClientBuilder WithCallChaining(Func provider) + public AAuthClientBuilder WithCallChaining(string upstreamToken) + public HttpClient Build() // terminal — delegates to inner builder + public HttpMessageHandler BuildHandler() // terminal +} +``` + +### Usage + +```csharp +// Golden path: +using var client = AAuthClientBuilder.SelfIssuing(key) + .As(issuer, subject) + .WithPersonServer(ps) + .WithChallengeHandling() + .Build(); + +// Custom kid: +using var client = AAuthClientBuilder.SelfIssuing(key) + .As(issuer, subject) + .WithKid("svc-key-1") + .WithPersonServer(ps) + .WithChallengeHandling() + .Build(); +``` + +### Backward Compatibility + +- `SelfIssued(key, iss, sub, kid)` remains (marks as `[Obsolete]` pointing to `SelfIssuing`) +- `WithSelfIssuedToken(iss, sub, kid)` remains (used by DI/From scenarios) + +### Files + +| Action | Path | +|--------|------| +| Create | `src/AAuth/HttpSig/SelfIssuingBuilder.cs` | +| Modify | `src/AAuth/HttpSig/AAuthClientBuilder.cs` (add `SelfIssuing()` factory) | +| Create | `tests/AAuth.Tests/HttpSig/SelfIssuingBuilderTests.cs` | + +### Definition of Done + +- [x] `SelfIssuingBuilder` compiles and is public +- [x] `AAuthClientBuilder.SelfIssuing(key)` returns it +- [x] `.As(iss, sub)` → `.WithPersonServer()` → `.Build()` produces working client +- [x] Unit tests pass (builder validation, delegation, kid default/override) +- [x] All 615 existing tests still pass + +--- + +## Phase 6: AP-Enrolled Client Shorthand — `AAuthClientBuilder.Enrolled(key)` ✅ + +The AP-enrolled pattern is verbose: + +```csharp +// Current (7 lines, 3 constructor calls) +new AAuthClientBuilder(key) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) + .Build()) + .WithChallengeHandling("https://ps.example") + .Build(); +``` + +### Proposed Fluent API + +```csharp +// New (reads naturally, refresh behavior obvious) +AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(refreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) + .WithPersonServer(ps) + .WithChallengeHandling() + .Build(); +``` + +### API Surface + +```csharp +public static EnrolledBuilder Enrolled(IAAuthKey key) + +public sealed class EnrolledBuilder +{ + public EnrolledBuilder RefreshingFrom(string refreshEndpoint, string localKeyHandle) + public EnrolledBuilder WithKeyStore(IKeyStore keyStore) + public EnrolledBuilder WithRefreshMode(RefreshMode mode, string? apIssuer = null) + + // Delegation to AAuthClientBuilder + public AAuthClientBuilder WithPersonServer(string personServer) + public AAuthClientBuilder WithChallengeHandling() + public AAuthClientBuilder WithChallengeHandling(string personServer) + public AAuthClientBuilder WithChallengeHandling(Action configure) + public AAuthClientBuilder WithInteractionHandling() + public AAuthClientBuilder WithInteractionHandling(Action configure) + public HttpClient Build() + public HttpMessageHandler BuildHandler() +} +``` + +### Usage Comparison + +| Scenario | Before | After | +|----------|--------|-------| +| Basic AP JWT | 7 lines | 5 lines | +| AP + interaction | 12 lines | 7 lines | +| Two-key refresh | 9 lines | 6 lines | + +### Files + +| Action | Path | +|--------|------| +| Create | `src/AAuth/HttpSig/EnrolledBuilder.cs` | +| Modify | `src/AAuth/HttpSig/AAuthClientBuilder.cs` (add `Enrolled()` factory) | +| Create | `tests/AAuth.Tests/HttpSig/EnrolledBuilderTests.cs` | + +### Definition of Done + +- [x] `EnrolledBuilder` compiles and is public +- [x] `.Enrolled(key).RefreshingFrom(...).WithKeyStore(...).Build()` produces working client +- [x] Default keystore is `FileKeyStore.Default()` when `WithKeyStore` is omitted +- [x] Two-key mode via `.WithRefreshMode(RefreshMode.TwoKey, apIssuer)` +- [x] Unit tests pass +- [x] Existing tests pass + +--- + +## Phase 7: Resource Setup Shorthand — Unified `app.UseAAuth()` ✅ + +Current resource setup requires 3 separate middleware calls + options repetition: + +```csharp +// Current (verbose, issuer/key repeated across options) +app.MapAAuthResourceWellKnown(new AAuthResourceMetadataOptions +{ + Issuer = resourceUrl, + SigningKeys = new Dictionary { [kid] = key }, + ScopeDescriptions = new() { ["read"] = "Read data" }, +}); + +app.UseAAuthVerification(new AAuthVerificationOptions +{ + ResourceIdentifier = resourceUrl, // repeated! + RequireIssuerVerification = true, +}); + +app.UseAAuthChallenge(new ChallengeOptions +{ + ResourceSigningKey = key, // repeated! + ResourceKeyId = kid, // repeated! + ResourceIdentifier = resourceUrl, // repeated! + DefaultScopes = "read", +}); +``` + +### Proposed: `app.UseAAuth(resource => ...)` + +```csharp +app.UseAAuth(resource => resource + .Issuer(resourceUrl) + .SigningKey(kid, key) + .Scopes(s => s.Add("read", "Read data")) + .RequireAuthToken() + .RequireIssuerVerification()); +``` + +### Alternative (less magic, more explicit): Keep DI + streamlined middleware + +```csharp +// DI registration (already good — AddAAuthResource is fine) +builder.Services.AddAAuthResource(options => +{ + options.Issuer = resourceUrl; + options.SigningKeys = new() { [kid] = key }; +}); + +// Middleware: single call replaces MapWellKnown + UseVerification + UseChallenge +app.MapAAuthResource(); // uses registered options for all three +``` + +### Analysis + +The resource-side verbosity is not as painful because: +1. It's write-once setup code (not per-request like client construction) +2. `AddAAuthResource(opts => ...)` already captures most config in one place +3. The 3 middleware calls serve distinct concerns (metadata / verification / challenge) and may be mixed differently per path + +**Recommendation:** A lighter touch — add `app.MapAAuthResource()` no-arg that reads from DI-registered `AAuthResourceOptions` to do all three in one call. Keep the individual middleware for advanced per-path configurations. + +### Files + +| Action | Path | +|--------|------| +| Modify | `src/AAuth/DependencyInjection/AAuthResourceServiceCollectionExtensions.cs` | +| Modify | `src/AAuth/Server/WellKnownEndpoints.cs` (add `MapAAuthResource()` extension) | +| Create | `tests/AAuth.Conformance/Server/MapAAuthResourceTests.cs` | + +### Definition of Done + +- [x] `app.MapAAuthResource()` serves well-known + configures verification + challenge middleware +- [x] Uses DI-registered `AAuthResourceOptions` automatically +- [x] Optional `Action` overload for inline config without DI pre-registration +- [x] Existing per-path middleware untouched +- [x] Unit tests pass + +--- + +## Phase 8: Update Samples & Docs for Phases 5–7 ✅ + +Update all samples and docs to use the new fluent APIs. + +### Files to Update + +| File | Change | +|------|--------| +| `samples/Orchestrator/Program.cs` | Use `SelfIssuing(key).As(...)` | +| `samples/SampleApp/Components/Pages/*.razor` | Same | +| `samples/AgentConsole/Program.cs` | AP-enrolled path uses `Enrolled(key).RefreshingFrom(...)` | +| `samples/WhoAmI/Program.cs` | Consider `MapAAuthResource()` consolidation | +| `docs/getting-started.md` | Update all code examples | +| `docs/signing-modes/agent-token-jwt.md` | Update examples | +| `docs/workflows/*.md` | Update relevant examples | +| `docs/reference/dependency-injection.md` | Update DI examples | +| `README.md` | Update quick-start | +| `samples/GuidedTour/CodeSnippets.cs` | Update snippets | + +### Definition of Done + +- [x] All samples compile +- [x] All docs use new fluent API as primary examples +- [x] Verbose forms preserved in "Advanced" expandable sections +- [x] All tests pass + +--- + +## Phase 9: Constants & HttpContext Extension Methods ✅ + +Consolidate bare string literals into well-named constants and provide typed +extension methods so resource endpoints don't need manual casts from +`HttpContext.Items`. + +### Problem + +Resource endpoint handlers currently require boilerplate: + +```csharp +var parsed = (SignatureKeyParser.ParsedSignatureKeyInfo) + ctx.Items[AAuthVerificationMiddleware.ParsedInfoItemKey]!; +var typ = (string?)parsed.Header?["typ"]; + +if (typ == "aa-agent+jwt") { ... } + +ctx.Response.Headers["AAuth-Requirement"] = ...; +``` + +Issues: +1. Casting from `object?` is error-prone and requires importing two types. +2. Token type, header name, and scheme strings are scattered as bare literals. +3. The `VerificationResult` in `Items` duplicates what `Features` already holds via `AAuthVerificationResult` — users don't know which to use. + +### A. Protocol Constants — `AAuthConstants` static class + +Centralize strings that appear in 3+ places and aren't already behind a named constant. + +```csharp +namespace AAuth; + +/// Well-known protocol constants for the AAuth SDK. +public static class AAuthConstants +{ + /// HTTP header names used by AAuth. + public static class Headers + { + public const string Signature = "Signature"; + public const string SignatureInput = "Signature-Input"; + public const string SignatureKey = "Signature-Key"; // already SignatureKeyHeader.Name + public const string AAuthError = "AAuth-Error"; + public const string AAuthRequirement = "AAuth-Requirement"; // already AAuthRequirementHeader.Name + public const string AAuthMission = "AAuth-Mission"; // already AAuthMission.Name + public const string AAuthCapabilities = "AAuth-Capabilities"; + } + + /// Signature-Key scheme identifiers. + public static class Schemes + { + public const string Jwt = "jwt"; + public const string Hwk = "hwk"; + public const string JktJwt = "jkt-jwt"; + public const string JwksUri = "jwks_uri"; + } + + /// Token type (typ header) values. + public static class TokenTypes + { + public const string AgentToken = "aa-agent+jwt"; + public const string AuthToken = "aa-auth+jwt"; + public const string ResourceToken = "aa-resource+jwt"; + public const string NamingJwt = "naming+jwt"; + } + + /// Well-known DWK file names. + public static class DwkFiles + { + public const string Agent = "aauth-agent.json"; + public const string Person = "aauth-person.json"; + public const string Access = "aauth-access.json"; + public const string Resource = "aauth-resource.json"; + } +} +``` + +### B. HttpContext Extension Methods — `AAuthHttpContextExtensions` + +Provide strongly-typed access so endpoints read: + +```csharp +using AAuth.Server; + +app.MapGet("/", (HttpContext ctx) => +{ + var result = ctx.GetAAuthVerification(); // AAuthVerificationResult (from Features) + var parsed = ctx.GetAAuthParsedKey(); // ParsedSignatureKeyInfo (from Items) + + if (result.TokenType == AAuthConstants.TokenTypes.AgentToken) { ... } +}); +``` + +Proposed surface: + +```csharp +namespace AAuth.Server; + +public static class AAuthHttpContextExtensions +{ + /// + /// Gets the from HttpContext.Features. + /// Returns null if verification middleware has not run. + /// + public static AAuthVerificationResult? GetAAuthVerification(this HttpContext context) + => context.Features.Get(); + + /// + /// Gets the from HttpContext.Items. + /// Returns null if verification middleware has not run. + /// + public static SignatureKeyParser.ParsedSignatureKeyInfo? GetAAuthParsedKey(this HttpContext context) + => context.Items.TryGetValue(AAuthVerificationMiddleware.ParsedInfoItemKey, out var obj) + ? obj as SignatureKeyParser.ParsedSignatureKeyInfo + : null; + + /// + /// Gets the from HttpContext.Items. + /// Prefer which returns the richer typed result. + /// + public static VerificationResult? GetAAuthResult(this HttpContext context) + => context.Items.TryGetValue(AAuthVerificationMiddleware.ContextItemKey, out var obj) + ? obj as VerificationResult + : null; +} +``` + +### C. Replace Bare Literals in SDK Source + +| File | Before | After | +|------|--------|-------| +| `AAuthVerificationMiddleware.cs` L91-92 | `"Signature"`, `"Signature-Input"` | `AAuthConstants.Headers.Signature`, `.SignatureInput` | +| `AAuthVerificationMiddleware.cs` L173, 211 | `"AAuth-Error"` | `AAuthConstants.Headers.AAuthError` | +| `AAuthVerificationMiddleware.cs` L227 | `"jwt" or "jkt-jwt"` | `AAuthConstants.Schemes.Jwt or AAuthConstants.Schemes.JktJwt` | +| `AAuthSigningHandler.cs` L163-168 | `"Signature"`, `"Signature-Input"` | `AAuthConstants.Headers.Signature`, `.SignatureInput` | +| `ChallengeHandler.cs` L203 | `"Signature" or "Signature-Input" or "Signature-Key"` | constants | +| `AAuthChallengeMiddleware.cs` L75, 135 | `"AAuth-Error"` | `AAuthConstants.Headers.AAuthError` | +| `AAuthChallengeMiddleware.cs` L80 | `"hwk" or "jwks_uri"` | `AAuthConstants.Schemes.Hwk or AAuthConstants.Schemes.JwksUri` | +| `SignatureKeyParser.cs` L96-99 | `"jwt"`, `"hwk"`, `"jkt-jwt"`, `"jwks_uri"` | scheme constants | +| `DefaultSignatureKeyResolver.cs` L38-41 | same scheme strings | scheme constants | +| `NamingJwtBuilder.cs` L31 | `"naming+jwt"` | `AAuthConstants.TokenTypes.NamingJwt` | +| `ServerMetadata.cs` L101,110,119 | DWK file names inline | `AAuthConstants.DwkFiles.*` | + +### D. Update Samples to Use Extension Methods + +| File | Before | After | +|------|--------|-------| +| `WhoAmI/Program.cs` (4 occurrences) | `(ParsedSignatureKeyInfo)ctx.Items[ParsedInfoItemKey]!` | `ctx.GetAAuthParsedKey()!` | +| `MockPersonServer/Program.cs` (1 occurrence) | same cast | `ctx.GetAAuthParsedKey()!` | +| `CallChain.razor` (execution code) | same cast | `ctx.GetAAuthParsedKey()!` | +| `Hwk.razor`, `JwksUri.razor` | same cast | `ctx.GetAAuthParsedKey()!` | +| All inline `"aa-agent+jwt"` | string literal | `AgentTokenBuilder.TokenType` or `AAuthConstants.TokenTypes.AgentToken` | + +### Files + +| Action | Path | +|--------|------| +| Create | `src/AAuth/AAuthConstants.cs` | +| Create | `src/AAuth/Server/AAuthHttpContextExtensions.cs` | +| Modify | `src/AAuth/Server/AAuthVerificationMiddleware.cs` (use constants) | +| Modify | `src/AAuth/Server/AAuthChallengeMiddleware.cs` (use constants) | +| Modify | `src/AAuth/HttpSig/AAuthSigningHandler.cs` (use constants) | +| Modify | `src/AAuth/HttpSig/SignatureKeyParser.cs` (use constants) | +| Modify | `src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs` (use constants) | +| Modify | `src/AAuth/Agent/ChallengeHandler.cs` (use constants) | +| Modify | `src/AAuth/Agent/NamingJwtBuilder.cs` (use constants) | +| Modify | `src/AAuth/Discovery/ServerMetadata.cs` (use DWK constants) | +| Modify | `samples/WhoAmI/Program.cs` (use extension methods) | +| Modify | `samples/MockPersonServer/Program.cs` (use extension methods) | +| Modify | `samples/SampleApp/Components/Pages/CallChain.razor` (use extension & constants) | +| Modify | `samples/SampleApp/Components/Pages/Hwk.razor` (use extension) | +| Modify | `samples/SampleApp/Components/Pages/JwksUri.razor` (use extension) | +| Create | `tests/AAuth.Tests/AAuthConstantsTests.cs` (verify values match existing) | +| Create | `tests/AAuth.Tests/Server/AAuthHttpContextExtensionsTests.cs` | + +### Definition of Done + +- [x] `AAuthConstants` class compiles with all constants +- [x] Existing token builder constants (`AgentTokenBuilder.TokenType` etc.) remain but delegate or cross-reference to `AAuthConstants` +- [x] `AAuthHttpContextExtensions` compiles; `GetAAuthVerification()`, `GetAAuthParsedKey()`, `GetAAuthResult()` accessible +- [x] All bare string literals in SDK source replaced with constants +- [x] All `ctx.Items[ParsedInfoItemKey]!` casts in samples replaced with extension method +- [x] All 615+ existing tests still pass +- [x] New tests verify constant values match protocol spec +- [x] New tests verify extension methods return null when middleware hasn't run + +### E. Token Type Enum — `AAuthTokenType` + +Replace `string?` token type comparisons with a type-safe enum. The raw string +is still needed for JWT serialization but the public-facing API should use the enum. + +```csharp +namespace AAuth; + +/// AAuth token types from the JWT typ header. +public enum AAuthTokenType +{ + /// Unknown or missing token type. + Unknown = 0, + + /// Agent token (aa-agent+jwt). + AgentToken, + + /// Auth token (aa-auth+jwt). + AuthToken, + + /// Resource token (aa-resource+jwt). + ResourceToken, + + /// Naming JWT for key delegation (naming+jwt). + NamingJwt, +} +``` + +With a helper for string↔enum conversion: + +```csharp +public static class AAuthTokenTypeExtensions +{ + public static string ToHeaderValue(this AAuthTokenType type) => type switch + { + AAuthTokenType.AgentToken => AAuthConstants.TokenTypes.AgentToken, + AAuthTokenType.AuthToken => AAuthConstants.TokenTypes.AuthToken, + AAuthTokenType.ResourceToken => AAuthConstants.TokenTypes.ResourceToken, + AAuthTokenType.NamingJwt => AAuthConstants.TokenTypes.NamingJwt, + _ => throw new ArgumentOutOfRangeException(nameof(type)), + }; + + public static AAuthTokenType ParseTokenType(string? typ) => typ switch + { + AAuthConstants.TokenTypes.AgentToken => AAuthTokenType.AgentToken, + AAuthConstants.TokenTypes.AuthToken => AAuthTokenType.AuthToken, + AAuthConstants.TokenTypes.ResourceToken => AAuthTokenType.ResourceToken, + AAuthConstants.TokenTypes.NamingJwt => AAuthTokenType.NamingJwt, + _ => AAuthTokenType.Unknown, + }; +} +``` + +Affected properties: +- `AAuthVerificationResult.TokenType` → change from `string?` to `AAuthTokenType` +- `VerificationResult.TokenType` → change from `string?` to `AAuthTokenType` +- Middleware switch/if statements use enum comparisons +- `AgentTokenBuilder.TokenType`, `AuthTokenBuilder.TokenType`, `ResourceTokenBuilder.TokenType` remain as `const string` for JWT serialization (non-breaking) + +Additional DoD: +- [x] `AAuthTokenType` enum compiles +- [x] `AAuthTokenTypeExtensions` round-trips all values +- [x] `AAuthVerificationResult.TokenType` is `AAuthTokenType` (breaking — acceptable since enum is richer) +- [x] Middleware uses enum comparisons internally +- [x] Samples use enum for comparisons (e.g. `result.TokenType == AAuthTokenType.AgentToken`) + +--- + +## Phase 10: Resource-Side Response Helpers ✅ + +Encapsulate first-class protocol behaviors (challenge, error, token type query) +as one-liner extension methods on `HttpContext` so resource endpoints don't need +to know header names, formatting rules, or status codes. + +### Problem + +Issuing an AAuth challenge currently requires: + +```csharp +ctx.Response.Headers[AAuthConstants.Headers.AAuthRequirement] = + AAuthRequirementHeader.FormatAuthToken(resourceToken); +return Results.Json(new { error = "auth_token_required" }, + statusCode: StatusCodes.Status401Unauthorized); +``` + +This is verbose and leaks formatting details into application code. + +### Solution + +Three new extension methods on `AAuthHttpContextExtensions`: + +```csharp +// Challenge: sets header + returns 401 +return ctx.ChallengeAAuth(resourceToken); + +// Error: sets AAuth-Error response header +ctx.SetAAuthError("something went wrong"); + +// Token type: reads enum from verification result +var type = ctx.GetAAuthTokenType(); +``` + +### Files + +| Action | Path | +|--------|------| +| Modify | `src/AAuth/Server/AAuthHttpContextExtensions.cs` | +| Modify | `samples/WhoAmI/Program.cs` (use `ChallengeAAuth`) | +| Modify | `docs/workflows/call-chaining.md` (use `ChallengeAAuth`) | +| Modify | `tests/AAuth.Tests/Server/AAuthHttpContextExtensionsTests.cs` | + +### Definition of Done + +- [x] `ctx.ChallengeAAuth(resourceToken)` sets `AAuth-Requirement` header and returns 401 `IResult` +- [x] `ctx.SetAAuthError(message)` sets `AAuth-Error` response header +- [x] `ctx.GetAAuthTokenType()` returns `AAuthTokenType` from verification result (or `Unknown`) +- [x] WhoAmI sample uses `ChallengeAAuth` instead of manual header + Results.Json +- [x] call-chaining.md docs updated +- [x] New unit tests pass +- [x] Full test suite passes (644 tests) + +--- + +## Out of Scope + +| Item | Reason | +|------|--------| +| `AdditionalClaims` in self-issued shorthand | Rare; use `WithTokenRefresh(SelfIssuedTokenRefresher.Create(...))` | +| Custom lifetime in shorthand | Default (1h) covers >95%; escape to `WithTokenRefresh` | +| Deprecating `WithTokenRefresh()` | Must remain for custom refresher scenarios | +| Removing `AgentTokenBuilder` | Still needed for server-side issuance and tests | +| Fluent Person Server setup | PS metadata is simple (1 endpoint + key) — no shorthand needed | +| Fluent MockAgentProvider setup | Demo code, not SDK API surface | +| JWT claim name constants (`"iss"`, `"sub"`, `"aud"`) | Standard claims — idiomatic to use inline in .NET; only AAuth-specific claims (`"agent"`, `"dwk"`, `"ps"`) could be future work | diff --git a/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/research.md b/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/research.md new file mode 100644 index 0000000..d0221b1 --- /dev/null +++ b/.agent/plans/2026-05-28-self-issued-client-builder-shorthand/research.md @@ -0,0 +1,325 @@ +--- +title: "Self-Issued Client Builder Shorthand — Research" +description: Research for combining AgentTokenBuilder into the AAuthClientBuilder fluent API +ms.date: 2026-05-28 +--- + +## Problem Statement + +The most common self-issued pattern requires constructing an `AgentTokenBuilder` inside a `WithTokenRefresh` callback: + +```csharp +using var client = new AAuthClientBuilder(key) + .WithTokenRefresh(async (ctx, ct) => new AgentTokenBuilder + { + Issuer = issuer, + Subject = "aauth:my-service@my-service.example", + KeyId = Kid, + Key = key, + PersonServer = "https://ps.example", + }.Build()) + .WithChallengeHandling("https://ps.example") + .Build(); +``` + +Problems: +1. **Repetition** — `key` is passed to both the constructor and the token builder. `PersonServer` is passed to both the token builder and `WithChallengeHandling()`. +2. **Ceremony** — The user must understand `AgentTokenBuilder`, `ITokenRefresher`, and the callback signature just to do the simplest self-issued call. +3. **Leaking internals** — `KeyId` is usually just the JWK thumbprint (which the SDK already computes internally for `SelfIssuedTokenRefresher`). + +Even with the existing `SelfIssuedTokenRefresher` (added in the token-refresher-concrete-types plan), it's still multi-line: + +```csharp +using var client = new AAuthClientBuilder(key) + .WithTokenRefresh(SelfIssuedTokenRefresher.Create(key, issuer, subject) + .WithKid(kid) + .WithPersonServer(psUrl) + .Build()) + .WithChallengeHandling(psUrl) + .Build(); +``` + +## What the SDK Already Knows at Build Time + +When a user configures a self-issued client, the `AAuthClientBuilder` already has: + +| Parameter | Source | Already available? | +|-----------|--------|-------------------| +| `key` | Constructor `new AAuthClientBuilder(key)` | Yes | +| `kid` | Defaults to key's JWK thumbprint | Yes (computed internally) | +| `issuer` | Unique to caller | No — must be provided | +| `subject` | Unique to caller | No — must be provided | +| `personServer` | Often same value passed to `WithChallengeHandling(ps)` | Partially (only if challenge configured) | +| `lifetime` | Usually default (1 hour) | Not needed | + +**Only `issuer` and `subject` are truly new information.** Everything else is either already held by the builder or has sensible defaults. + +## Spec References + +- Agent token structure: `draft-hardt-oauth-aauth-protocol.md` §Agent Token Structure — Required claims: `iss`, `dwk`, `sub`, `jti`, `cnf`, `iat`, `exp`. Optional: `ps`. +- Self-issued identity: Same spec §Agent Token Acquisition — "The mechanism for proving identity is platform-dependent." Self-hosted services act as their own AP. +- Signature-Key header: `draft-hardt-httpbis-signature-key` — scheme=jwt carries the agent token on every request. +- Token lifetime: spec recommends ≤ 24 hours; SDK defaults to 1 hour. + +## Existing SDK Surface (Relevant) + +| Type | Role | +|------|------| +| `AAuthClientBuilder(key)` | Fluent builder, holds the signing key | +| `.WithTokenRefresh(ITokenRefresher)` | Plugs in refresh logic | +| `.WithTokenRefresh(Func>)` | Lambda shorthand | +| `.WithChallengeHandling(personServer)` | Enables 401 exchange; needs PS URL | +| `SelfIssuedTokenRefresher` | Implements `ITokenRefresher` for self-issued tokens | +| `SelfIssuedTokenRefresher.Create(key, issuer, subject).WithKid().WithPersonServer().Build()` | Fluent builder for the refresher | +| `AgentTokenBuilder` | Low-level JWT builder with all claims | + +## Proposed API Options + +### Option A: `WithSelfIssuedToken(issuer, subject)` — Minimal Required Params + +```csharp +using var client = new AAuthClientBuilder(key) + .WithSelfIssuedToken("https://my-service.example", "aauth:my-service@my-service.example") + .WithChallengeHandling("https://ps.example") + .Build(); +``` + +**Semantics:** +- Implicitly creates a `SelfIssuedTokenRefresher` using the builder's `key` +- `kid` defaults to the key's JWK thumbprint +- `PersonServer` inferred from `WithChallengeHandling(psUrl)` if configured (or null) +- Implicitly sets JWT signing mode (no need for `UseJwt()` or separate `WithTokenRefresh()`) +- Token lifetime defaults to 1 hour + +**Optional overload for PS in token (when no challenge handling needed):** +```csharp +.WithSelfIssuedToken("https://my-service.example", "aauth:my-service@my-service.example", personServer: "https://ps.example") +``` + +**Pros:** +- Fewest parameters (only what the SDK can't infer) +- Reads naturally: "build a client with a self-issued token" +- PersonServer DRY: inferred from challenge handling config when possible +- Backward compatible — existing `WithTokenRefresh()` APIs unchanged + +**Cons:** +- Magic: `PersonServer` inference from `WithChallengeHandling` is non-obvious +- No kid customization without falling back to longer form +- Two concepts (token identity + refresh lifecycle) collapsed into one call + +--- + +### Option B: `WithSelfIssuedToken(Action)` — Options Lambda + +```csharp +using var client = new AAuthClientBuilder(key) + .WithSelfIssuedToken(opts => + { + opts.Issuer = "https://my-service.example"; + opts.Subject = "aauth:my-service@my-service.example"; + opts.PersonServer = "https://ps.example"; + }) + .WithChallengeHandling() // PS auto-extracted from token's ps claim + .Build(); +``` + +**Semantics:** +- Same underlying behavior as Option A +- `SelfIssuedTokenOptions` class with: `Issuer` (required), `Subject` (required), `KeyId` (optional), `PersonServer` (optional), `Lifetime` (optional) +- When `PersonServer` is set in options, `WithChallengeHandling()` (no-arg) can read it from the token's `ps` claim automatically + +**Pros:** +- Familiar .NET pattern (matches `WithChallengeHandling(Action<...>)`) +- All token parameters visible in one block +- Easy to add future options without API breaks +- PersonServer set once, used by both token and challenge handler + +**Cons:** +- More verbose than Option A for the common case +- Required properties in an options class are slightly awkward (must throw at build time, not compile time) + +--- + +### Option C: Static Factory `AAuthClientBuilder.SelfIssued(key, issuer, subject)` + +```csharp +using var client = AAuthClientBuilder.SelfIssued(key, issuer, subject) + .WithPersonServer("https://ps.example") // sets both token ps + challenge PS + .WithChallengeHandling() + .Build(); +``` + +**Semantics:** +- Static factory pre-configures: signing key, self-issued token refresh, JWT signing mode +- New `WithPersonServer(string)` method sets PS for both the token AND the challenge handler +- The factory returns `AAuthClientBuilder` — all existing methods still work + +**Pros:** +- Most concise for the self-issued use case +- `WithPersonServer()` DRYly configures both token and challenge +- Mirrors `AAuthClientBuilder.Bootstrap()` and `AAuthClientBuilder.From()` patterns already in SDK +- Strong "pit of success" — caller can't forget to configure token refresh + +**Cons:** +- Static factory proliferation (already have `Bootstrap()` and `From()`) +- `WithPersonServer()` is new builder state that has dual meaning (token claim vs. exchange target) +- Naming: `SelfIssued` as a factory name is clear but slightly breaks from the `.Bootstrap()` / `.From()` naming pattern + +--- + +### Option D: Extend `WithTokenRefresh` with Sub-Builder Overload + +```csharp +using var client = new AAuthClientBuilder(key) + .WithTokenRefresh(selfIssued => selfIssued + .Issuer("https://my-service.example") + .Subject("aauth:my-service@my-service.example") + .PersonServer("https://ps.example")) + .WithChallengeHandling() + .Build(); +``` + +**Semantics:** +- New overload: `WithTokenRefresh(Action)` +- The sub-builder internally creates a `SelfIssuedTokenRefresher` +- Key and kid auto-inherited from the parent builder + +**Pros:** +- Stays within the existing `WithTokenRefresh` concept +- No new top-level methods on the builder +- Builder-in-builder pattern makes the relationship explicit + +**Cons:** +- Nested builder might confuse newcomers +- Method resolution ambiguity risk with existing `WithTokenRefresh(Func<...>)` overload +- Doesn't simplify as much as A or C — still 4-5 lines + +--- + +## Recommendation Matrix + +| Criterion | A | B | C | D | +|-----------|---|---|---|---| +| Conciseness (fewest chars for common case) | ★★★★ | ★★★ | ★★★★★ | ★★★ | +| Discoverability (IntelliSense) | ★★★★ | ★★★ | ★★★★★ | ★★★ | +| Extensibility (future options without breaks) | ★★★ | ★★★★★ | ★★★★ | ★★★★ | +| Consistency with existing API | ★★★★ | ★★★★ | ★★★★ | ★★★ | +| Avoids PersonServer duplication | ★★★ | ★★★★ | ★★★★★ | ★★★★ | +| Non-breaking (existing code untouched) | ★★★★★ | ★★★★★ | ★★★★★ | ★★★★ | + +## Combination Strategy + +Options A and C are not mutually exclusive. A recommended approach combines: + +- **Option C** (`AAuthClientBuilder.SelfIssued(...)`) for the "golden path" self-hosted service scenario +- **Option A** (`WithSelfIssuedToken(issuer, subject)`) as an instance method for cases where the builder is already constructed (e.g., from DI or `From()`) + +Both delegate to `SelfIssuedTokenRefresher` internally. Existing `WithTokenRefresh()` overloads remain for advanced/custom scenarios. + +## Impact Analysis + +### Files That Currently Use the Verbose Pattern + +| File | Current Pattern | +|------|----------------| +| `samples/Orchestrator/Program.cs` | Inline `AgentTokenBuilder` in `WithTokenRefresh` callback | +| `samples/SampleApp/Components/Pages/Jwt.razor` | Inline `AgentTokenBuilder` in callback | +| `samples/SampleApp/Components/Pages/Deferred.razor` | Same | +| `samples/SampleApp/Components/Pages/CallChain.razor` | Same | +| `docs/signing-modes/agent-token-jwt.md` | Code example with inline builder | +| `docs/workflows/ps-asserted-access.md` | Code example | +| `docs/getting-started.md` | Multiple examples | +| `samples/GuidedTour/CodeSnippets.cs` | String constants with examples | + +### Documentation Pages Affected + +- `docs/getting-started.md` — Primary onboarding examples +- `docs/signing-modes/agent-token-jwt.md` — JWT mode reference +- `docs/workflows/ps-asserted-access.md` — Three-party workflow +- `docs/workflows/call-chaining.md` — Call chaining examples +- `docs/reference/dependency-injection.md` — DI registration examples +- `README.md` — Quick start snippet + +## Open Questions + +1. **Should `WithPersonServer()` (Option C) set the challenge handler's PS automatically?** Recommendation: Yes — if `WithChallengeHandling()` is called without an explicit PS after `WithPersonServer()` was set, use the stored PS. Explicit PS in `WithChallengeHandling(ps)` takes precedence. + +2. **Should `WithSelfIssuedToken()` implicitly enable challenge handling?** Recommendation: No — keep concerns separate. Self-issued tokens can be used for two-party access (no PS/challenge) as well. + +3. **Should `kid` be customizable in the shorthand?** Recommendation: Yes, via an optional parameter or overload: `.WithSelfIssuedToken(issuer, subject, kid: "custom-kid")`. + +--- + +## Phase 9 Research: Constants & HttpContext Extensions + +### Current State of String Literals + +The SDK has **48+ public constants** already defined, but they're co-located with +their owning types (e.g., `AgentTokenBuilder.TokenType`, `SignatureError.HeaderName`). +Several frequently-used strings have **no constant at all**: + +| Literal | Occurrences | Defined? | +|---------|-------------|----------| +| `"Signature"` | 6 (middleware, signing handler, challenge handler) | No | +| `"Signature-Input"` | 5 (same three files) | No | +| `"AAuth-Error"` | 4 (verification + challenge middleware) | No | +| `"jwt"` / `"hwk"` / `"jkt-jwt"` / `"jwks_uri"` | 20+ (parser, resolver, middleware) | No | +| `"naming+jwt"` | 1 (NamingJwtBuilder) | No | +| `"aauth-resource.json"` etc. | 3 (ServerMetadata uses inline, not builder constants) | Defined in builders but not referenced by all consumers | + +### HttpContext Access Patterns + +Two parallel systems exist for reading verification results: + +| Mechanism | Stored By | Access Pattern | Typed? | +|-----------|-----------|----------------|--------| +| `HttpContext.Items[ParsedInfoItemKey]` | Verification middleware | Cast from `object?` | No — requires `(ParsedSignatureKeyInfo)items[key]!` | +| `HttpContext.Items[ContextItemKey]` | Verification middleware | Cast from `object?` | No — requires `(VerificationResult)items[key]!` | +| `HttpContext.Features.Get()` | Verification middleware | Generic typed accessor | Yes | +| `HttpContext.Features.Get()` | Verification middleware | Generic typed accessor | Yes | + +**Problem:** Samples and user code use `Items[]` because `ParsedSignatureKeyInfo` +exposes raw JWT header/payload access (scheme, jkt, jwks_uri, kid) that +`AAuthVerificationResult` doesn't fully surface. The cast-from-Items pattern +is awkward and undiscoverable. + +### Design Decisions + +1. **Single centralized `AAuthConstants` class vs. keep co-located constants?** + Recommendation: Add `AAuthConstants` as the canonical "one-stop" reference + for all protocol strings. Existing per-type constants (e.g., `AgentTokenBuilder.TokenType`) + remain as convenience aliases but should reference `AAuthConstants` internally to + ensure consistency. + +2. **Extension methods on `HttpContext` vs. a wrapper type?** + Recommendation: Extension methods — idiomatic in ASP.NET Core, zero allocation, + no new abstraction. Three methods cover all use cases: + - `GetAAuthVerification()` → `AAuthVerificationResult?` (the rich typed result) + - `GetAAuthParsedKey()` → `ParsedSignatureKeyInfo?` (raw parsed JWT access) + - `GetAAuthResult()` → `VerificationResult?` (legacy Items-based result) + +3. **Should we deprecate `VerificationResult`?** + Not in this phase. It has a different shape from `AAuthVerificationResult` and is + used by `AAuthChallengeMiddleware`. Mark as internal consideration for a future phase. + +4. **Namespace for constants?** + `AAuth` (root namespace) — ensures constants are always in scope when `using AAuth;` + is present. + +5. **Pattern matching with constants?** + C# pattern matching (`is "jwt" or "jkt-jwt"`) doesn't work with non-const references. + Since `const string` fields in a static class work fine in patterns, this is safe: + ```csharp + if (scheme is AAuthConstants.Schemes.Jwt or AAuthConstants.Schemes.JktJwt) { ... } + ``` + +### Prior Art in .NET + +- `Microsoft.Net.Http.Headers.HeaderNames` — centralized HTTP header constants +- `System.Net.Mime.MediaTypeNames` — centralized MIME type constants +- `Microsoft.AspNetCore.Http.HttpContext.Features` + extension methods pattern + (e.g., `IHttpResponseFeature`, `IHttpActivityFeature`) +- `Microsoft.AspNetCore.Authentication.AuthenticationHttpContextExtensions` — + `HttpContext.AuthenticateAsync()`, `.SignInAsync()`, etc. + +4. **What about `AdditionalClaims`?** Recommendation: Not in the shorthand. Users with additional claims fall back to `WithTokenRefresh(SelfIssuedTokenRefresher.Create(...))` or the full `AgentTokenBuilder` lambda. diff --git a/README.md b/README.md index ca80ece..6a0684a 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,6 @@ Hosted services act as their own Agent Provider — generate a key, publish meta using AAuth.Crypto; using AAuth.HttpSig; using AAuth.Server; -using AAuth.Tokens; var builder = WebApplication.CreateBuilder(args); var key = AAuthKey.Generate(); @@ -94,16 +93,11 @@ app.MapAAuthAgentWellKnown(new AAuthAgentMetadataOptions }); // Build signed client with automatic token refresh and challenge handling -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(async (ctx, ct) => new AgentTokenBuilder - { - Issuer = issuer, - Subject = "aauth:my-service@my-service.example", - KeyId = Kid, - Key = key, - PersonServer = "https://ps.example", - }.Build()) - .WithChallengeHandling("https://ps.example") +using var client = AAuthClientBuilder.SelfIssuing(key) + .As(issuer, "aauth:my-service@my-service.example") + .WithKid(Kid) + .WithPersonServer("https://ps.example") + .WithChallengeHandling() .Build(); ``` diff --git a/docs/README.md b/docs/README.md index 81d8893..43961b5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -74,6 +74,10 @@ This is the documentation for the AAuth .NET SDK (`AAuth` NuGet package). It cov | Type | Purpose | |------|---------| | `AAuthClientBuilder` | Fluent builder → configured `HttpClient` with signing | +| `AAuthClientBuilder.SelfIssuing(key)` | Fluent factory for self-hosted services (self-issued identity) | +| `AAuthClientBuilder.Enrolled(key)` | Fluent factory for AP-enrolled agents | +| `.WithPersonServer()` | Sets PS for both token `ps` claim and challenge handling | +| `app.MapAAuthResource()` | Unified resource pipeline (well-known + verification + challenge) | | `AAuthSigningHandler` | `DelegatingHandler` that signs outbound requests (RFC 9421) | | `AAuthVerifier` | Server-side signature verification | | `AAuthVerificationMiddleware` | ASP.NET middleware — HTTP sig + JWT issuer verification | diff --git a/docs/getting-started.md b/docs/getting-started.md index f2977c0..35dbc28 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -242,7 +242,6 @@ A hosted service (web app, API, orchestrator) acts as its own Agent Provider: using AAuth.Crypto; using AAuth.HttpSig; using AAuth.Server; -using AAuth.Tokens; var builder = WebApplication.CreateBuilder(args); var key = AAuthKey.Generate(); @@ -259,16 +258,11 @@ app.MapAAuthAgentWellKnown(new AAuthAgentMetadataOptions }); // Build a signed HTTP client with automatic token refresh and challenge handling -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(async (ctx, ct) => new AgentTokenBuilder - { - Issuer = issuer, - Subject = "aauth:my-service@my-service.example", - KeyId = Kid, - Key = key, - PersonServer = "https://ps.example", - }.Build()) - .WithChallengeHandling("https://ps.example") +using var client = AAuthClientBuilder.SelfIssuing(key) + .As(issuer, "aauth:my-service@my-service.example") + .WithKid(Kid) + .WithPersonServer("https://ps.example") + .WithChallengeHandling() .Build(); // Every request is signed; 401 challenges are handled automatically @@ -365,7 +359,6 @@ Hosted services (web apps, APIs, orchestrators) that have a stable URL act as th using AAuth.Crypto; using AAuth.HttpSig; using AAuth.Server; -using AAuth.Tokens; var key = AAuthKey.Generate(); const string Kid = "my-service-1"; @@ -379,15 +372,11 @@ app.MapAAuthAgentWellKnown(new AAuthAgentMetadataOptions }); // Self-issue agent tokens for outbound requests -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(async (ctx, ct) => new AgentTokenBuilder - { - Issuer = issuer, - Subject = "aauth:my-service@my-service.example", - KeyId = Kid, - Key = key, - }.Build()) - .WithChallengeHandling("https://ps.example") +using var client = AAuthClientBuilder.SelfIssuing(key) + .As(issuer, "aauth:my-service@my-service.example") + .WithKid(Kid) + .WithPersonServer("https://ps.example") + .WithChallengeHandling() .Build(); ``` @@ -435,10 +424,9 @@ var key = await keyStore.LoadAsync(localKeyHandle) // The SDK acquires the agent token lazily on first request // via WithTokenRefresh, then keeps it fresh automatically. -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) - .WithKeyStore(keyStore) - .Build()) +using var client = AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(apRefreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) .WithChallengeHandling("https://ps.example") .Build(); @@ -451,10 +439,9 @@ Console.WriteLine(await response.Content.ReadAsStringAsync()); > to auto-configure the signing mode: > > ```csharp -> using var client = AAuthClientBuilder.From(enrol) -> .WithTokenRefresh(AgentProviderTokenRefresher.Create(enrol.ApRefreshEndpoint, enrol.LocalKeyHandle) -> .WithKeyStore(keyStore) -> .Build()) +> using var client = AAuthClientBuilder.Enrolled(enrol.Key) +> .RefreshingFrom(enrol.ApRefreshEndpoint, enrol.LocalKeyHandle) +> .WithKeyStore(keyStore) > .WithChallengeHandling("https://ps.example") > .Build(); > ``` @@ -489,10 +476,9 @@ var enrol = await apClient.EnrolAsync( ### 2. Build the Signed Client with Challenge Handling ```csharp -using var client = new AAuthClientBuilder(enrol.Key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create("https://ap.example/refresh", enrol.LocalKeyHandle) - .WithKeyStore(keyStore) - .Build()) +using var client = AAuthClientBuilder.Enrolled(enrol.Key) + .RefreshingFrom("https://ap.example/refresh", enrol.LocalKeyHandle) + .WithKeyStore(keyStore) .WithChallengeHandling(personServer: "https://ps.example") .Build(); ``` diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index 627f944..20a61a2 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -55,14 +55,10 @@ builder.Services.AddAAuthAgent("self-issued", options => { options.Key = key; options.PersonServer = "https://ps.example"; - options.UseJwt(() => new AgentTokenBuilder - { - Issuer = issuer, - Subject = "aauth:my-service@my-service.example", - KeyId = Kid, - Key = key, - PersonServer = "https://ps.example", - }.Build()); + options.TokenRefresher = SelfIssuedTokenRefresher.Create(key, issuer, "aauth:my-service@my-service.example") + .WithKid(Kid) + .WithPersonServer("https://ps.example") + .Build(); }); // Also publish agent metadata so verifiers can discover the JWKS @@ -276,8 +272,7 @@ app.MapAAuthWellKnown(); app.MapGet("/data", async (HttpContext ctx, IHttpClientFactory factory) => { // Inbound request was verified by middleware - var parsed = (SignatureKeyParser.ParsedSignatureKeyInfo) - ctx.Items[AAuthVerificationMiddleware.ParsedInfoItemKey]!; + var parsed = ctx.GetAAuthParsedKey()!; // Make signed outbound request var client = factory.CreateClient("downstream"); diff --git a/docs/server/challenge-middleware.md b/docs/server/challenge-middleware.md index 3a13cde..0726187 100644 --- a/docs/server/challenge-middleware.md +++ b/docs/server/challenge-middleware.md @@ -82,7 +82,7 @@ app.UseAAuthChallenge(new ChallengeOptions // Endpoints below here see only authorized requests app.MapGet("/data", (HttpContext ctx) => { - var result = ctx.Features.Get()!; + var result = ctx.GetAAuthVerification()!; // result.Level == AAuthLevel.Authorized }); ``` diff --git a/docs/server/verification-middleware.md b/docs/server/verification-middleware.md index 3019a04..2f7b965 100644 --- a/docs/server/verification-middleware.md +++ b/docs/server/verification-middleware.md @@ -114,7 +114,7 @@ After successful verification, the middleware stores an `AAuthVerificationResult ```csharp app.MapGet("/protected", (HttpContext ctx) => { - var result = ctx.Features.Get()!; + var result = ctx.GetAAuthVerification()!; // result.Level: Pseudonymous | Identified | Authorized // result.Scheme: "jwt" | "hwk" | "jkt-jwt" | "jwks_uri" // result.Agent: agent identifier diff --git a/docs/signing-modes/agent-token-jwt.md b/docs/signing-modes/agent-token-jwt.md index de59894..79bf27a 100644 --- a/docs/signing-modes/agent-token-jwt.md +++ b/docs/signing-modes/agent-token-jwt.md @@ -19,10 +19,26 @@ The agent presents its full agent token inline. The resource (or Person Server) ```csharp using AAuth.Crypto; using AAuth.HttpSig; -using AAuth.Tokens; var key = AAuthKey.Generate(); +using var client = AAuthClientBuilder.SelfIssuing(key) + .As("https://my-service.example", "aauth:my-service@my-service.example") + .WithKid("svc-key-1") + .WithPersonServer("https://ps.example") + .WithChallengeHandling() + .Build(); + +var response = await client.GetAsync("https://resource.example/data"); +``` + +
+Advanced: Custom Token Building + +For scenarios requiring additional claims, custom lifetimes, or non-standard flows, +use the full `AgentTokenBuilder` via `WithTokenRefresh`: + +```csharp using var client = new AAuthClientBuilder(key) .WithTokenRefresh((ctx, ct) => Task.FromResult(new AgentTokenBuilder { @@ -31,13 +47,17 @@ using var client = new AAuthClientBuilder(key) KeyId = "svc-key-1", Key = key, PersonServer = "https://ps.example", + AdditionalClaims = new Dictionary + { + ["attestation"] = "platform-verified", + }, }.Build())) .WithChallengeHandling("https://ps.example") .Build(); - -var response = await client.GetAsync("https://resource.example/data"); ``` +
+ **CLI/Desktop agent (AP-enrolled):** ```csharp @@ -49,8 +69,9 @@ var keyStore = FileKeyStore.Default(); var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!); var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; -using var client = new AAuthClientBuilder(key!) - .WithTokenRefresh(new AgentProviderTokenRefresher(new HttpClient(), keyStore, apRefreshEndpoint)) +using var client = AAuthClientBuilder.Enrolled(key!) + .RefreshingFrom(apRefreshEndpoint, configuration["AAuth:LocalKeyHandle"]!) + .WithKeyStore(keyStore) .WithChallengeHandling("https://ps.example") .Build(); diff --git a/docs/workflows/bootstrap-enrollment.md b/docs/workflows/bootstrap-enrollment.md index bfe8c06..8299701 100644 --- a/docs/workflows/bootstrap-enrollment.md +++ b/docs/workflows/bootstrap-enrollment.md @@ -95,10 +95,9 @@ var key = await keyStore.LoadAsync(localKeyHandle) // The SDK acquires the agent token lazily on first request // via WithTokenRefresh, then keeps it fresh automatically. -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) - .WithKeyStore(keyStore) - .Build()) +using var client = AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(apRefreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) .WithChallengeHandling(personServer: "https://ps.example") .Build(); ``` @@ -160,10 +159,9 @@ sequenceDiagram // localKeyHandle = the agent-local IKeyStore handle returned by EnrolAsync // (defaults to the durable key's JWK thumbprint). // Used only by IKeyStore.LoadAsync — it is never sent to the AP. -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) - .WithKeyStore(keyStore) - .Build()) +using var client = AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(apRefreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) .Build(); ``` @@ -188,11 +186,10 @@ sequenceDiagram ```csharp // Spec: "The AP verifies the durable-key signature on the naming JWT, // looks up the enrollment by the durable key's thumbprint" -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) - .WithKeyStore(keyStore) - .WithRefreshMode(RefreshMode.TwoKey, apIssuer) - .Build()) +using var client = AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(apRefreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) + .WithRefreshMode(RefreshMode.TwoKey, apIssuer) .Build(); ``` @@ -202,14 +199,12 @@ Hosted services with a stable HTTPS URL act as their own issuer — no AP is nee ```csharp // keyId = any stable identifier you choose for the JWT "kid" header. -// Defaults to the key's JWK thumbprint if omitted via the fluent API. +// Defaults to the key's JWK thumbprint if omitted. // Resources resolve this key by fetching your /.well-known/jwks.json. -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(SelfIssuedTokenRefresher.Create(key, - issuer: "https://my-service.example", - subject: "aauth:my-service@my-service.example") - .WithPersonServer("https://ps.example") - .Build()) +using var client = AAuthClientBuilder.SelfIssuing(key) + .As("https://my-service.example", "aauth:my-service@my-service.example") + .WithPersonServer("https://ps.example") + .WithChallengeHandling() .Build(); ``` diff --git a/docs/workflows/call-chaining.md b/docs/workflows/call-chaining.md index 46747ae..2543aa3 100644 --- a/docs/workflows/call-chaining.md +++ b/docs/workflows/call-chaining.md @@ -114,12 +114,11 @@ app.UseAAuthVerification(new AAuthVerificationOptions app.MapGet("/", async (HttpContext ctx) => { - var parsed = (ParsedSignatureKeyInfo) - ctx.Items[AAuthVerificationMiddleware.ParsedInfoItemKey]!; - var typ = (string?)parsed.Header?["typ"]; + var parsed = ctx.GetAAuthParsedKey()!; + var tokenType = ctx.GetAAuthTokenType(); // Agent token → challenge the caller - if (typ == "aa-agent+jwt") + if (tokenType == AAuthTokenType.AgentToken) { var rt = new ResourceTokenBuilder { @@ -132,10 +131,7 @@ app.MapGet("/", async (HttpContext ctx) => Scope = "orchestrate", }.Build(); - ctx.Response.Headers["AAuth-Requirement"] = - AAuthRequirementHeader.FormatAuthToken(rt); - return Results.Json(new { error = "auth_token_required" }, - statusCode: 401); + return ctx.ChallengeAAuth(rt); } // Auth token → forward downstream with call chaining diff --git a/docs/workflows/deferred-consent.md b/docs/workflows/deferred-consent.md index 5d4389f..e0c030a 100644 --- a/docs/workflows/deferred-consent.md +++ b/docs/workflows/deferred-consent.md @@ -74,9 +74,11 @@ var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!) ?? throw new InvalidOperationException("Key not found. Run enrollment first."); var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(new AgentProviderTokenRefresher(new HttpClient(), keyStore, apRefreshEndpoint)) - .WithChallengeHandling("https://ps.example", options => +using var client = AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(apRefreshEndpoint, configuration["AAuth:LocalKeyHandle"]!) + .WithKeyStore(keyStore) + .WithPersonServer("https://ps.example") + .WithChallengeHandling(options => { options.PollingTimeout = TimeSpan.FromMinutes(5); options.PreferWaitSeconds = 30; // long-poll (RFC 7240 §4.3) diff --git a/docs/workflows/federated-access.md b/docs/workflows/federated-access.md index 7ae3080..cdd465c 100644 --- a/docs/workflows/federated-access.md +++ b/docs/workflows/federated-access.md @@ -34,8 +34,9 @@ var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!) ?? throw new InvalidOperationException("Key not found. Run enrollment first."); var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(new AgentProviderTokenRefresher(new HttpClient(), keyStore, apRefreshEndpoint)) +using var client = AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(apRefreshEndpoint, configuration["AAuth:LocalKeyHandle"]!) + .WithKeyStore(keyStore) .WithChallengeHandling(personServer: "https://ps.example") .Build(); @@ -58,7 +59,9 @@ builder.Services.AddAAuthAgent("federated", options => { options.Key = key!; options.PersonServer = "https://ps.example"; - options.TokenRefresher = new AgentProviderTokenRefresher(new HttpClient(), keyStore, apRefreshEndpoint); + options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, configuration["AAuth:LocalKeyHandle"]!) + .WithKeyStore(keyStore) + .Build(); }); ``` diff --git a/docs/workflows/ps-asserted-access.md b/docs/workflows/ps-asserted-access.md index 6f805ce..4e56c79 100644 --- a/docs/workflows/ps-asserted-access.md +++ b/docs/workflows/ps-asserted-access.md @@ -33,14 +33,11 @@ using AAuth.HttpSig; // Hosted service: self-issue (no AP needed) var key = AAuthKey.Generate(); -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(new SelfIssuedTokenRefresher( - key, - issuer: "https://my-service.example", - subject: "aauth:my-service@my-service.example", - keyId: "svc-key-1", - personServer: "https://ps.example")) - .WithChallengeHandling(personServer: "https://ps.example") +using var client = AAuthClientBuilder.SelfIssuing(key) + .As("https://my-service.example", "aauth:my-service@my-service.example") + .WithKid("svc-key-1") + .WithPersonServer("https://ps.example") + .WithChallengeHandling() .Build(); var response = await client.GetAsync("https://resource.example/data"); diff --git a/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs index 126b94f..5c66dca 100644 --- a/samples/GuidedTour/CodeSnippets.cs +++ b/samples/GuidedTour/CodeSnippets.cs @@ -73,10 +73,9 @@ internal static class CodeSnippets """; public const string SignedGetJwt = """ - using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) - .WithKeyStore(keyStore) - .Build()) + using var client = AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(refreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) .Build(); var response = await client.GetAsync("https://resource.example/data"); @@ -119,10 +118,9 @@ internal static class CodeSnippets public const string TokenExchangeDirect = """ // Automatic (recommended): - using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) - .WithKeyStore(keyStore) - .Build()) + using var client = AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(refreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) .WithChallengeHandling(personServer: "https://ps.example") .Build(); @@ -202,8 +200,9 @@ internal static class CodeSnippets // Convenience: WithCallChaining routes downstream exchanges // automatically, passing upstream_token to the PS/AS. // Use this when building an intermediary service: - using var downstream = new AAuthClientBuilder(myKey) - .WithTokenRefresh(refreshFunc) + using var downstream = AAuthClientBuilder.SelfIssuing(myKey) + .As(myIssuer, myAgentId) + .WithPersonServer(psUrl) .WithCallChaining(httpContext) // reads upstream token from request .Build(); @@ -223,11 +222,9 @@ internal static class CodeSnippets // --- Application (every startup — load key by handle) --- var key = await keyStore.LoadAsync(localKeyHandle); - // From() auto-configures signing mode from the enrollment result - using var client = AAuthClientBuilder.From(enrolResult) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) - .WithKeyStore(keyStore) - .Build()) + using var client = AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(refreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) .WithChallengeHandling("https://ps.example") .Build(); diff --git a/samples/MockPersonServer/Program.cs b/samples/MockPersonServer/Program.cs index 323af62..4b02f22 100644 --- a/samples/MockPersonServer/Program.cs +++ b/samples/MockPersonServer/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using AAuth; using AAuth.Crypto; using AAuth.DependencyInjection; using AAuth.Discovery; @@ -117,15 +118,14 @@ // ----------------------------------------------------------------------- app.MapPost("/token", async (HttpContext ctx, ConsentStore consent, PendingStore pending) => { - var parsed = (SignatureKeyParser.ParsedSignatureKeyInfo)ctx.Items[ - AAuthVerificationMiddleware.ParsedInfoItemKey]!; + var parsed = ctx.GetAAuthParsedKey()!; // Only an agent token may exchange — refuse anything else. - var typ = (string?)parsed.Header?["typ"]; - if (typ != AgentTokenBuilder.TokenType) + var tokenType = ctx.GetAAuthTokenType(); + if (tokenType != AAuthTokenType.AgentToken) { return Results.Json( - new { error = "invalid_carrier_token", detail = $"expected {AgentTokenBuilder.TokenType}, got {typ}" }, + new { error = "invalid_carrier_token", detail = $"expected {AAuthConstants.TokenTypes.AgentToken}, got {tokenType}" }, statusCode: StatusCodes.Status401Unauthorized); } diff --git a/samples/Orchestrator/Program.cs b/samples/Orchestrator/Program.cs index 36d624b..f32f256 100644 --- a/samples/Orchestrator/Program.cs +++ b/samples/Orchestrator/Program.cs @@ -61,17 +61,6 @@ // This ensures agent_token.iss == resource URL, satisfying §Upstream Token // Verification step 3 (aud in upstream_token matches intermediary resource). // ----------------------------------------------------------------------- -string SelfIssueAgentToken() -{ - return new AgentTokenBuilder - { - Issuer = orchestratorUrl, - Subject = agentId, - KeyId = OrchestratorKid, - Key = orchestratorKey, - PersonServer = psUrl, - }.Build(); -} // ----------------------------------------------------------------------- // Verification + Challenge middleware: validates the HTTP signature, @@ -118,8 +107,10 @@ await adminHttp.PostAsJsonAsync( // Build a call-chaining client: upstream token routing + auto-challenge. // Self-issued agent token (iss = orchestratorUrl) satisfies §Upstream Token // Verification step 3 — the PS can match upstream_token.aud against iss. - using var downstream = new AAuthClientBuilder(orchestratorKey) - .WithTokenRefresh(async (_, ct) => SelfIssueAgentToken()) + using var downstream = AAuthClientBuilder.SelfIssuing(orchestratorKey) + .As(orchestratorUrl, agentId) + .WithKid(OrchestratorKid) + .WithPersonServer(psUrl) .WithCallChaining(ctx) .Build(); @@ -128,7 +119,7 @@ await adminHttp.PostAsJsonAsync( JsonNode? downstreamJson = null; try { downstreamJson = JsonNode.Parse(body); } catch { } - var upstreamResult = ctx.Features.Get(); + var upstreamResult = ctx.GetAAuthVerification(); return Results.Ok(new { chain = "Agent → Orchestrator → WhoAmI", diff --git a/samples/SampleApp/Components/Pages/CallChain.razor b/samples/SampleApp/Components/Pages/CallChain.razor index 665b964..d9ae4b1 100644 --- a/samples/SampleApp/Components/Pages/CallChain.razor +++ b/samples/SampleApp/Components/Pages/CallChain.razor @@ -1,7 +1,6 @@ @page "/call-chain" @using AAuth.Crypto @using AAuth.HttpSig -@using AAuth.Tokens @inject IJSRuntime JS @rendermode InteractiveServer @@ -24,14 +23,9 @@ // 1. Sends agent token → gets 401 + resource_token // 2. Exchanges at PS → gets auth token // 3. Retries with auth token -using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(async (ctx, ct) => - { - var ap = new AgentProviderClient( - new HttpClient(), keyStore); - return await ap.RefreshAsync( - refreshEndpoint, localKeyHandle, ct); - }) +using var client = AAuthClientBuilder.Enrolled(key) + .RefreshingFrom(refreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) .WithChallengeHandling(personServer) .Build(); @@ -56,26 +50,18 @@ app.UseAAuthVerification(new AAuthVerificationOptions RequireIssuerVerification = true, });
Orchestrator Handler
-
app.MapGet("/", async (HttpContext ctx) =>
-{
-    var parsed = (ParsedSignatureKeyInfo)
-        ctx.Items[ParsedInfoItemKey]!;
-    var typ = (string?)parsed.Header?["typ"];
-
-    // Agent token → challenge (like any resource)
-    if (typ == "aa-agent+jwt")
-    {
-        var rt = new ResourceTokenBuilder { ... };
-        ctx.Response.Headers["AAuth-Requirement"]
-            = FormatAuthToken(rt.Build());
-        return Results.Unauthorized();
-    }
+
// Middleware: auto-challenges agent tokens with a resource token.
+// Only auth-token callers reach the handler.
+app.UseAAuthIntermediary(verificationOptions, challengeOptions);
 
+app.MapGet("/", async (HttpContext ctx) =>
+{
     // Auth token → call downstream with chaining.
     // WithCallChaining reads the upstream token from
     // the verified auth token automatically.
-    using var client = new AAuthClientBuilder(key)
-        .WithTokenRefresh(refreshFunc)
+    using var client = AAuthClientBuilder.SelfIssuing(key)
+        .As(orchestratorUrl, agentId)
+        .WithPersonServer(psUrl)
         .WithCallChaining(ctx) // upstream_token + mission
         .Build();
     var result = await client.GetAsync(downstreamUrl);
@@ -140,19 +126,11 @@ app.UseAAuthVerification(new AAuthVerificationOptions
             await GrantConsentAsync(orchestratorUrl);
 
             // Self-issued token refresh — no AP round-trip needed
-            using var client = new AAuthClientBuilder(_identity.Key)
-                .WithTokenRefresh(async (ctx, ct) =>
-                {
-                    return new AgentTokenBuilder
-                    {
-                        Issuer = _identity.Issuer,
-                        Subject = _identity.AgentId,
-                        KeyId = _identity.KeyId,
-                        Key = _identity.Key,
-                        PersonServer = personServer,
-                    }.Build();
-                })
-                .WithChallengeHandling(personServer)
+            using var client = AAuthClientBuilder.SelfIssuing(_identity.Key)
+                .As(_identity.Issuer, _identity.AgentId)
+                .WithKid(_identity.KeyId)
+                .WithPersonServer(personServer)
+                .WithChallengeHandling()
                 .Build();
 
             var response = await client.GetAsync(orchestratorUrl);
diff --git a/samples/SampleApp/Components/Pages/Deferred.razor b/samples/SampleApp/Components/Pages/Deferred.razor
index ebc4a73..b02edb7 100644
--- a/samples/SampleApp/Components/Pages/Deferred.razor
+++ b/samples/SampleApp/Components/Pages/Deferred.razor
@@ -3,7 +3,6 @@
 @using AAuth.Crypto
 @using AAuth.Headers
 @using AAuth.HttpSig
-@using AAuth.Tokens
 @inject IJSRuntime JS
 @rendermode InteractiveServer
 
@@ -21,15 +20,11 @@
 
Client Code
-
using var client = new AAuthClientBuilder(key)
-    .WithTokenRefresh(async (ctx, ct) =>
-    {
-        var ap = new AgentProviderClient(
-            new HttpClient(), keyStore);
-        return await ap.RefreshAsync(
-            refreshEndpoint, localKeyHandle, ct);
-    })
-    .WithChallengeHandling(personServer, opts =>
+
using var client = AAuthClientBuilder.Enrolled(key)
+    .RefreshingFrom(refreshEndpoint, localKeyHandle)
+    .WithKeyStore(keyStore)
+    .WithPersonServer(personServer)
+    .WithChallengeHandling(opts =>
     {
         opts.OnInteractionRequired =
             async (interaction, ct) =>
@@ -171,19 +166,11 @@ app.MapGet("/", (HttpContext ctx) =>
             var personServer = Config["AAuth:PersonServer"]!;
 
             // Self-issued token refresh + deferred consent support
-            using var client = new AAuthClientBuilder(_identity.Key)
-                .WithTokenRefresh(async (ctx, ct) =>
-                {
-                    return new AgentTokenBuilder
-                    {
-                        Issuer = _identity.Issuer,
-                        Subject = _identity.AgentId,
-                        KeyId = _identity.KeyId,
-                        Key = _identity.Key,
-                        PersonServer = personServer,
-                    }.Build();
-                })
-                .WithChallengeHandling(personServer, opts =>
+            using var client = AAuthClientBuilder.SelfIssuing(_identity.Key)
+                .As(_identity.Issuer, _identity.AgentId)
+                .WithKid(_identity.KeyId)
+                .WithPersonServer(personServer)
+                .WithChallengeHandling(opts =>
                 {
                     opts.OnInteractionRequired = async (interaction, ct) =>
                     {
diff --git a/samples/SampleApp/Components/Pages/Hwk.razor b/samples/SampleApp/Components/Pages/Hwk.razor
index 76a6807..193a102 100644
--- a/samples/SampleApp/Components/Pages/Hwk.razor
+++ b/samples/SampleApp/Components/Pages/Hwk.razor
@@ -37,9 +37,7 @@ app.UseAAuthVerification(new AAuthVerificationOptions
 
 app.MapGet("/hwk", (HttpContext ctx) =>
 {
-    var parsed = (ParsedSignatureKeyInfo)
-        ctx.Items[AAuthVerificationMiddleware
-            .ParsedInfoItemKey]!;
+    var parsed = ctx.GetAAuthParsedKey()!;
     return Results.Ok(new
     {
         mode = "pseudonymous",
diff --git a/samples/SampleApp/Components/Pages/JwksUri.razor b/samples/SampleApp/Components/Pages/JwksUri.razor
index 76d2d69..b8e1b60 100644
--- a/samples/SampleApp/Components/Pages/JwksUri.razor
+++ b/samples/SampleApp/Components/Pages/JwksUri.razor
@@ -42,9 +42,7 @@ app.UseAAuthVerification(new AAuthVerificationOptions
 
 app.MapGet("/jwks-uri", (HttpContext ctx) =>
 {
-    var parsed = (ParsedSignatureKeyInfo)
-        ctx.Items[AAuthVerificationMiddleware
-            .ParsedInfoItemKey]!;
+    var parsed = ctx.GetAAuthParsedKey()!;
     return Results.Ok(new
     {
         mode = "agent-identity",
diff --git a/samples/SampleApp/Components/Pages/Jwt.razor b/samples/SampleApp/Components/Pages/Jwt.razor
index 7dc39e9..113bdc9 100644
--- a/samples/SampleApp/Components/Pages/Jwt.razor
+++ b/samples/SampleApp/Components/Pages/Jwt.razor
@@ -1,7 +1,6 @@
 @page "/jwt"
 @using AAuth.Crypto
 @using AAuth.HttpSig
-@using AAuth.Tokens
 @inject IJSRuntime JS
 @rendermode InteractiveServer
 
@@ -29,14 +28,9 @@ var result = await apClient.EnrolAsync(
 // Runtime — build signed client
 var key = await keyStore.LoadAsync(result.LocalKeyHandle);
 
-using var client = new AAuthClientBuilder(key)
-    .WithTokenRefresh(async (ctx, ct) =>
-    {
-        var ap = new AgentProviderClient(
-            new HttpClient(), keyStore);
-        return await ap.RefreshAsync(
-            refreshEndpoint, localKeyHandle, ct);
-    })
+using var client = AAuthClientBuilder.Enrolled(key)
+    .RefreshingFrom(refreshEndpoint, localKeyHandle)
+    .WithKeyStore(keyStore)
     .WithChallengeHandling(personServer)
     .Build();
@@ -141,19 +135,11 @@ app.MapGet("/", (HttpContext ctx) => await GrantConsentAsync(); // Self-issued token refresh — no AP round-trip needed - using var client = new AAuthClientBuilder(_identity.Key) - .WithTokenRefresh(async (ctx, ct) => - { - return new AgentTokenBuilder - { - Issuer = _identity.Issuer, - Subject = _identity.AgentId, - KeyId = _identity.KeyId, - Key = _identity.Key, - PersonServer = personServer, - }.Build(); - }) - .WithChallengeHandling(personServer) + using var client = AAuthClientBuilder.SelfIssuing(_identity.Key) + .As(_identity.Issuer, _identity.AgentId) + .WithKid(_identity.KeyId) + .WithPersonServer(personServer) + .WithChallengeHandling() .Build(); var response = await client.GetAsync(resourceUrl); diff --git a/samples/WhoAmI/Program.cs b/samples/WhoAmI/Program.cs index 770a527..a17367b 100644 --- a/samples/WhoAmI/Program.cs +++ b/samples/WhoAmI/Program.cs @@ -1,8 +1,8 @@ using System.Text.Json.Nodes; +using AAuth; using AAuth.Crypto; using AAuth.DependencyInjection; using AAuth.Discovery; -using AAuth.Headers; using AAuth.HttpSig; using AAuth.Server; using AAuth.Tokens; @@ -101,8 +101,7 @@ // ----------------------------------------------------------------------- app.MapGet("/hwk", (HttpContext ctx) => { - var parsed = (SignatureKeyParser.ParsedSignatureKeyInfo)ctx.Items[ - AAuthVerificationMiddleware.ParsedInfoItemKey]!; + var parsed = ctx.GetAAuthParsedKey()!; return Results.Ok(new { @@ -121,8 +120,7 @@ // ----------------------------------------------------------------------- app.MapGet("/jkt-jwt", (HttpContext ctx) => { - var parsed = (SignatureKeyParser.ParsedSignatureKeyInfo)ctx.Items[ - AAuthVerificationMiddleware.ParsedInfoItemKey]!; + var parsed = ctx.GetAAuthParsedKey()!; return Results.Ok(new { @@ -138,8 +136,7 @@ // ----------------------------------------------------------------------- app.MapGet("/jwks-uri", (HttpContext ctx) => { - var parsed = (SignatureKeyParser.ParsedSignatureKeyInfo)ctx.Items[ - AAuthVerificationMiddleware.ParsedInfoItemKey]!; + var parsed = ctx.GetAAuthParsedKey()!; return Results.Ok(new { @@ -172,21 +169,19 @@ MetadataClient metadata, JwksClient jwks) => { - var parsed = (SignatureKeyParser.ParsedSignatureKeyInfo)ctx.Items[ - AAuthVerificationMiddleware.ParsedInfoItemKey]!; + var parsed = ctx.GetAAuthParsedKey()!; + var tokenType = ctx.GetAAuthTokenType(); - var typ = (string?)parsed.Header?["typ"]; - - if (typ == AgentTokenBuilder.TokenType) + if (tokenType == AAuthTokenType.AgentToken) { return await ChallengeWithResourceToken(ctx, parsed, tokenVerifier, resourceSigningKey, resourceUrl, metadata, jwks); } - if (typ == AuthTokenBuilder.TokenType) + if (tokenType == AAuthTokenType.AuthToken) { // Middleware already verified signature, aud, cnf.jwk, and act.sub. // Just return the verified claims. - var result = ctx.Features.Get()!; + var result = ctx.GetAAuthVerification()!; return Results.Ok(new { mode = "three-party", @@ -200,7 +195,7 @@ } return Results.Json( - new { error = "unsupported_token_type", typ }, + new { error = "unsupported_token_type", tokenType = tokenType.ToString() }, statusCode: StatusCodes.Status401Unauthorized); }); @@ -261,10 +256,7 @@ static async Task ChallengeWithResourceToken( Scope = ResourceScope, }.Build(); - ctx.Response.Headers[AAuthRequirementHeader.Name] = - AAuthRequirementHeader.FormatAuthToken(resourceToken); - return Results.Json(new { error = "auth_token_required" }, - statusCode: StatusCodes.Status401Unauthorized); + return ctx.ChallengeAAuth(resourceToken); } // Marker type for `WebApplicationFactory` in the diff --git a/src/AAuth/AAuthConstants.cs b/src/AAuth/AAuthConstants.cs new file mode 100644 index 0000000..3f0f252 --- /dev/null +++ b/src/AAuth/AAuthConstants.cs @@ -0,0 +1,78 @@ +namespace AAuth; + +/// Well-known protocol constants for the AAuth SDK. +public static class AAuthConstants +{ + /// HTTP header names used by AAuth. + public static class Headers + { + /// RFC 9421 HTTP signature header. + public const string Signature = "Signature"; + + /// RFC 9421 HTTP signature input header. + public const string SignatureInput = "Signature-Input"; + + /// AAuth Signature-Key header. + public const string SignatureKey = "Signature-Key"; + + /// AAuth error response header. + public const string AAuthError = "AAuth-Error"; + + /// AAuth requirement challenge header. + public const string AAuthRequirement = "AAuth-Requirement"; + + /// AAuth mission header. + public const string AAuthMission = "AAuth-Mission"; + + /// AAuth capabilities header. + public const string AAuthCapabilities = "AAuth-Capabilities"; + } + + /// Signature-Key scheme identifiers. + public static class Schemes + { + /// JWT-based agent identity (three-party). + public const string Jwt = "jwt"; + + /// Hardware-bound key (pseudonymous). + public const string Hwk = "hwk"; + + /// JKT-JWT key delegation (pseudonymous with naming JWT). + public const string JktJwt = "jkt-jwt"; + + /// JWKS URI-based agent identity. + public const string JwksUri = "jwks_uri"; + } + + /// Token type (typ header) values. + public static class TokenTypes + { + /// Agent token type. + public const string AgentToken = "aa-agent+jwt"; + + /// Auth token type. + public const string AuthToken = "aa-auth+jwt"; + + /// Resource token type. + public const string ResourceToken = "aa-resource+jwt"; + + /// Naming JWT type (key delegation). + public const string NamingJwt = "naming+jwt"; + } + + /// Well-known DWK file names. + public static class DwkFiles + { + /// Agent DWK metadata file. + public const string Agent = "aauth-agent.json"; + + /// Person Server DWK metadata file. + public const string Person = "aauth-person.json"; + + /// Access Server DWK metadata file. + public const string Access = "aauth-access.json"; + + /// Resource DWK metadata file. + public const string Resource = "aauth-resource.json"; + } +} diff --git a/src/AAuth/AAuthTokenType.cs b/src/AAuth/AAuthTokenType.cs new file mode 100644 index 0000000..ae3bafd --- /dev/null +++ b/src/AAuth/AAuthTokenType.cs @@ -0,0 +1,44 @@ +namespace AAuth; + +/// AAuth token types from the JWT typ header. +public enum AAuthTokenType +{ + /// Unknown or missing token type. + Unknown = 0, + + /// Agent token (aa-agent+jwt). + AgentToken, + + /// Auth token (aa-auth+jwt). + AuthToken, + + /// Resource token (aa-resource+jwt). + ResourceToken, + + /// Naming JWT for key delegation (naming+jwt). + NamingJwt, +} + +/// Extension methods for . +public static class AAuthTokenTypeExtensions +{ + /// Convert the enum to its JWT typ header string value. + public static string ToHeaderValue(this AAuthTokenType type) => type switch + { + AAuthTokenType.AgentToken => AAuthConstants.TokenTypes.AgentToken, + AAuthTokenType.AuthToken => AAuthConstants.TokenTypes.AuthToken, + AAuthTokenType.ResourceToken => AAuthConstants.TokenTypes.ResourceToken, + AAuthTokenType.NamingJwt => AAuthConstants.TokenTypes.NamingJwt, + _ => throw new System.ArgumentOutOfRangeException(nameof(type), type, "Unknown AAuth token type."), + }; + + /// Parse a JWT typ header value to the enum. + public static AAuthTokenType ParseTokenType(string? typ) => typ switch + { + AAuthConstants.TokenTypes.AgentToken => AAuthTokenType.AgentToken, + AAuthConstants.TokenTypes.AuthToken => AAuthTokenType.AuthToken, + AAuthConstants.TokenTypes.ResourceToken => AAuthTokenType.ResourceToken, + AAuthConstants.TokenTypes.NamingJwt => AAuthTokenType.NamingJwt, + _ => AAuthTokenType.Unknown, + }; +} diff --git a/src/AAuth/Agent/ChallengeHandler.cs b/src/AAuth/Agent/ChallengeHandler.cs index 3de1223..16faef4 100644 --- a/src/AAuth/Agent/ChallengeHandler.cs +++ b/src/AAuth/Agent/ChallengeHandler.cs @@ -200,7 +200,7 @@ private static async Task CloneAsync( foreach (var header in source.Headers) { // Strip prior signature headers so the signer re-emits them. - if (header.Key is "Signature" or "Signature-Input" or "Signature-Key") + if (header.Key is AAuthConstants.Headers.Signature or AAuthConstants.Headers.SignatureInput or AAuthConstants.Headers.SignatureKey) { continue; } diff --git a/src/AAuth/Agent/NamingJwtBuilder.cs b/src/AAuth/Agent/NamingJwtBuilder.cs index 75c635c..a733f70 100644 --- a/src/AAuth/Agent/NamingJwtBuilder.cs +++ b/src/AAuth/Agent/NamingJwtBuilder.cs @@ -28,7 +28,7 @@ public static string Build(IAAuthKey durableKey, IAAuthKey ephemeralKey, string var header = new JsonObject { ["alg"] = AAuthKey.Algorithm, - ["typ"] = "naming+jwt", + ["typ"] = AAuthConstants.TokenTypes.NamingJwt, ["kid"] = kid, }; diff --git a/src/AAuth/DependencyInjection/AAuthApplicationBuilderExtensions.cs b/src/AAuth/DependencyInjection/AAuthApplicationBuilderExtensions.cs index 58df178..76ae640 100644 --- a/src/AAuth/DependencyInjection/AAuthApplicationBuilderExtensions.cs +++ b/src/AAuth/DependencyInjection/AAuthApplicationBuilderExtensions.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using AAuth.Crypto; using AAuth.Discovery; using AAuth.HttpSig; using AAuth.Server; @@ -83,6 +86,66 @@ public static IEndpointRouteBuilder MapAAuthWellKnown(this IEndpointRouteBuilder return WellKnownEndpoints.MapAAuthResourceWellKnown(endpoints, options); } + /// + /// Configure the full AAuth resource pipeline in one call: maps well-known endpoints, + /// adds verification middleware, and adds challenge middleware. Uses the + /// DI-registered for configuration. + /// + /// + /// Equivalent to calling , , + /// and separately. For per-path customization, use the + /// individual middleware methods instead. + /// + /// The web application (both endpoint routing and middleware). + /// Optional configuration for verification and challenge behavior. + public static WebApplication MapAAuthResource( + this WebApplication app, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(app); + + var metadataOptions = app.Services.GetRequiredService(); + var pipelineOptions = new AAuthResourcePipelineOptions(); + configure?.Invoke(pipelineOptions); + + // 1. Map well-known endpoints + WellKnownEndpoints.MapAAuthResourceWellKnown(app, metadataOptions); + + // 2. Verification middleware + app.UseAAuthVerification(new AAuthVerificationOptions + { + ResourceIdentifier = metadataOptions.Issuer, + RequireIssuerVerification = pipelineOptions.RequireIssuerVerification, + TrustedAuthTokenIssuers = pipelineOptions.TrustedAuthTokenIssuers, + TrustedAgentProviderIssuers = pipelineOptions.TrustedAgentProviderIssuers, + }); + + // 3. Challenge middleware (only if there's a signing key available) + if (metadataOptions.SigningKeys.Count > 0) + { + // Use the first signing key for challenges + string? kid = null; + AAuth.Crypto.AAuthKey? key = null; + foreach (var kvp in metadataOptions.SigningKeys) + { + kid = kvp.Key; + key = kvp.Value; + break; + } + + app.UseAAuthChallenge(new ChallengeOptions + { + ResourceSigningKey = key, + ResourceKeyId = kid, + ResourceIdentifier = metadataOptions.Issuer, + AccessMode = pipelineOptions.AccessMode, + DefaultScopes = pipelineOptions.DefaultScopes, + }); + } + + return app; + } + /// /// Compose AAuth verification and challenge middleware for an intermediary /// resource that participates in call-chaining. Equivalent to calling diff --git a/src/AAuth/DependencyInjection/AAuthResourcePipelineOptions.cs b/src/AAuth/DependencyInjection/AAuthResourcePipelineOptions.cs new file mode 100644 index 0000000..d0db9ab --- /dev/null +++ b/src/AAuth/DependencyInjection/AAuthResourcePipelineOptions.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using AAuth.Server; + +namespace AAuth.DependencyInjection; + +/// +/// Options for the unified +/// pipeline, controlling verification and challenge behavior. +/// +public sealed class AAuthResourcePipelineOptions +{ + /// + /// When true, the middleware verifies the JWT issuer's signature via JWKS discovery. + /// Default: true. + /// + public bool RequireIssuerVerification { get; set; } = true; + + /// + /// Access mode controlling whether the middleware challenges or passes through. + /// Default: . + /// + public AAuthAccessMode AccessMode { get; set; } = AAuthAccessMode.RequireAuthToken; + + /// + /// Optional allow-list of trusted Person Server / Access Server issuers (for aa-auth+jwt). + /// When null, any issuer whose JWKS is resolvable is accepted. + /// + public IReadOnlySet? TrustedAuthTokenIssuers { get; set; } + + /// + /// Optional allow-list of trusted Agent Provider issuers (for aa-agent+jwt). + /// When null, any issuer whose JWKS is resolvable is accepted. + /// + public IReadOnlySet? TrustedAgentProviderIssuers { get; set; } + + /// + /// Default scopes to request in the resource token. Space-separated. + /// + public string? DefaultScopes { get; set; } +} diff --git a/src/AAuth/Discovery/ServerMetadata.cs b/src/AAuth/Discovery/ServerMetadata.cs index 071a4e8..3559773 100644 --- a/src/AAuth/Discovery/ServerMetadata.cs +++ b/src/AAuth/Discovery/ServerMetadata.cs @@ -98,7 +98,7 @@ public static class MetadataClientExtensions public static async Task FetchResourceMetadataAsync( this MetadataClient client, string issuer, CancellationToken ct = default) { - var url = MetadataClient.BuildUrl(issuer, "aauth-resource.json"); + var url = MetadataClient.BuildUrl(issuer, AAuthConstants.DwkFiles.Resource); var doc = await client.FetchAsync(url, ct); return ResourceMetadata.FromJson(doc); } @@ -107,7 +107,7 @@ public static async Task FetchResourceMetadataAsync( public static async Task FetchPersonServerMetadataAsync( this MetadataClient client, string issuer, CancellationToken ct = default) { - var url = MetadataClient.BuildUrl(issuer, "aauth-person.json"); + var url = MetadataClient.BuildUrl(issuer, AAuthConstants.DwkFiles.Person); var doc = await client.FetchAsync(url, ct); return ServerMetadata.FromJson(doc); } @@ -116,7 +116,7 @@ public static async Task FetchPersonServerMetadataAsync( public static async Task FetchAccessServerMetadataAsync( this MetadataClient client, string issuer, CancellationToken ct = default) { - var url = MetadataClient.BuildUrl(issuer, "aauth-access.json"); + var url = MetadataClient.BuildUrl(issuer, AAuthConstants.DwkFiles.Access); var doc = await client.FetchAsync(url, ct); return ServerMetadata.FromJson(doc); } diff --git a/src/AAuth/HttpSig/AAuthClientBuilder.cs b/src/AAuth/HttpSig/AAuthClientBuilder.cs index 496e892..1cc8e8f 100644 --- a/src/AAuth/HttpSig/AAuthClientBuilder.cs +++ b/src/AAuth/HttpSig/AAuthClientBuilder.cs @@ -69,6 +69,12 @@ public static AAuthClientBuilder From(EnrollResult result) private string? _personServer; private Action? _challengeOptionsConfigure; + // Self-issued token state + private string? _selfIssuedPersonServer; + private string? _selfIssuedIssuer; + private string? _selfIssuedSubject; + private string? _selfIssuedKid; + // Call-chaining state private Func? _upstreamTokenProvider; @@ -90,6 +96,45 @@ public AAuthClientBuilder(IAAuthKey key) _key = key; } + /// + /// Start building a self-issued agent identity with a fluent sub-builder. + /// Call to set issuer and subject. + /// + /// The agent's signing key. + /// + /// + /// using var client = AAuthClientBuilder.SelfIssuing(key) + /// .As(issuer, subject) + /// .WithPersonServer(ps) + /// .Build(); + /// + /// + public static SelfIssuingBuilder SelfIssuing(IAAuthKey key) + { + ArgumentNullException.ThrowIfNull(key); + return new SelfIssuingBuilder(key); + } + + /// + /// Start building an AP-enrolled agent client with a fluent sub-builder. + /// Call to set the refresh endpoint. + /// + /// The agent's durable signing key (loaded from the key store). + /// + /// + /// using var client = AAuthClientBuilder.Enrolled(key) + /// .RefreshingFrom(refreshEndpoint, localKeyHandle) + /// .WithKeyStore(keyStore) + /// .WithChallengeHandling(ps) + /// .Build(); + /// + /// + public static EnrolledBuilder Enrolled(IAAuthKey key) + { + ArgumentNullException.ThrowIfNull(key); + return new EnrolledBuilder(key); + } + /// Use the pseudonymous (hwk) signing mode. public AAuthClientBuilder UseHwk() { @@ -176,24 +221,24 @@ public AAuthClientBuilder OnSignatureBase(Action cal /// /// Enable automatic 401 challenge handling. The Person Server URL is - /// extracted from the agent token's ps claim. + /// extracted from the agent token's ps claim, or from + /// if previously configured. /// public AAuthClientBuilder WithChallengeHandling() { _challengeHandling = true; - _personServer = null; return this; } /// /// Enable automatic 401 challenge handling with options. The Person Server URL is - /// extracted from the agent token's ps claim. + /// extracted from the agent token's ps claim, or from + /// if previously configured. /// public AAuthClientBuilder WithChallengeHandling(Action configure) { ArgumentNullException.ThrowIfNull(configure); _challengeHandling = true; - _personServer = null; _challengeOptionsConfigure = configure; return this; } @@ -264,6 +309,34 @@ public AAuthClientBuilder WithCallChaining(HttpContext httpContext) return this; } + /// + /// Configure a self-issued agent token identity. The builder's key is used + /// for both HTTP signing and token signing. A + /// is created internally — no separate call is needed. + /// + internal AAuthClientBuilder WithSelfIssuedToken(string issuer, string subject, string? kid = null) + { + ArgumentException.ThrowIfNullOrEmpty(issuer); + ArgumentException.ThrowIfNullOrEmpty(subject); + _selfIssuedIssuer = issuer; + _selfIssuedSubject = subject; + _selfIssuedKid = kid; + return this; + } + + /// + /// Set the Person Server URL for both the agent token's ps claim and + /// challenge handling. Calling after this + /// method uses the stored PS automatically. + /// + public AAuthClientBuilder WithPersonServer(string personServer) + { + ArgumentException.ThrowIfNullOrEmpty(personServer); + _selfIssuedPersonServer = personServer; + _personServer = personServer; + return this; + } + /// /// Register a custom token refresher that is invoked when the agent /// token nears expiry. @@ -320,9 +393,23 @@ public AAuthClientBuilder WithInteractionHandling() /// No signing mode was configured. public HttpMessageHandler BuildHandler() { + // Materialize self-issued token refresher if WithSelfIssuedToken was called. + if (_selfIssuedIssuer is not null && _tokenRefresher is null) + { + if (_key is not AAuthKey concreteKey) + throw new InvalidOperationException( + "WithSelfIssuedToken() requires the key to be an AAuthKey instance."); + _tokenRefresher = new SelfIssuedTokenRefresher( + concreteKey, + _selfIssuedIssuer, + _selfIssuedSubject!, + _selfIssuedKid ?? _key.ComputeJwkThumbprint(), + _selfIssuedPersonServer); + } + if (_provider is null && _tokenRefresher is null) throw new InvalidOperationException( - "A signing mode must be configured. Call UseHwk(), UseJwksUri(), or UseJktJwt() before Build(), or use WithTokenRefresh() for JWT mode."); + "A signing mode must be configured. Call UseHwk(), UseJwksUri(), WithSelfIssuedToken(), or UseJktJwt() before Build(), or use WithTokenRefresh() for JWT mode."); if (!_challengeHandling) { diff --git a/src/AAuth/HttpSig/AAuthSigningHandler.cs b/src/AAuth/HttpSig/AAuthSigningHandler.cs index 6a0bdd7..37fb75a 100644 --- a/src/AAuth/HttpSig/AAuthSigningHandler.cs +++ b/src/AAuth/HttpSig/AAuthSigningHandler.cs @@ -159,13 +159,13 @@ public void Sign(HttpRequestMessage request) var signature = _key.Sign(Encoding.ASCII.GetBytes(signatureBase)); - request.Headers.Remove(SignatureKeyHeader.Name); - request.Headers.Remove("Signature-Input"); - request.Headers.Remove("Signature"); + request.Headers.Remove(AAuthConstants.Headers.SignatureKey); + request.Headers.Remove(AAuthConstants.Headers.SignatureInput); + request.Headers.Remove(AAuthConstants.Headers.Signature); - request.Headers.TryAddWithoutValidation(SignatureKeyHeader.Name, signatureKey); - request.Headers.TryAddWithoutValidation("Signature-Input", $"{SignatureLabel}={paramsLine}"); - request.Headers.TryAddWithoutValidation("Signature", $"{SignatureLabel}=:{Convert.ToBase64String(signature)}:"); + request.Headers.TryAddWithoutValidation(AAuthConstants.Headers.SignatureKey, signatureKey); + request.Headers.TryAddWithoutValidation(AAuthConstants.Headers.SignatureInput, $"{SignatureLabel}={paramsLine}"); + request.Headers.TryAddWithoutValidation(AAuthConstants.Headers.Signature, $"{SignatureLabel}=:{Convert.ToBase64String(signature)}:"); // Emit capabilities header if configured if (Capabilities is { Count: > 0 }) diff --git a/src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs b/src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs index 1051c85..76e96f7 100644 --- a/src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs +++ b/src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs @@ -35,10 +35,10 @@ public async Task ResolveAsync( IAAuthKey key = info.Scheme switch { - "jwt" => ResolveJwt(info), - "hwk" => await ResolveHwkAsync(info, ct).ConfigureAwait(false), - "jwks_uri" => await ResolveJwksUriAsync(info, ct).ConfigureAwait(false), - "jkt-jwt" => await ResolveJktJwtAsync(info, ct).ConfigureAwait(false), + AAuthConstants.Schemes.Jwt => ResolveJwt(info), + AAuthConstants.Schemes.Hwk => await ResolveHwkAsync(info, ct).ConfigureAwait(false), + AAuthConstants.Schemes.JwksUri => await ResolveJwksUriAsync(info, ct).ConfigureAwait(false), + AAuthConstants.Schemes.JktJwt => await ResolveJktJwtAsync(info, ct).ConfigureAwait(false), _ => throw new AAuthVerificationException($"Unsupported Signature-Key scheme: '{info.Scheme}'."), }; diff --git a/src/AAuth/HttpSig/EnrolledBuilder.cs b/src/AAuth/HttpSig/EnrolledBuilder.cs new file mode 100644 index 0000000..4cf91d3 --- /dev/null +++ b/src/AAuth/HttpSig/EnrolledBuilder.cs @@ -0,0 +1,156 @@ +using System; +using System.Net.Http; +using AAuth.Agent; +using AAuth.Crypto; +using AAuth.Server; +using Microsoft.AspNetCore.Http; + +namespace AAuth.HttpSig; + +/// +/// Fluent sub-builder for configuring an AP-enrolled agent client. +/// Returned by . +/// +/// +/// +/// using var client = AAuthClientBuilder.Enrolled(key) +/// .RefreshingFrom(refreshEndpoint, localKeyHandle) +/// .WithKeyStore(keyStore) +/// .WithPersonServer(ps) +/// .WithChallengeHandling() +/// .Build(); +/// +/// +public sealed class EnrolledBuilder +{ + private readonly IAAuthKey _key; + private string? _refreshEndpoint; + private string? _localKeyHandle; + private IKeyStore? _keyStore; + private RefreshMode _refreshMode = RefreshMode.SingleKey; + private string? _apIssuer; + + internal EnrolledBuilder(IAAuthKey key) + { + _key = key; + } + + /// + /// Configure the AP refresh endpoint and the local key handle used to sign refresh requests. + /// + /// The AP's refresh/token endpoint URL. + /// Agent-local handle for the durable signing key. + public EnrolledBuilder RefreshingFrom(string refreshEndpoint, string localKeyHandle) + { + ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); + ArgumentException.ThrowIfNullOrEmpty(localKeyHandle); + _refreshEndpoint = refreshEndpoint; + _localKeyHandle = localKeyHandle; + return this; + } + + /// + /// Use a custom instead of . + /// + public EnrolledBuilder WithKeyStore(IKeyStore keyStore) + { + ArgumentNullException.ThrowIfNull(keyStore); + _keyStore = keyStore; + return this; + } + + /// + /// Set the refresh mode. Default is . + /// + /// Refresh mode to use. + /// AP issuer URL (required for ). + public EnrolledBuilder WithRefreshMode(RefreshMode mode, string? apIssuer = null) + { + _refreshMode = mode; + _apIssuer = apIssuer; + return this; + } + + /// + /// Set the Person Server URL for challenge handling. + /// + public AAuthClientBuilder WithPersonServer(string personServer) + { + return ToBuilder().WithPersonServer(personServer); + } + + /// Enable automatic 401 challenge handling (PS resolved from token). + public AAuthClientBuilder WithChallengeHandling() + { + return ToBuilder().WithChallengeHandling(); + } + + /// Enable automatic 401 challenge handling with an explicit Person Server URL. + public AAuthClientBuilder WithChallengeHandling(string personServer) + { + return ToBuilder().WithChallengeHandling(personServer); + } + + /// Enable automatic 401 challenge handling with options. + public AAuthClientBuilder WithChallengeHandling(Action configure) + { + return ToBuilder().WithChallengeHandling(configure); + } + + /// Enable interaction handling for deferred consent flows. + public AAuthClientBuilder WithInteractionHandling() + { + return ToBuilder().WithInteractionHandling(); + } + + /// Enable interaction handling with options. + public AAuthClientBuilder WithInteractionHandling(Action configure) + { + return ToBuilder().WithInteractionHandling(configure); + } + + /// Enable call-chaining with a delegate that provides the upstream auth token. + public AAuthClientBuilder WithCallChaining(Func upstreamTokenProvider) + { + return ToBuilder().WithCallChaining(upstreamTokenProvider); + } + + /// Enable call-chaining with a fixed upstream auth token. + public AAuthClientBuilder WithCallChaining(string upstreamAuthToken) + { + return ToBuilder().WithCallChaining(upstreamAuthToken); + } + + /// Enable call-chaining from the current HTTP context. + public AAuthClientBuilder WithCallChaining(HttpContext httpContext) + { + return ToBuilder().WithCallChaining(httpContext); + } + + /// Override the inner HTTP handler. + public AAuthClientBuilder WithInnerHandler(HttpMessageHandler handler) + { + return ToBuilder().WithInnerHandler(handler); + } + + /// Build the configured . + public HttpClient Build() => ToBuilder().Build(); + + /// Build the configured handler pipeline. + public HttpMessageHandler BuildHandler() => ToBuilder().BuildHandler(); + + private AAuthClientBuilder ToBuilder() + { + if (_refreshEndpoint is null || _localKeyHandle is null) + throw new InvalidOperationException( + "RefreshingFrom(endpoint, keyHandle) must be called before building."); + + var refresher = AgentProviderTokenRefresher.Create(_refreshEndpoint, _localKeyHandle) + .WithKeyStore(_keyStore ?? FileKeyStore.Default()) + .WithRefreshMode(_refreshMode, _apIssuer) + .Build(); + + return new AAuthClientBuilder(_key) + .WithTokenRefresh(refresher); + } +} diff --git a/src/AAuth/HttpSig/SelfIssuingBuilder.cs b/src/AAuth/HttpSig/SelfIssuingBuilder.cs new file mode 100644 index 0000000..82b41e4 --- /dev/null +++ b/src/AAuth/HttpSig/SelfIssuingBuilder.cs @@ -0,0 +1,122 @@ +using System; +using System.Net.Http; +using AAuth.Crypto; +using AAuth.Server; +using Microsoft.AspNetCore.Http; + +namespace AAuth.HttpSig; + +/// +/// Fluent sub-builder for configuring a self-issued agent identity. +/// Returned by . +/// +/// +/// +/// using var client = AAuthClientBuilder.SelfIssuing(key) +/// .As(issuer, subject) +/// .WithPersonServer(ps) +/// .WithChallengeHandling() +/// .Build(); +/// +/// +public sealed class SelfIssuingBuilder +{ + private readonly IAAuthKey _key; + private string? _issuer; + private string? _subject; + private string? _kid; + + internal SelfIssuingBuilder(IAAuthKey key) + { + _key = key; + } + + /// + /// Set the issuer and subject for the self-issued agent token. + /// + /// Issuer URL (the service's own HTTPS URL). + /// Agent identifier (e.g. aauth:my-service@my-service.example). + public SelfIssuingBuilder As(string issuer, string subject) + { + ArgumentException.ThrowIfNullOrEmpty(issuer); + ArgumentException.ThrowIfNullOrEmpty(subject); + _issuer = issuer; + _subject = subject; + return this; + } + + /// + /// Set a custom key ID for the agent token header. Defaults to the key's JWK thumbprint. + /// + public SelfIssuingBuilder WithKid(string kid) + { + ArgumentException.ThrowIfNullOrEmpty(kid); + _kid = kid; + return this; + } + + /// + /// Set the Person Server URL for both the agent token's ps claim + /// and challenge handling. + /// + public AAuthClientBuilder WithPersonServer(string personServer) + { + return ToBuilder().WithPersonServer(personServer); + } + + /// Enable automatic 401 challenge handling (PS resolved from token). + public AAuthClientBuilder WithChallengeHandling() + { + return ToBuilder().WithChallengeHandling(); + } + + /// Enable automatic 401 challenge handling with an explicit Person Server URL. + public AAuthClientBuilder WithChallengeHandling(string personServer) + { + return ToBuilder().WithChallengeHandling(personServer); + } + + /// Enable automatic 401 challenge handling with options. + public AAuthClientBuilder WithChallengeHandling(Action configure) + { + return ToBuilder().WithChallengeHandling(configure); + } + + /// Enable call-chaining with a delegate that provides the upstream auth token. + public AAuthClientBuilder WithCallChaining(Func upstreamTokenProvider) + { + return ToBuilder().WithCallChaining(upstreamTokenProvider); + } + + /// Enable call-chaining with a fixed upstream auth token. + public AAuthClientBuilder WithCallChaining(string upstreamAuthToken) + { + return ToBuilder().WithCallChaining(upstreamAuthToken); + } + + /// Enable call-chaining from the current HTTP context. + public AAuthClientBuilder WithCallChaining(HttpContext httpContext) + { + return ToBuilder().WithCallChaining(httpContext); + } + + /// Override the inner HTTP handler. + public AAuthClientBuilder WithInnerHandler(HttpMessageHandler handler) + { + return ToBuilder().WithInnerHandler(handler); + } + + /// Build the configured . + public HttpClient Build() => ToBuilder().Build(); + + /// Build the configured handler pipeline. + public HttpMessageHandler BuildHandler() => ToBuilder().BuildHandler(); + + private AAuthClientBuilder ToBuilder() + { + if (_issuer is null || _subject is null) + throw new InvalidOperationException( + "As(issuer, subject) must be called before building."); + return new AAuthClientBuilder(_key).WithSelfIssuedToken(_issuer, _subject, _kid); + } +} diff --git a/src/AAuth/HttpSig/SignatureKeyParser.cs b/src/AAuth/HttpSig/SignatureKeyParser.cs index af857e5..f88e7f2 100644 --- a/src/AAuth/HttpSig/SignatureKeyParser.cs +++ b/src/AAuth/HttpSig/SignatureKeyParser.cs @@ -93,10 +93,10 @@ public static ParsedSignatureKeyInfo ParseAny(string signatureKeyHeader) return scheme switch { - "jwt" => ParseJwtScheme(parameters), - "hwk" => ParseHwkScheme(parameters), - "jkt-jwt" => ParseJktJwtScheme(parameters), - "jwks_uri" => ParseJwksUriScheme(parameters), + AAuthConstants.Schemes.Jwt => ParseJwtScheme(parameters), + AAuthConstants.Schemes.Hwk => ParseHwkScheme(parameters), + AAuthConstants.Schemes.JktJwt => ParseJktJwtScheme(parameters), + AAuthConstants.Schemes.JwksUri => ParseJwksUriScheme(parameters), _ => throw new AAuthVerificationException($"Unsupported Signature-Key scheme: '{scheme}'."), }; } diff --git a/src/AAuth/Server/AAuthChallengeMiddleware.cs b/src/AAuth/Server/AAuthChallengeMiddleware.cs index a30c8e9..977eca1 100644 --- a/src/AAuth/Server/AAuthChallengeMiddleware.cs +++ b/src/AAuth/Server/AAuthChallengeMiddleware.cs @@ -66,32 +66,33 @@ public async Task InvokeAsync(HttpContext context) // Determine the scheme and token type. var scheme = result?.Scheme ?? parsedInfo?.Scheme; - var tokenType = result?.TokenType ?? (string?)parsedInfo?.Header?["typ"]; + var tokenType = AAuthTokenTypeExtensions.ParseTokenType( + result?.TokenType ?? (string?)parsedInfo?.Header?["typ"]); // Scheme filtering. if (scheme is not null && _options.AllowedSignatureKeySchemes is { } allowed && !allowed.Contains(scheme)) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Headers["AAuth-Error"] = $"Scheme '{scheme}' is not allowed by this resource."; + context.Response.Headers[AAuthConstants.Headers.AAuthError] = $"Scheme '{scheme}' is not allowed by this resource."; return; } // Non-JWT schemes (hwk, jwks_uri) pass through — they have no token upgrade path. - if (scheme is "hwk" or "jwks_uri") + if (scheme is AAuthConstants.Schemes.Hwk or AAuthConstants.Schemes.JwksUri) { await _next(context).ConfigureAwait(false); return; } // Auth token already present → pass through. - if (tokenType == AuthTokenBuilder.TokenType) + if (tokenType == AAuthTokenType.AuthToken) { await _next(context).ConfigureAwait(false); return; } // Agent token → challenge. - if (tokenType == AgentTokenBuilder.TokenType) + if (tokenType == AAuthTokenType.AgentToken) { await IssueChallenge(context, result, parsedInfo).ConfigureAwait(false); return; @@ -132,7 +133,7 @@ private Task IssueChallenge( // No PS available and no explicit audience configured. // Return 401 without resource token — the resource cannot issue a valid challenge. context.Response.StatusCode = StatusCodes.Status401Unauthorized; - context.Response.Headers["AAuth-Error"] = + context.Response.Headers[AAuthConstants.Headers.AAuthError] = "Auth token required but no Person Server audience could be resolved."; return Task.CompletedTask; } diff --git a/src/AAuth/Server/AAuthHttpContextExtensions.cs b/src/AAuth/Server/AAuthHttpContextExtensions.cs new file mode 100644 index 0000000..f442828 --- /dev/null +++ b/src/AAuth/Server/AAuthHttpContextExtensions.cs @@ -0,0 +1,80 @@ +using AAuth.Headers; +using AAuth.HttpSig; +using Microsoft.AspNetCore.Http; + +namespace AAuth.Server; + +/// +/// Extension methods on for convenient access to +/// AAuth verification results and protocol response helpers. +/// +public static class AAuthHttpContextExtensions +{ + // ----------------------------------------------------------------------- + // Request-side: reading verification results + // ----------------------------------------------------------------------- + + /// + /// Gets the from HttpContext.Features. + /// Returns null if verification middleware has not run. + /// + public static AAuthVerificationResult? GetAAuthVerification(this HttpContext context) + => context.Features.Get(); + + /// + /// Gets the from HttpContext.Items. + /// Returns null if verification middleware has not run. + /// + public static SignatureKeyParser.ParsedSignatureKeyInfo? GetAAuthParsedKey(this HttpContext context) + => context.Items.TryGetValue(AAuthVerificationMiddleware.ParsedInfoItemKey, out var obj) + ? obj as SignatureKeyParser.ParsedSignatureKeyInfo + : null; + + /// + /// Gets the from HttpContext.Items. + /// Prefer which returns the richer typed result. + /// + public static VerificationResult? GetAAuthResult(this HttpContext context) + => context.Items.TryGetValue(AAuthVerificationMiddleware.ContextItemKey, out var obj) + ? obj as VerificationResult + : null; + + /// + /// Gets the token type from the verified request as an . + /// Returns if verification middleware has not run + /// or the token type is unrecognized. + /// + public static AAuthTokenType GetAAuthTokenType(this HttpContext context) + => context.GetAAuthVerification()?.TokenType ?? AAuthTokenType.Unknown; + + // ----------------------------------------------------------------------- + // Response-side: protocol challenge and error helpers + // ----------------------------------------------------------------------- + + /// + /// Issues an AAuth challenge by setting the AAuth-Requirement header with + /// an auth-token requirement and returning 401 Unauthorized. + /// + /// The current HTTP context. + /// The signed resource token JWT string. + /// An that writes the 401 response. + public static IResult ChallengeAAuth(this HttpContext context, string resourceToken) + { + context.Response.Headers[AAuthConstants.Headers.AAuthRequirement] = + AAuthRequirementHeader.FormatAuthToken(resourceToken); + return Results.Json( + new { error = "auth_token_required" }, + statusCode: StatusCodes.Status401Unauthorized); + } + + /// + /// Sets the AAuth-Error response header with the given message. + /// Does not change the status code — call this before returning an error result. + /// + /// The current HTTP context. + /// A human-readable error message. + public static void SetAAuthError(this HttpContext context, string message) + { + context.Response.Headers[AAuthConstants.Headers.AAuthError] = message; + } +} diff --git a/src/AAuth/Server/AAuthVerificationMiddleware.cs b/src/AAuth/Server/AAuthVerificationMiddleware.cs index 4ab7f1f..42565aa 100644 --- a/src/AAuth/Server/AAuthVerificationMiddleware.cs +++ b/src/AAuth/Server/AAuthVerificationMiddleware.cs @@ -88,9 +88,9 @@ public async Task InvokeAsync(HttpContext context) { var req = context.Request; - if (!TryGetSingle(req, "Signature", out var signature) || - !TryGetSingle(req, "Signature-Input", out var signatureInput) || - !TryGetSingle(req, SignatureKeyHeader.Name, out var signatureKey)) + if (!TryGetSingle(req, AAuthConstants.Headers.Signature, out var signature) || + !TryGetSingle(req, AAuthConstants.Headers.SignatureInput, out var signatureInput) || + !TryGetSingle(req, AAuthConstants.Headers.SignatureKey, out var signatureKey)) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers[SignatureError.HeaderName] = @@ -160,7 +160,7 @@ storeObj is IJtiStore jtiStore && // Naming JWT expiration check: for jkt-jwt scheme, reject expired naming JWTs // regardless of RequireIssuerVerification. The naming JWT has a short lifetime // (typically 5 min) to limit the window of delegation from the durable key. - if (parsedInfo.Scheme == "jkt-jwt" && + if (parsedInfo.Scheme == AAuthConstants.Schemes.JktJwt && parsedInfo.Payload?["exp"] is JsonNode expClaim) { var now = (_options.Clock ?? (() => DateTimeOffset.UtcNow))(); @@ -170,14 +170,14 @@ storeObj is IJtiStore jtiStore && context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers[SignatureError.HeaderName] = SignatureError.Format(SignatureErrorCode.InvalidJwt); - context.Response.Headers["AAuth-Error"] = "Naming JWT has expired."; + context.Response.Headers[AAuthConstants.Headers.AAuthError] = "Naming JWT has expired."; return; } } // Step 4-6: JWT issuer verification (for jwt and jkt-jwt schemes with carrier tokens). if (_options.RequireIssuerVerification && - parsedInfo.Scheme is "jwt" or "jkt-jwt" && + parsedInfo.Scheme is AAuthConstants.Schemes.Jwt or AAuthConstants.Schemes.JktJwt && parsedInfo.Jwt is not null && parsedInfo.Header is not null && parsedInfo.Payload is not null) @@ -208,7 +208,7 @@ await VerifyAuthTokenIssuerAsync(parsedInfo, publicKey, context.RequestAborted) context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers[SignatureError.HeaderName] = SignatureError.Format(SignatureErrorCode.InvalidJwt); - context.Response.Headers["AAuth-Error"] = ex.Message; + context.Response.Headers[AAuthConstants.Headers.AAuthError] = ex.Message; return; } } @@ -224,12 +224,13 @@ await VerifyAuthTokenIssuerAsync(parsedInfo, publicKey, context.RequestAborted) Subject = (string?)parsedInfo.Payload?["sub"], Scope = (string?)parsedInfo.Payload?["scope"], IssuerVerified = _options.RequireIssuerVerification && - parsedInfo.Scheme is "jwt" or "jkt-jwt", + parsedInfo.Scheme is AAuthConstants.Schemes.Jwt or AAuthConstants.Schemes.JktJwt, }; // Store typed verification result in HttpContext.Features for // AAuthAuthenticationHandler and authorization policies. var tokenType = (string?)parsedInfo.Header?["typ"]; + var tokenTypeEnum = AAuthTokenTypeExtensions.ParseTokenType(tokenType); var level = DetermineLevel(parsedInfo.Scheme, tokenType); var scopeString = (string?)parsedInfo.Payload?["scope"]; var scopes = ParseScopes(scopeString); @@ -239,7 +240,7 @@ await VerifyAuthTokenIssuerAsync(parsedInfo, publicKey, context.RequestAborted) { Level = level, Scheme = parsedInfo.Scheme, - TokenType = tokenType, + TokenType = tokenTypeEnum, Issuer = (string?)parsedInfo.Payload?["iss"], Agent = tokenType == AuthTokenBuilder.TokenType ? (string?)parsedInfo.Payload?["agent"] @@ -250,7 +251,7 @@ await VerifyAuthTokenIssuerAsync(parsedInfo, publicKey, context.RequestAborted) Jkt = parsedInfo.ConfirmationKey?.ComputeJwkThumbprint() ?? parsedInfo.Jkt, IssuerVerified = _options.RequireIssuerVerification && - parsedInfo.Scheme is "jwt" or "jkt-jwt", + parsedInfo.Scheme is AAuthConstants.Schemes.Jwt or AAuthConstants.Schemes.JktJwt, }); // Set UpstreamAuthTokenFeature for aa-auth+jwt tokens so that @@ -259,7 +260,7 @@ await VerifyAuthTokenIssuerAsync(parsedInfo, publicKey, context.RequestAborted) if (tokenType == AuthTokenBuilder.TokenType && parsedInfo.Jwt is not null && _options.RequireIssuerVerification && - parsedInfo.Scheme is "jwt" or "jkt-jwt") + parsedInfo.Scheme is AAuthConstants.Schemes.Jwt or AAuthConstants.Schemes.JktJwt) { context.Features.Set(new UpstreamAuthTokenFeature(parsedInfo.Jwt)); } @@ -282,7 +283,7 @@ parsedInfo.Jwt is not null && if (scopeString is not null) activity.SetTag(AAuthDiagnostics.TagScope, scopeString); activity.SetTag(AAuthDiagnostics.TagIssuerVerified, - _options.RequireIssuerVerification && parsedInfo.Scheme is "jwt" or "jkt-jwt"); + _options.RequireIssuerVerification && parsedInfo.Scheme is AAuthConstants.Schemes.Jwt or AAuthConstants.Schemes.JktJwt); } await _next(context).ConfigureAwait(false); @@ -493,7 +494,7 @@ private static bool TryGetSingle(HttpRequest request, string headerName, out str private static AAuthLevel DetermineLevel(string scheme, string? tokenType) { - if (scheme == "hwk") + if (scheme == AAuthConstants.Schemes.Hwk) return AAuthLevel.Pseudonymous; if (tokenType == AuthTokenBuilder.TokenType) return AAuthLevel.Authorized; diff --git a/src/AAuth/Server/AAuthVerificationResult.cs b/src/AAuth/Server/AAuthVerificationResult.cs index a9be19d..91eb82d 100644 --- a/src/AAuth/Server/AAuthVerificationResult.cs +++ b/src/AAuth/Server/AAuthVerificationResult.cs @@ -16,8 +16,8 @@ public sealed class AAuthVerificationResult /// The Signature-Key scheme (jwt, hwk, jwks_uri, jkt-jwt). public required string Scheme { get; init; } - /// Token type from JWT typ header (aa-agent+jwt, aa-auth+jwt), or null for non-JWT schemes. - public string? TokenType { get; init; } + /// Token type from JWT typ header. + public AAuthTokenType TokenType { get; init; } /// Issuer (iss) from the JWT, or null for non-JWT schemes. public string? Issuer { get; init; } diff --git a/tests/AAuth.Tests/AAuthConstantsTests.cs b/tests/AAuth.Tests/AAuthConstantsTests.cs new file mode 100644 index 0000000..3066e12 --- /dev/null +++ b/tests/AAuth.Tests/AAuthConstantsTests.cs @@ -0,0 +1,71 @@ +using AAuth; +using AAuth.Tokens; +using Xunit; + +namespace AAuth.Tests; + +public class AAuthConstantsTests +{ + [Fact] + public void TokenType_AgentToken_MatchesBuilder() + { + Assert.Equal(AgentTokenBuilder.TokenType, AAuthConstants.TokenTypes.AgentToken); + } + + [Fact] + public void TokenType_AuthToken_MatchesBuilder() + { + Assert.Equal(AuthTokenBuilder.TokenType, AAuthConstants.TokenTypes.AuthToken); + } + + [Fact] + public void TokenType_ResourceToken_MatchesBuilder() + { + Assert.Equal(ResourceTokenBuilder.TokenType, AAuthConstants.TokenTypes.ResourceToken); + } + + [Fact] + public void DwkFiles_Agent_MatchesBuilder() + { + Assert.Equal(AgentTokenBuilder.AgentDwk, AAuthConstants.DwkFiles.Agent); + } + + [Fact] + public void DwkFiles_Person_MatchesBuilder() + { + Assert.Equal(AuthTokenBuilder.PersonDwk, AAuthConstants.DwkFiles.Person); + } + + [Fact] + public void DwkFiles_Access_MatchesBuilder() + { + Assert.Equal(AuthTokenBuilder.AccessDwk, AAuthConstants.DwkFiles.Access); + } + + [Fact] + public void DwkFiles_Resource_MatchesBuilder() + { + Assert.Equal(ResourceTokenBuilder.ResourceDwk, AAuthConstants.DwkFiles.Resource); + } + + [Fact] + public void Headers_SignatureKey_MatchesExisting() + { + Assert.Equal(AAuth.HttpSig.SignatureKeyHeader.Name, AAuthConstants.Headers.SignatureKey); + } + + [Fact] + public void Headers_AAuthRequirement_MatchesExisting() + { + Assert.Equal(AAuth.Headers.AAuthRequirementHeader.Name, AAuthConstants.Headers.AAuthRequirement); + } + + [Fact] + public void Schemes_AreProtocolValues() + { + Assert.Equal("jwt", AAuthConstants.Schemes.Jwt); + Assert.Equal("hwk", AAuthConstants.Schemes.Hwk); + Assert.Equal("jkt-jwt", AAuthConstants.Schemes.JktJwt); + Assert.Equal("jwks_uri", AAuthConstants.Schemes.JwksUri); + } +} diff --git a/tests/AAuth.Tests/AAuthTokenTypeTests.cs b/tests/AAuth.Tests/AAuthTokenTypeTests.cs new file mode 100644 index 0000000..e549859 --- /dev/null +++ b/tests/AAuth.Tests/AAuthTokenTypeTests.cs @@ -0,0 +1,44 @@ +using AAuth; +using Xunit; + +namespace AAuth.Tests; + +public class AAuthTokenTypeTests +{ + [Theory] + [InlineData("aa-agent+jwt", AAuthTokenType.AgentToken)] + [InlineData("aa-auth+jwt", AAuthTokenType.AuthToken)] + [InlineData("aa-resource+jwt", AAuthTokenType.ResourceToken)] + [InlineData("naming+jwt", AAuthTokenType.NamingJwt)] + public void ParseTokenType_KnownValues(string input, AAuthTokenType expected) + { + Assert.Equal(expected, AAuthTokenTypeExtensions.ParseTokenType(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("unknown+jwt")] + [InlineData("JWT")] + public void ParseTokenType_UnknownValues_ReturnUnknown(string? input) + { + Assert.Equal(AAuthTokenType.Unknown, AAuthTokenTypeExtensions.ParseTokenType(input)); + } + + [Theory] + [InlineData(AAuthTokenType.AgentToken, "aa-agent+jwt")] + [InlineData(AAuthTokenType.AuthToken, "aa-auth+jwt")] + [InlineData(AAuthTokenType.ResourceToken, "aa-resource+jwt")] + [InlineData(AAuthTokenType.NamingJwt, "naming+jwt")] + public void ToHeaderValue_RoundTrips(AAuthTokenType type, string expected) + { + Assert.Equal(expected, type.ToHeaderValue()); + } + + [Fact] + public void ToHeaderValue_Unknown_Throws() + { + Assert.Throws(() => + AAuthTokenType.Unknown.ToHeaderValue()); + } +} diff --git a/tests/AAuth.Tests/HttpSig/EnrolledBuilderTests.cs b/tests/AAuth.Tests/HttpSig/EnrolledBuilderTests.cs new file mode 100644 index 0000000..8bf22f1 --- /dev/null +++ b/tests/AAuth.Tests/HttpSig/EnrolledBuilderTests.cs @@ -0,0 +1,115 @@ +using System; +using AAuth.Agent; +using AAuth.Crypto; +using AAuth.HttpSig; +using Xunit; + +namespace AAuth.Tests.HttpSig; + +public class EnrolledBuilderTests +{ + private readonly AAuthKey _key = AAuthKey.Generate(); + private const string RefreshEndpoint = "http://localhost:5200/refresh"; + private const string LocalKeyHandle = "my-agent-key"; + private const string PersonServer = "http://localhost:5100"; + + [Fact] + public void Enrolled_throws_on_null_key() + { + Assert.Throws(() => AAuthClientBuilder.Enrolled(null!)); + } + + [Fact] + public void RefreshingFrom_throws_on_null_endpoint() + { + var builder = AAuthClientBuilder.Enrolled(_key); + Assert.Throws(() => builder.RefreshingFrom(null!, LocalKeyHandle)); + } + + [Fact] + public void RefreshingFrom_throws_on_empty_endpoint() + { + var builder = AAuthClientBuilder.Enrolled(_key); + Assert.Throws(() => builder.RefreshingFrom("", LocalKeyHandle)); + } + + [Fact] + public void RefreshingFrom_throws_on_null_keyHandle() + { + var builder = AAuthClientBuilder.Enrolled(_key); + Assert.Throws(() => builder.RefreshingFrom(RefreshEndpoint, null!)); + } + + [Fact] + public void RefreshingFrom_throws_on_empty_keyHandle() + { + var builder = AAuthClientBuilder.Enrolled(_key); + Assert.Throws(() => builder.RefreshingFrom(RefreshEndpoint, "")); + } + + [Fact] + public void Build_throws_without_RefreshingFrom() + { + var builder = AAuthClientBuilder.Enrolled(_key); + Assert.Throws(() => builder.Build()); + } + + [Fact] + public void Enrolled_with_RefreshingFrom_builds_client() + { + // This builds a client with token refresh configured (will attempt refresh on first request) + using var client = AAuthClientBuilder.Enrolled(_key) + .RefreshingFrom(RefreshEndpoint, LocalKeyHandle) + .WithKeyStore(new InMemoryKeyStore(_key)) + .Build(); + + Assert.NotNull(client); + } + + [Fact] + public void Enrolled_with_challenge_handling_builds_client() + { + using var client = AAuthClientBuilder.Enrolled(_key) + .RefreshingFrom(RefreshEndpoint, LocalKeyHandle) + .WithKeyStore(new InMemoryKeyStore(_key)) + .WithChallengeHandling(PersonServer) + .Build(); + + Assert.NotNull(client); + } + + [Fact] + public void Enrolled_with_two_key_mode_builds_client() + { + using var client = AAuthClientBuilder.Enrolled(_key) + .RefreshingFrom(RefreshEndpoint, LocalKeyHandle) + .WithKeyStore(new InMemoryKeyStore(_key)) + .WithRefreshMode(RefreshMode.TwoKey, "http://localhost:5200") + .WithChallengeHandling(PersonServer) + .Build(); + + Assert.NotNull(client); + } + + [Fact] + public void WithKeyStore_throws_on_null() + { + var builder = AAuthClientBuilder.Enrolled(_key); + Assert.Throws(() => builder.WithKeyStore(null!)); + } + + /// Simple in-memory key store for testing. + private sealed class InMemoryKeyStore : IKeyStore + { + private readonly AAuthKey _key; + public InMemoryKeyStore(AAuthKey key) => _key = key; + public Task LoadAsync(string handle, System.Threading.CancellationToken ct = default) + => Task.FromResult(_key); + public Task StoreAsync(string handle, IAAuthKey key, System.Threading.CancellationToken ct = default) + => Task.CompletedTask; + public Task DeleteAsync(string handle, System.Threading.CancellationToken ct = default) + => Task.CompletedTask; + public Task ListAsync(System.Threading.CancellationToken ct = default) + => Task.FromResult(Array.Empty()); + } +} diff --git a/tests/AAuth.Tests/HttpSig/SelfIssuingBuilderTests.cs b/tests/AAuth.Tests/HttpSig/SelfIssuingBuilderTests.cs new file mode 100644 index 0000000..219645a --- /dev/null +++ b/tests/AAuth.Tests/HttpSig/SelfIssuingBuilderTests.cs @@ -0,0 +1,269 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Crypto; +using AAuth.HttpSig; +using AAuth.Tokens; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace AAuth.Tests.HttpSig; + +public class SelfIssuingBuilderTests +{ + private readonly AAuthKey _key = AAuthKey.Generate(); + private const string Issuer = "http://localhost:5000"; + private const string Subject = "aauth:my-svc@localhost:5000"; + private const string PersonServer = "http://localhost:5100"; + + [Fact] + public async Task SelfIssuing_builds_working_client() + { + var stub = new StubHandler(); + using var client = AAuthClientBuilder.SelfIssuing(_key) + .As(Issuer, Subject) + .WithInnerHandler(stub) + .Build(); + + await client.GetAsync("http://localhost:9999/test"); + + Assert.NotNull(stub.LastRequest); + Assert.True(stub.LastRequest!.Headers.Contains("Signature")); + Assert.True(stub.LastRequest.Headers.Contains("Signature-Input")); + Assert.True(stub.LastRequest.Headers.Contains("Signature-Key")); + } + + [Fact] + public async Task SelfIssuing_creates_valid_jwt_claims() + { + var stub = new StubHandler(); + using var client = AAuthClientBuilder.SelfIssuing(_key) + .As(Issuer, Subject) + .WithInnerHandler(stub) + .Build(); + + await client.GetAsync("http://localhost:9999/test"); + + var sigKey = stub.LastRequest!.Headers.GetValues("Signature-Key"); + var headerValue = string.Join("", sigKey); + Assert.Contains("sig=jwt", headerValue); + + var jwt = ExtractJwt(headerValue); + var payload = ReadPayload(jwt); + + Assert.Equal(Issuer, (string?)payload["iss"]); + Assert.Equal(Subject, (string?)payload["sub"]); + } + + [Fact] + public async Task SelfIssuing_with_person_server_sets_ps_claim() + { + var stub = new StubHandler(); + using var client = AAuthClientBuilder.SelfIssuing(_key) + .As(Issuer, Subject) + .WithPersonServer(PersonServer) + .WithInnerHandler(stub) + .Build(); + + await client.GetAsync("http://localhost:9999/test"); + + var sigKey = stub.LastRequest!.Headers.GetValues("Signature-Key"); + var headerValue = string.Join("", sigKey); + var jwt = ExtractJwt(headerValue); + var payload = ReadPayload(jwt); + + Assert.Equal(PersonServer, (string?)payload["ps"]); + } + + [Fact] + public async Task SelfIssuing_with_kid_uses_custom_kid() + { + var stub = new StubHandler(); + using var client = AAuthClientBuilder.SelfIssuing(_key) + .As(Issuer, Subject) + .WithKid("custom-kid-1") + .WithInnerHandler(stub) + .Build(); + + await client.GetAsync("http://localhost:9999/test"); + + var sigKey = stub.LastRequest!.Headers.GetValues("Signature-Key"); + var headerValue = string.Join("", sigKey); + var jwt = ExtractJwt(headerValue); + var header = ReadHeader(jwt); + Assert.Equal("custom-kid-1", (string?)header["kid"]); + } + + [Fact] + public async Task SelfIssuing_kid_defaults_to_thumbprint() + { + var stub = new StubHandler(); + using var client = AAuthClientBuilder.SelfIssuing(_key) + .As(Issuer, Subject) + .WithInnerHandler(stub) + .Build(); + + await client.GetAsync("http://localhost:9999/test"); + + var sigKey = stub.LastRequest!.Headers.GetValues("Signature-Key"); + var headerValue = string.Join("", sigKey); + var jwt = ExtractJwt(headerValue); + var header = ReadHeader(jwt); + Assert.Equal(_key.ComputeJwkThumbprint(), (string?)header["kid"]); + } + + [Fact] + public void SelfIssuing_throws_on_null_key() + { + Assert.Throws(() => AAuthClientBuilder.SelfIssuing(null!)); + } + + [Fact] + public void As_throws_on_null_issuer() + { + var builder = AAuthClientBuilder.SelfIssuing(_key); + Assert.Throws(() => builder.As(null!, Subject)); + } + + [Fact] + public void As_throws_on_empty_issuer() + { + var builder = AAuthClientBuilder.SelfIssuing(_key); + Assert.Throws(() => builder.As("", Subject)); + } + + [Fact] + public void As_throws_on_null_subject() + { + var builder = AAuthClientBuilder.SelfIssuing(_key); + Assert.Throws(() => builder.As(Issuer, null!)); + } + + [Fact] + public void Build_throws_without_As() + { + var builder = AAuthClientBuilder.SelfIssuing(_key); + Assert.Throws(() => builder.Build()); + } + + [Fact] + public void WithKid_throws_on_empty() + { + var builder = AAuthClientBuilder.SelfIssuing(_key); + Assert.Throws(() => builder.WithKid("")); + } + + [Fact] + public async Task SelfIssuing_with_challenge_handling_builds_client() + { + var stub = new StubHandler(); + using var client = AAuthClientBuilder.SelfIssuing(_key) + .As(Issuer, Subject) + .WithChallengeHandling(PersonServer) + .WithInnerHandler(stub) + .Build(); + + await client.GetAsync("http://localhost:9999/test"); + + Assert.NotNull(stub.LastRequest); + Assert.True(stub.LastRequest!.Headers.Contains("Signature")); + } + + [Fact] + public async Task SelfIssuing_creates_full_jwt_claims() + { + var stub = new StubHandler(); + using var client = AAuthClientBuilder.SelfIssuing(_key) + .As(Issuer, Subject) + .WithInnerHandler(stub) + .Build(); + + await client.GetAsync("http://localhost:9999/test"); + + var sigKey = stub.LastRequest!.Headers.GetValues("Signature-Key"); + var headerValue = string.Join("", sigKey); + var jwt = ExtractJwt(headerValue); + var payload = ReadPayload(jwt); + + Assert.Equal("aauth-agent.json", (string?)payload["dwk"]); + Assert.NotNull(payload["cnf"]); + Assert.NotNull(payload["exp"]); + Assert.NotNull(payload["iat"]); + } + + [Fact] + public void As_throws_on_empty_subject() + { + var builder = AAuthClientBuilder.SelfIssuing(_key); + Assert.Throws(() => builder.As(Issuer, "")); + } + + [Fact] + public void Explicit_ps_in_challenge_overrides_stored() + { + using var client = AAuthClientBuilder.SelfIssuing(_key) + .As(Issuer, Subject) + .WithPersonServer(PersonServer) + .WithChallengeHandling("http://localhost:6000") + .WithInnerHandler(new StubHandler()) + .Build(); + + Assert.NotNull(client); + } + + [Fact] + public async Task Existing_WithTokenRefresh_still_works() + { + var stub = new StubHandler(); + var refresher = new SelfIssuedTokenRefresher( + _key, Issuer, Subject, _key.ComputeJwkThumbprint()); + + using var client = new AAuthClientBuilder(_key) + .WithTokenRefresh(refresher) + .WithInnerHandler(stub) + .Build(); + + await client.GetAsync("http://localhost:9999/test"); + + Assert.NotNull(stub.LastRequest); + Assert.True(stub.LastRequest!.Headers.Contains("Signature")); + } + + private static string ExtractJwt(string signatureKeyHeader) + { + // Format: sig=jwt;jwt="eyJ..." + var start = signatureKeyHeader.IndexOf("jwt=\"", StringComparison.Ordinal) + 5; + var end = signatureKeyHeader.IndexOf('"', start); + return signatureKeyHeader[start..end]; + } + + private static JsonNode ReadPayload(string jwt) + { + var parts = jwt.Split('.'); + var bytes = Base64UrlEncoder.DecodeBytes(parts[1]); + return JsonNode.Parse(bytes) ?? new JsonObject(); + } + + private static JsonObject ReadHeader(string jwt) + { + var parts = jwt.Split('.'); + var bytes = Base64UrlEncoder.DecodeBytes(parts[0]); + return JsonNode.Parse(bytes) as JsonObject ?? new JsonObject(); + } + + private sealed class StubHandler : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } +} diff --git a/tests/AAuth.Tests/Server/AAuthHttpContextExtensionsTests.cs b/tests/AAuth.Tests/Server/AAuthHttpContextExtensionsTests.cs new file mode 100644 index 0000000..6a6092f --- /dev/null +++ b/tests/AAuth.Tests/Server/AAuthHttpContextExtensionsTests.cs @@ -0,0 +1,136 @@ +using AAuth; +using AAuth.Crypto; +using AAuth.Headers; +using AAuth.HttpSig; +using AAuth.Server; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace AAuth.Tests.Server; + +public class AAuthHttpContextExtensionsTests +{ + [Fact] + public void GetAAuthVerification_ReturnsNull_WhenMiddlewareNotRun() + { + var ctx = new DefaultHttpContext(); + Assert.Null(ctx.GetAAuthVerification()); + } + + [Fact] + public void GetAAuthVerification_ReturnsResult_WhenSetInFeatures() + { + var ctx = new DefaultHttpContext(); + var expected = new AAuthVerificationResult + { + Level = AAuthLevel.Identified, + Scheme = AAuthConstants.Schemes.Jwt, + TokenType = AAuthTokenType.AgentToken, + }; + ctx.Features.Set(expected); + + var actual = ctx.GetAAuthVerification(); + Assert.Same(expected, actual); + } + + [Fact] + public void GetAAuthParsedKey_ReturnsNull_WhenMiddlewareNotRun() + { + var ctx = new DefaultHttpContext(); + Assert.Null(ctx.GetAAuthParsedKey()); + } + + [Fact] + public void GetAAuthParsedKey_ReturnsParsedInfo_WhenSetInItems() + { + var ctx = new DefaultHttpContext(); + var expected = new SignatureKeyParser.ParsedSignatureKeyInfo + { + Scheme = AAuthConstants.Schemes.Hwk, + Jkt = "test-thumbprint", + }; + ctx.Items[AAuthVerificationMiddleware.ParsedInfoItemKey] = expected; + + var actual = ctx.GetAAuthParsedKey(); + Assert.Same(expected, actual); + } + + [Fact] + public void GetAAuthResult_ReturnsNull_WhenMiddlewareNotRun() + { + var ctx = new DefaultHttpContext(); + Assert.Null(ctx.GetAAuthResult()); + } + + [Fact] + public void GetAAuthResult_ReturnsResult_WhenSetInItems() + { + var ctx = new DefaultHttpContext(); + var expected = new VerificationResult + { + Scheme = AAuthConstants.Schemes.Jwt, + TokenType = "aa-agent+jwt", + }; + ctx.Items[AAuthVerificationMiddleware.ContextItemKey] = expected; + + var actual = ctx.GetAAuthResult(); + Assert.Same(expected, actual); + } + + // ----------------------------------------------------------------------- + // GetAAuthTokenType + // ----------------------------------------------------------------------- + + [Fact] + public void GetAAuthTokenType_ReturnsUnknown_WhenMiddlewareNotRun() + { + var ctx = new DefaultHttpContext(); + Assert.Equal(AAuthTokenType.Unknown, ctx.GetAAuthTokenType()); + } + + [Fact] + public void GetAAuthTokenType_ReturnsTokenType_FromVerificationResult() + { + var ctx = new DefaultHttpContext(); + ctx.Features.Set(new AAuthVerificationResult + { + Level = AAuthLevel.Identified, + Scheme = AAuthConstants.Schemes.Jwt, + TokenType = AAuthTokenType.AuthToken, + }); + + Assert.Equal(AAuthTokenType.AuthToken, ctx.GetAAuthTokenType()); + } + + // ----------------------------------------------------------------------- + // ChallengeAAuth + // ----------------------------------------------------------------------- + + [Fact] + public void ChallengeAAuth_SetsHeaderAndReturns401() + { + var ctx = new DefaultHttpContext(); + var result = ctx.ChallengeAAuth("eyJ.test.sig"); + + Assert.Equal( + AAuthRequirementHeader.FormatAuthToken("eyJ.test.sig"), + ctx.Response.Headers[AAuthConstants.Headers.AAuthRequirement].ToString()); + + var statusResult = Assert.IsAssignableFrom(result); + Assert.Equal(StatusCodes.Status401Unauthorized, statusResult.StatusCode); + } + + // ----------------------------------------------------------------------- + // SetAAuthError + // ----------------------------------------------------------------------- + + [Fact] + public void SetAAuthError_SetsHeader() + { + var ctx = new DefaultHttpContext(); + ctx.SetAAuthError("something went wrong"); + + Assert.Equal("something went wrong", + ctx.Response.Headers[AAuthConstants.Headers.AAuthError].ToString()); + } +}