From 1d2d8ca0052ffd15e1cf62b483d88646eb55a47c Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Thu, 1 Oct 2026 16:31:37 +0000 Subject: [PATCH 1/5] fix(samples): align LiveWhoAmITest with draft-11 person-token flow - Mode 2b now expects requirement=person-token for a scoped request carrying only an agent token (draft-11 issues the resource token after a person token), so Mode 3 is no longer skipped. - Use a live egress policy admitting person.hello.coop's cross-origin jwks_uri (issuer.hello.coop) instead of the localhost-only SampleEgress. - Report token verification failures in Mode 3 instead of crashing. Mode 3 remains blocked upstream: whoami.aauth.dev still emits person_token_jti instead of the required presented_jti (aauth-dev/whoami#5). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LiveWhoAmITest/LiveInteropValidation.cs | 19 ++------- samples/LiveWhoAmITest/Program.cs | 39 ++++++++++++------- samples/README.md | 4 +- .../AAuth.Tests/LiveInteropValidationTests.cs | 23 +++++------ 4 files changed, 41 insertions(+), 44 deletions(-) diff --git a/samples/LiveWhoAmITest/LiveInteropValidation.cs b/samples/LiveWhoAmITest/LiveInteropValidation.cs index b891c926..214de335 100644 --- a/samples/LiveWhoAmITest/LiveInteropValidation.cs +++ b/samples/LiveWhoAmITest/LiveInteropValidation.cs @@ -1,7 +1,6 @@ using System.Net; using System.Text.Json.Nodes; using AAuth.Headers; -using Microsoft.IdentityModel.Tokens; namespace LiveWhoAmITest; @@ -19,11 +18,12 @@ public static bool IsAgentIdentityResponse(HttpStatusCode status, string body, && StringValue(json, "sub") == expectedSubject && StringValue(json, "ps") == expectedPersonServer; - public static bool IsAuthTokenChallenge(HttpStatusCode status, + // Draft-11: a resource answers a scoped request carrying only an agent token + // with requirement=person-token; the resource token follows a person token. + public static bool IsPersonTokenChallenge(HttpStatusCode status, AAuthRequirementHeader.ParsedRequirement? requirement) => status == HttpStatusCode.Unauthorized - && requirement?.Requirement == AAuthRequirementHeader.AuthTokenRequirement - && IsCompactJws(requirement.ResourceToken); + && requirement?.Requirement == AAuthRequirementHeader.PersonTokenRequirement; public static bool IsAuthorizedIdentityResponse(HttpStatusCode status, string body) => status == HttpStatusCode.OK @@ -31,17 +31,6 @@ public static bool IsAuthorizedIdentityResponse(HttpStatusCode status, string bo && !string.IsNullOrWhiteSpace(StringValue(json, "iss")) && !string.IsNullOrWhiteSpace(StringValue(json, "sub")); - private static bool IsCompactJws(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return false; - var segments = value.Split('.'); - if (segments.Length != 3 || segments.Any(segment => segment.Length == 0 - || segment.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not ('-' or '_')))) - return false; - try { return segments.All(segment => Base64UrlEncoder.DecodeBytes(segment).Length > 0); } - catch (FormatException) { return false; } - } - private static JsonObject? ParseObject(string body) { try { return JsonNode.Parse(body) as JsonObject; } diff --git a/samples/LiveWhoAmITest/Program.cs b/samples/LiveWhoAmITest/Program.cs index 4812a8bd..6a6cffb1 100644 --- a/samples/LiveWhoAmITest/Program.cs +++ b/samples/LiveWhoAmITest/Program.cs @@ -8,7 +8,7 @@ // // Mode 1: No signature → 401 + Accept-Signature-Scheme / Accept-Signature-Alg headers // Mode 2a: aa-agent+jwt (no scope) → 200 + agent identity (sub echoed back) -// Mode 2b: aa-agent+jwt (scope) → 401 + AAuth-Requirement (resource token) +// Mode 2b: aa-agent+jwt (scope) → 401 + AAuth-Requirement: requirement=person-token // Mode 3: Full 3-party flow → 200 + identity claims (via PS exchange) // // Architecture: @@ -44,6 +44,12 @@ const string Subject = "aauth:live-test@dotnet-samples"; const int LocalPort = 5199; +// person.hello.coop publishes its jwks_uri on issuer.hello.coop; cross-origin +// JWKS must be admitted explicitly per (metadata issuer, JWKS origin) pair. +var liveEgress = new AAuth.Discovery.AAuthEgressPolicy( + crossOriginJwks: [(PersonServer, "https://issuer.hello.coop")], + requestTimeout: TimeSpan.FromSeconds(45)); + Console.WriteLine("╔══════════════════════════════════════════════════════════════╗"); Console.WriteLine("║ Live WhoAmI Test — All 3 Protocol Modes ║"); Console.WriteLine("║ Resource: whoami.aauth.dev ║"); @@ -176,7 +182,7 @@ Console.WriteLine(); // Build a client without challenge handling — unscoped requests get 200 directly -using var mode2aClient = AAuthClientBuilder.SelfIssuing(agentKey).WithEgressPolicy(SampleEgress.Policy) +using var mode2aClient = AAuthClientBuilder.SelfIssuing(agentKey).WithEgressPolicy(liveEgress) .As(tunnelUrl!, Subject) .WithKid(agentKid) .WithPersonServer(PersonServer) @@ -212,7 +218,7 @@ Console.WriteLine(); // Build a client WITHOUT challenge handling so we see the raw 401 + resource_token -using var mode2bClient = AAuthClientBuilder.SelfIssuing(agentKey).WithEgressPolicy(SampleEgress.Policy) +using var mode2bClient = AAuthClientBuilder.SelfIssuing(agentKey).WithEgressPolicy(liveEgress) .As(tunnelUrl!, Subject) .WithKid(agentKid) .WithPersonServer(PersonServer) @@ -239,19 +245,18 @@ var mode2bBody = await mode2bResp.Content.ReadAsStringAsync(); Console.WriteLine($" Body: {mode2bBody}"); Console.WriteLine(); -var mode2bPassed = LiveInteropValidation.IsAuthTokenChallenge(mode2bResp.StatusCode, mode2bRequirement); +var mode2bPassed = LiveInteropValidation.IsPersonTokenChallenge(mode2bResp.StatusCode, mode2bRequirement); if (mode2bPassed) { - Console.WriteLine(" → Resource verified our agent token via our tunneled JWKS,"); - Console.WriteLine(" read the 'ps' claim (person.hello.coop), and minted a resource_token"); - Console.WriteLine(" audienced to the PS. Agent takes this to the PS to get an auth_token."); + Console.WriteLine(" → Resource verified our agent token via our tunneled JWKS and asked for a"); + Console.WriteLine(" person token. The agent gets one from the PS (person.hello.coop) and"); + Console.WriteLine(" presents it; the resource then issues the resource_token (see Mode 3)."); } else { - Console.WriteLine(" ✗ Draft-11 auth-token challenge was not returned."); - Console.WriteLine(" Expected: requirement=auth-token; resource-token=\"\""); + Console.WriteLine(" ✗ Draft-11 person-token challenge was not returned."); + Console.WriteLine(" Expected: requirement=person-token"); Console.WriteLine($" Received: requirement={mode2bRequirement?.Requirement ?? "(missing or malformed)"}"); - Console.WriteLine(" The client leaves this unsupported requirement unsatisfied and does not contact the PS."); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -262,11 +267,12 @@ Console.WriteLine("MODE 3: aa-auth+jwt — full 3-party flow (automated)"); Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); Console.WriteLine(); -Console.WriteLine(" Flow: agent_token → 401/resource_token → PS exchange → auth_token → 200"); +Console.WriteLine(" Flow: agent_token → 401/person-token → PS person_token → 401/resource_token"); +Console.WriteLine(" → PS exchange → auth_token → 200"); Console.WriteLine(" Using live PS at person.hello.coop (may require user consent)"); Console.WriteLine(); -using var mode3Client = AAuthClientBuilder.SelfIssuing(agentKey).WithEgressPolicy(SampleEgress.Policy) +using var mode3Client = AAuthClientBuilder.SelfIssuing(agentKey).WithEgressPolicy(liveEgress) .As(tunnelUrl!, Subject) .WithKid(agentKid) .WithPersonServer(PersonServer) @@ -301,7 +307,7 @@ string? mode3Body = null; if (!mode2bPassed) { - Console.WriteLine(" SKIPPED: The resource did not issue the draft-11 resource token required for PS exchange."); + Console.WriteLine(" SKIPPED: The resource did not issue the draft-11 person-token challenge."); } else try { @@ -325,6 +331,13 @@ Console.WriteLine(" person.hello.coop. The PS would then send you a push/redirect"); Console.WriteLine(" for consent, and return an auth_token with your identity claims."); } +catch (AAuth.Tokens.TokenVerificationException ex) +{ + Console.WriteLine(); + Console.WriteLine($" Token verification error: {ex.Message}"); + Console.WriteLine(" The agent rejected a token the resource or PS returned as non-conformant"); + Console.WriteLine(" with draft-11, so it did not continue the exchange."); +} catch (HttpRequestException ex) { Console.WriteLine(); diff --git a/samples/README.md b/samples/README.md index 92c438f1..3086f439 100644 --- a/samples/README.md +++ b/samples/README.md @@ -373,8 +373,8 @@ dotnet run --project samples/LiveWhoAmITest Live interop test that runs against the public reference servers (`whoami.aauth.dev` and `person.hello.coop`) instead of the local mocks. It generates an agent key, starts a local metadata + JWKS endpoint on port 5199, exposes it via a `cloudflared` quick tunnel, and exercises three public checks: - **Mode 1** — unsigned request returns `401` + `Accept-Signature-Scheme` / `Accept-Signature-Alg`. -- **Mode 2** — `aa-agent+jwt` returns the agent identity (no scope) or a `401` + `AAuth-Requirement` resource token (scoped). -- **Mode 3** — full three-party flow: agent token → resource token → PS exchange → auth token → identity claims. +- **Mode 2** — `aa-agent+jwt` returns the agent identity (no scope) or a `401` + `AAuth-Requirement: requirement=person-token` (scoped). +- **Mode 3** — full three-party flow: agent token → person token from the PS → resource token → PS exchange → auth token → identity claims. Requires `cloudflared` on the `PATH` (preinstalled in the dev container) and outbound network access. Mode 3 may prompt for user consent at `person.hello.coop`; the agent prints the interaction URL to approve in a browser. diff --git a/tests/AAuth.Tests/LiveInteropValidationTests.cs b/tests/AAuth.Tests/LiveInteropValidationTests.cs index 4fd54d49..8c2b7939 100644 --- a/tests/AAuth.Tests/LiveInteropValidationTests.cs +++ b/tests/AAuth.Tests/LiveInteropValidationTests.cs @@ -36,26 +36,21 @@ public void AgentIdentityAcceptsExactExpectedPayload() "https://agent.example", "aauth:test@agent.example", "https://ps.example")); [Theory] - [InlineData("requirement=person-token")] [InlineData("requirement=auth-token")] - [InlineData("requirement=auth-token; resource-token=\"\"")] - [InlineData("requirement=auth-token; resource-token=\"a.b\"")] - [InlineData("requirement=auth-token; resource-token=\"a..c\"")] - [InlineData("requirement=auth-token; resource-token=\"a.b.$\"")] - [InlineData("requirement=auth-token; resource-token=\"a.b.*\"")] - [InlineData("requirement=auth-token; resource-token=\"a.a.a\"")] - public void AuthTokenChallengeRejectsLegacyMissingOrMalformedTokens(string header) - => Assert.False(LiveInteropValidation.IsAuthTokenChallenge(HttpStatusCode.Unauthorized, + [InlineData("requirement=auth-token; resource-token=\"eyJ9.e30.c2ln\"")] + [InlineData("requirement=agent-token")] + public void PersonTokenChallengeRejectsOtherRequirements(string header) + => Assert.False(LiveInteropValidation.IsPersonTokenChallenge(HttpStatusCode.Unauthorized, AAuthRequirementHeader.Parse(header))); [Fact] - public void AuthTokenChallengeRequiresUnauthorizedAndCompactJws() + public void PersonTokenChallengeRequiresUnauthorized() { - var requirement = AAuthRequirementHeader.Parse( - "requirement=auth-token; resource-token=\"eyJ9.e30.c2ln\""); + var requirement = AAuthRequirementHeader.Parse("requirement=person-token"); - Assert.True(LiveInteropValidation.IsAuthTokenChallenge(HttpStatusCode.Unauthorized, requirement)); - Assert.False(LiveInteropValidation.IsAuthTokenChallenge(HttpStatusCode.OK, requirement)); + Assert.True(LiveInteropValidation.IsPersonTokenChallenge(HttpStatusCode.Unauthorized, requirement)); + Assert.False(LiveInteropValidation.IsPersonTokenChallenge(HttpStatusCode.OK, requirement)); + Assert.False(LiveInteropValidation.IsPersonTokenChallenge(HttpStatusCode.Unauthorized, null)); } [Theory] From d1b892e9a8e2ff00a7218e1e6576b007d3ee3a7c Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Thu, 1 Oct 2026 16:36:44 +0000 Subject: [PATCH 2/5] test: regenerate docs inventory snapshot; add AGENTS.md samples/README.md changed in the previous commit, so the frozen docs inventory hash was stale. AGENTS.md records which files the inventory covers and how to regenerate the snapshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 21 +++++++++++++++++++ .../Api/DocumentationInventory.snapshot.md | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e8c28f2c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# Agent Instructions + +## Documentation inventory snapshot + +The documentation inventory test +(`SnippetCompilationTests.Documentation_FrozenSurface`) hashes every file it +covers into +[`tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md`](tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md). +CI fails when that snapshot is stale. Covered files are: + +- the root `README.md`; +- every `*.md` under `docs/`, `src/` and `samples/` (including sample READMEs); +- `*.cs`, `*.razor` and `*.ts` under `samples/GuidedTour/`, `samples/SampleApp/`, + `samples/CapabilitySupport/` and `samples/EventSupport/`. + +After changing any of these, regenerate the snapshot, review its diff and commit +it in the same change: + +```bash +AAUTH_UPDATE_DOCS_INVENTORY=1 dotnet test tests/AAuth.Tests --filter "FullyQualifiedName~Documentation_FrozenSurface" +``` diff --git a/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md b/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md index 84106661..8b48ce01 100644 --- a/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md +++ b/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md @@ -159,7 +159,7 @@ documentation change. | [samples/MockResourceServers/README.md](../../../samples/MockResourceServers/README.md) | `384560afe7b5ac16ae5377e86084865d1d20103adc5905b3c53af9d8ac2d4ce0` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/Trips/README.md](../../../samples/MockResourceServers/Trips/README.md) | `cfc34f623eef77ec41c54959f1f7c13f251c0679e5956f7b4e620e457c800666` | 1 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/Wallet/README.md](../../../samples/MockResourceServers/Wallet/README.md) | `211b19afe98ef47d20c0a61de690db2ec4b1fe722e6d3ac3e4e4bdeb5d1f948d` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/README.md](../../../samples/README.md) | `1bd1e1a46c3ac04072a577197afd1e687f9d0a50cef7f09c016b6e6748e3ae0b` | 25 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/README.md](../../../samples/README.md) | `e80c260496b3da91283063653658c36a50c95721ce8a23a639378b8671455454` | 25 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/_Imports.razor](../../../samples/SampleApp/Components/_Imports.razor) | `b7b03c630e075d1c783acff669ceb9e9de268daf31740a988a4f5945f477c030` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/App.razor](../../../samples/SampleApp/Components/App.razor) | `e28bc9f11a4329d2689a64520db6550c34aaf9c042c478ef46b88398fe6e707a` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/ConsentProgress.razor](../../../samples/SampleApp/Components/ConsentProgress.razor) | `7b525cdb5ea92267e0b5ea5d8ff1196694e203ff18459338f8f86f0952143ff4` | 1 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | From 182f45a450ea9ed471daeb6f36bec66db7c261b4 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Thu, 1 Oct 2026 17:33:24 +0000 Subject: [PATCH 3/5] fix(samples): bind demo roles to the person; fix consent display issues Roles are identity claims about the person (RFC 9068/SCIM), so the demo roles no longer depend on a hard-coded agent ID that the Agent Provider can no longer assign (IDs are now aauth:agent-@host): - MockPersonServer asserts calendar.owner, wallet.payer and demo-users for its demo person whichever agent asks. MockPersonServer:GuestPerson=true acts for a person with no roles, to exercise role-based denial. - The Federated stub AS no longer derives roles from the agent ID. For wallet.charge it asks the PS for the person's roles via requirement=claims, and denies when the pushed claims lack wallet.payer. - Keycloak tests now decide by the logged-in user, like the real realm. A denied deferred request now keeps the server's reason: the PS->AS client keeps the AS problem detail, the PS relays it on its own `denied`, and agents include it in AAuthInteractionDeniedException. Display fixes: - PS dashboard and consent pages show an R3 request's r3_uri instead of the PS default scope (calendar.read), which R3 tokens don't carry. - EventAgent prints each consent URL once instead of an extra empty "Consent:" line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/configuration.md | 2 +- samples/AgentConsole/README.md | 4 +- samples/EventAgent/Program.cs | 5 +- .../Federated/Policy/StubAccessPolicy.cs | 24 +++-- .../MockAccessServers/Federated/Program.cs | 13 --- samples/MockAccessServers/Federated/README.md | 7 +- .../ConsentBridgePersonPendingStore.cs | 12 ++- samples/MockPersonServer/ConsentDashboard.cs | 3 +- samples/MockPersonServer/ConsentDisplay.cs | 21 +++++ samples/MockPersonServer/ConsentRegistry.cs | 3 +- samples/MockPersonServer/Program.cs | 31 ++++--- samples/MockPersonServer/README.md | 1 + .../SampleIdentityClaimsAsserter.cs | 27 ++---- .../MockResourceServers/Calendar/README.md | 10 ++- samples/MockResourceServers/Wallet/README.md | 9 +- samples/README.md | 4 +- src/AAuth/Access/AccessServerClient.cs | 9 +- src/AAuth/Agent/AAuthInteractionExceptions.cs | 30 +++++++ src/AAuth/Agent/DeferredExchange.cs | 3 +- src/AAuth/Agent/TokenExchangeClient.cs | 5 +- .../Person/AAuthPersonServerEndpoints.cs | 4 +- .../Agent/DeferredExchangeTests.cs | 21 +++++ .../Api/DocumentationInventory.snapshot.md | 30 +++---- .../Integration/CalendarFlowTests.cs | 30 +++++-- .../MockAccessServerKeycloakTests.cs | 79 +++++------------ .../Integration/MockAccessServerTests.cs | 87 +++++++++++-------- .../SampleIdentityClaimsAsserterTests.cs | 49 +++++------ 27 files changed, 296 insertions(+), 227 deletions(-) create mode 100644 samples/MockPersonServer/ConsentDisplay.cs diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 6f414757..b678a953 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -175,7 +175,7 @@ An `IAccessPolicy` is required (`UsePolicy` or a DI registration). | `TokenPath` | `string` | `/token` | Auth token endpoint path (`auth_token_endpoint`) | | `RevocationPath` | `string` | `/revoke` | Revocation endpoint path (`revocation_endpoint`) | | `ConfigureRevocation` | `Action?` | `null` | *Code-only.* Adjusts the mapped revocation endpoint | -| `DeriveAgentClaims` | `Func?` | `null` | *Code-only.* Baseline policy claims derived from the verified agent id (demo convention; production uses the §Claims Required push) | +| `DeriveAgentClaims` | `Func?` | `null` | *Code-only.* Baseline policy claims derived from the verified agent id. Use it only for facts about the agent; identity claims about the person (`roles`, `groups`, `tenant`) come from the PS through the §Claims Required push | | `PendingPathPrefix` | `string` | `/pending` | Deferred-decision poll path prefix | | `DefaultScope` | `string` | `""` | Scope assumed when the resource token omits one | | `InteractionLoginPath` | `string` | `/interaction/login` | Browser entry point for interactive policies | diff --git a/samples/AgentConsole/README.md b/samples/AgentConsole/README.md index bde47328..150f1a07 100644 --- a/samples/AgentConsole/README.md +++ b/samples/AgentConsole/README.md @@ -84,12 +84,12 @@ dotnet run --project samples/AgentConsole -- \ http://localhost:5001/events/write --ap http://localhost:5301 \ --ps http://localhost:5100 --signing-mode jwt -# Three-party, RBAC — PS asserts roles ["calendar.owner"], groups ["demo-users"] +# Three-party, RBAC — PS asserts its demo person's roles ["calendar.owner", "wallet.payer"], groups ["demo-users"] dotnet run --project samples/AgentConsole -- \ http://localhost:5001/events/admin --ap http://localhost:5301 \ --ps http://localhost:5100 --signing-mode jwt -# Four-party payment — scope "wallet.charge" (Access Server requires the wallet.payer role) +# Four-party payment — scope "wallet.charge" (the Access Server asks the PS for the person's roles and requires wallet.payer) dotnet run --project samples/AgentConsole -- \ http://localhost:5003/wallet/charge --ap http://localhost:5301 \ --ps http://localhost:5100 --signing-mode jwt diff --git a/samples/EventAgent/Program.cs b/samples/EventAgent/Program.cs index 7fb48517..7ab67082 100644 --- a/samples/EventAgent/Program.cs +++ b/samples/EventAgent/Program.cs @@ -10,9 +10,12 @@ Protected = args.Contains("--protected", StringComparer.Ordinal), Account = args.Contains("--work", StringComparer.Ordinal) ? "work" : "personal" }; +string? shownConsent = null; session.Changed = () => { - Console.WriteLine("Consent: " + session.ConsentUrl); + // Changed also fires when the consent clears; only announce a new URL. + if (session.ConsentUrl is { } url && url != shownConsent) Console.WriteLine("Consent: " + url); + shownConsent = session.ConsentUrl; return Task.CompletedTask; }; Console.WriteLine("Events single-shot demo; AP polling and event trigger are local sample APIs."); diff --git a/samples/MockAccessServers/Federated/Policy/StubAccessPolicy.cs b/samples/MockAccessServers/Federated/Policy/StubAccessPolicy.cs index 2f874c7c..de5719a1 100644 --- a/samples/MockAccessServers/Federated/Policy/StubAccessPolicy.cs +++ b/samples/MockAccessServers/Federated/Policy/StubAccessPolicy.cs @@ -18,7 +18,9 @@ namespace MockAccessServer.Policy; /// /// any verified agent may obtain the base wallet.read scope; /// the elevated wallet.charge scope is granted only when the -/// PS-asserted claims carry the wallet.payer role. +/// person carries the wallet.payer role. Roles are identity claims +/// about the person, so the AS asks the PS for them (§Claims Required, +/// requirement=claims) rather than inferring them from the agent. /// /// /// When requireConsent is set (from AccessServer:RequireConsent) @@ -63,17 +65,27 @@ public Task EvaluateAsync( AccessPolicyRequest request, CancellationToken cancellationToken = default) { if (_walletRules?.Evaluate(request) is { } walletDecision) return Task.FromResult(walletDecision); - // §Claims Required: if the AS is configured to need identity claims it - // does not yet hold, ask the PS to push them before deciding. + var elevated = IsElevatedScope(request.Scope); + + // §Claims Required: before the PS has pushed anything, ask for every claim + // the decision needs: the configured ones, plus `roles` for the elevated scope. + if (request.Claims is null) + { + var needed = elevated ? _requiredClaims.Append("roles").Distinct(StringComparer.Ordinal).ToList() : _requiredClaims; + if (needed.Count > 0) return Task.FromResult(AccessDecision.NeedsClaims(needed)); + } + + // A push that still lacks a configured claim is asked again. var missing = MissingClaims(request.Claims); if (missing.Count > 0) { return Task.FromResult(AccessDecision.NeedsClaims(missing)); } - // An elevated scope requires the payer role; the base scope is - // open to any verified agent. - if (IsElevatedScope(request.Scope) && !HasRole(request.Claims, AdminRole)) + // An elevated scope requires the payer role among the claims the PS + // provided; a person without it (or without any roles) is denied rather + // than asked again. The base scope is open to any verified agent. + if (elevated && !HasRole(request.Claims, AdminRole)) { return Task.FromResult(AccessDecision.Deny( $"scope '{request.Scope}' requires the '{AdminRole}' role")); diff --git a/samples/MockAccessServers/Federated/Program.cs b/samples/MockAccessServers/Federated/Program.cs index 77a77489..484a7402 100644 --- a/samples/MockAccessServers/Federated/Program.cs +++ b/samples/MockAccessServers/Federated/Program.cs @@ -75,12 +75,6 @@ options.SigningKeys = new AAuthSigningKeySet(AsKid, AAuthKey.Generate()); options.DefaultScope = AsScope; options.InteractionLoginPath = "/interaction/login"; - // Demo convention: the exact agent id `aauth:demo@ap.example` is treated - // as holding the admin role. A production AS would receive the principal's - // directory membership via the PS's §Claims Required push. - options.DeriveAgentClaims = agentId => IsAdminAgent(agentId) - ? new JsonObject { ["roles"] = new JsonArray(StubAccessPolicy.AdminRole) } - : null; }) .WithTrust(trust => trust.PersonServers.Allowed = new HashSet(trustedPersonServers)); @@ -341,13 +335,6 @@ static string InteractionHtml(string title, string body) => ConsentHtml.Page(title, $"

{System.Net.WebUtility.HtmlEncode(title)}

{System.Net.WebUtility.HtmlEncode(body)}

"); -// Demo convention shared with MockPersonServer: the exact agent identifier -// `aauth:demo@ap.example` is treated as holding the admin role. The match is exact, -// never a prefix, so `aauth:demo@attacker.example` gets nothing. A production AS -// would receive the principal's directory membership via the PS's claim push. -static bool IsAdminAgent(string agentId) => - string.Equals(agentId, "aauth:demo@ap.example", StringComparison.Ordinal); - // ----------------------------------------------------------------------- // Access Server consent-screen HTML. Mirrors the MockPersonServer consent // screen's shape, but with an unmistakable **Access Server** identity banner diff --git a/samples/MockAccessServers/Federated/README.md b/samples/MockAccessServers/Federated/README.md index e55dba39..c7aa40fd 100644 --- a/samples/MockAccessServers/Federated/README.md +++ b/samples/MockAccessServers/Federated/README.md @@ -25,9 +25,10 @@ evaluates policy and mints the auth token. `aud` = this AS — the discriminator that distinguishes four-party from three-party. 5. Evaluates access policy through a pluggable `IAccessPolicy`: - - `stub` (default) — a hard-coded allow policy that denies elevated - (`:`-qualified) scopes to non-admin agents, can require identity - claims (§Claims Required) via `AccessServer:RequireClaims`, and can + - `stub` (default) — a hard-coded allow policy that grants the elevated + `wallet.charge` scope only when the person holds the `wallet.payer` + role, which it asks the PS for (§Claims Required), can require further + identity claims via `AccessServer:RequireClaims`, and can render its own interactive Approve/Deny consent screen via `AccessServer:RequireConsent` (returns `202` `requirement=interaction` until the user decides) — no Docker needed. diff --git a/samples/MockPersonServer/ConsentBridgePersonPendingStore.cs b/samples/MockPersonServer/ConsentBridgePersonPendingStore.cs index 6d680650..982b0078 100644 --- a/samples/MockPersonServer/ConsentBridgePersonPendingStore.cs +++ b/samples/MockPersonServer/ConsentBridgePersonPendingStore.cs @@ -19,11 +19,11 @@ public sealed class ConsentBridgePersonPendingStore : IPersonPendingStore private readonly InMemoryPersonPendingStore _inner = new(); private readonly ConsentStore _consent; private readonly ConsentRegistry _registry; - private readonly IReadOnlyList _demoRoles; - private readonly IReadOnlyList _demoGroups; + private readonly IReadOnlyList? _demoRoles; + private readonly IReadOnlyList? _demoGroups; public ConsentBridgePersonPendingStore( - ConsentStore consent, ConsentRegistry registry, IReadOnlyList demoRoles, IReadOnlyList demoGroups) + ConsentStore consent, ConsentRegistry registry, IReadOnlyList? demoRoles, IReadOnlyList? demoGroups) { _consent = consent; _registry = registry; @@ -53,13 +53,11 @@ public PersonPendingEntry Add( && entry.PendingExpiresAt > DateTimeOffset.UtcNow && _consent.IsConsented(entry.ConsentAgentId, entry.ResourceUrl, entry.Scope, entry.Account, entry.ResourceKeyThumbprint)) { - var isAdmin = entry.OwnerIssuer is not null - && SampleIdentityClaimsAsserter.IsAdminAgent(entry.OwnerIssuer, entry.ConsentAgentId); entry.PersonKey = SampleIdentityClaimsAsserter.DemoPersonKey; entry.Subject = SampleIdentityClaimsAsserter.DirectedSubject(entry.ResourceUrl); entry.Tenant = null; - entry.Roles = isAdmin ? _demoRoles : null; - entry.Groups = isAdmin ? _demoGroups : null; + entry.Roles = _demoRoles; + entry.Groups = _demoGroups; entry.AdditionalClaims = null; entry.Status = PersonPendingStatus.Allowed; _registry.MarkDecided(entry.Id, ConsentDecider.Admin); diff --git a/samples/MockPersonServer/ConsentDashboard.cs b/samples/MockPersonServer/ConsentDashboard.cs index aa4be95e..7fabbd09 100644 --- a/samples/MockPersonServer/ConsentDashboard.cs +++ b/samples/MockPersonServer/ConsentDashboard.cs @@ -195,6 +195,7 @@ private static JsonObject Describe(ConsentRecord record) ["agent"] = record.AgentId, ["resource"] = record.Resource, ["scope"] = record.Scope, + ["r3"] = record.R3Uri, ["account"] = record.Account, ["action"] = record.Action, ["mission_s256"] = record.MissionS256, @@ -321,7 +322,7 @@ function card(r, highlight) { if (r.status !== 'Pending') title.append(' ', el('span', 'pill s-' + r.status, statusLabels[r.status] || r.status)); body.append(title); const dl = el('dl', 'meta'); - row(dl, 'Agent', r.agent, true); row(dl, 'Resource', r.resource, true); row(dl, 'Scope', r.scope, true); + row(dl, 'Agent', r.agent, true); row(dl, 'Resource', r.resource, true); row(dl, 'Scope', r.scope, true); row(dl, 'R3 request', r.r3, true); row(dl, 'Account', r.account, true); row(dl, 'Tool', r.action, true); if (group !== 'mission') row(dl, 'Mission', r.mission); row(dl, 'Tools', r.tools, true); if (r.mission_s256 && group !== 'mission') row(dl, 'Mission s256', r.mission_s256, true); row(dl, 'Requested', when(r.created_at)); diff --git a/samples/MockPersonServer/ConsentDisplay.cs b/samples/MockPersonServer/ConsentDisplay.cs new file mode 100644 index 00000000..66ad19e4 --- /dev/null +++ b/samples/MockPersonServer/ConsentDisplay.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Nodes; +using AAuth.Person; + +namespace MockPersonServer; + +/// +/// What a consent screen should show as the request. An R3 resource token carries +/// r3_uri/r3_s256 in place of scope (R3 §Resource Token Extensions), +/// so the SDK fills with the PS default scope. +/// That default is not what the resource asked for and must not be shown as such. +/// +internal static class ConsentDisplay +{ + public static string? R3Uri(PersonPendingEntry entry) => Text(entry.ResourceContext, "r3_uri"); + + public static string? Scope(PersonPendingEntry entry) => + R3Uri(entry) is not null && string.IsNullOrWhiteSpace(Text(entry.ResourceContext, "scope")) ? null : entry.Scope; + + private static string? Text(JsonObject? document, string name) => + document?[name] is JsonValue value && value.TryGetValue(out var text) ? text : null; +} diff --git a/samples/MockPersonServer/ConsentRegistry.cs b/samples/MockPersonServer/ConsentRegistry.cs index 82311130..f4193441 100644 --- a/samples/MockPersonServer/ConsentRegistry.cs +++ b/samples/MockPersonServer/ConsentRegistry.cs @@ -65,7 +65,8 @@ internal ConsentRecord(MissionPendingEntry entry, MissionPolicyStore policy) public BrowserInteraction Browser => PersonEntry?.Browser ?? MissionEntry!.Browser; public string AgentId => PersonEntry?.ConsentAgentId ?? MissionEntry!.AgentId; public string? Resource => PersonEntry?.ResourceUrl ?? MissionEntry!.Resource; - public string? Scope => PersonEntry is { } entry ? (entry.PersonToken ? null : entry.Scope) : MissionEntry!.Scope; + public string? Scope => PersonEntry is { } entry ? (entry.PersonToken ? null : ConsentDisplay.Scope(entry)) : MissionEntry!.Scope; + public string? R3Uri => PersonEntry is { } entry ? ConsentDisplay.R3Uri(entry) : null; public string? Account => PersonEntry?.Account; public string? Action => MissionEntry?.Action; diff --git a/samples/MockPersonServer/Program.cs b/samples/MockPersonServer/Program.cs index 0273dcf8..ecc3416d 100644 --- a/samples/MockPersonServer/Program.cs +++ b/samples/MockPersonServer/Program.cs @@ -43,17 +43,18 @@ const string PsKid = "ps-1"; const string PsScope = "calendar.read"; // Demo identity claims the mock PS asserts about the user. A production PS -// would resolve these from the signed-in user's directory entry. These let -// the Calendar `/events/admin` (RBAC) endpoint succeed end-to-end. +// would resolve these from the signed-in user's directory entry. // -// Roles/groups are asserted ONLY for recognized "admin" demo agents: the AP -// issuer and agent id must exactly match the configured demo binding. Any other -// agent receives an auth token without the role, so role-based DENIAL is -// exercised end-to-end (a guest agent calling `/events/admin` gets a 403). -// A production PS would resolve the principal's directory membership instead -// of a hard-coded demo binding. -string[] demoRoles = ["calendar.owner"]; -string[] demoGroups = ["demo-users"]; +// `roles` and `groups` are identity claims about the person (RFC 9068 / SCIM), +// so they belong to the demo person, not to whichever agent asks: every auth +// token the PS issues for that person carries them, and the PS releases them +// to an Access Server through the §Claims Required push. `calendar.owner` +// opens Calendar `/events/admin` (RBAC); `wallet.payer` lets the stub AS grant +// `wallet.charge`. Set `MockPersonServer:GuestPerson=true` to act for a guest +// person with no roles or groups, which exercises role-based DENIAL end-to-end. +var guestPerson = builder.Configuration.GetValue("MockPersonServer:GuestPerson"); +string[]? demoRoles = guestPerson ? null : ["calendar.owner", "wallet.payer"]; +string[]? demoGroups = guestPerson ? null : ["demo-users"]; // Identity claims the PS can release for the bound principal when an Access // Server asks for them via the §Claims Required push. A production PS would // resolve these from its identity store keyed by the authenticated principal. @@ -894,7 +895,8 @@ await log.AppendAsync(new MissionLogEntry( // shown apart, and the agent's words are attributed to the agent. + "

From the resource

" + $"
Resource: {System.Net.WebUtility.HtmlEncode(entry.ResourceUrl)}
" - + $"
Scope: {System.Net.WebUtility.HtmlEncode(entry.Scope)}
" + + (ConsentDisplay.Scope(entry) is not { } shownScope ? "" : $"
Scope: {System.Net.WebUtility.HtmlEncode(shownScope)}
") + + (ConsentDisplay.R3Uri(entry) is not { } r3Uri ? "" : $"
R3 request: {System.Net.WebUtility.HtmlEncode(r3Uri)}
") + (entry.Account is null ? "" : $"
Account: {System.Net.WebUtility.HtmlEncode(entry.Account)}
") + "
" + (entry.AgentAsserted is not { } agentSays ? "" : @@ -972,8 +974,11 @@ await log.AppendAsync(new MissionLogEntry( + "
Person Server
" + "

Approved

" + $"

You granted {System.Net.WebUtility.HtmlEncode(entry.AgentId)} access to " - + $"{System.Net.WebUtility.HtmlEncode(entry.ResourceUrl)} with scope " - + $"{System.Net.WebUtility.HtmlEncode(entry.Scope)} at the Person Server.

" + + $"{System.Net.WebUtility.HtmlEncode(entry.ResourceUrl)} " + + (ConsentDisplay.Scope(entry) is { } grantedScope + ? $"with scope {System.Net.WebUtility.HtmlEncode(grantedScope)}" + : $"for the R3 request {System.Net.WebUtility.HtmlEncode(ConsentDisplay.R3Uri(entry))}") + + " at the Person Server.

" + "

You can close this tab — the agent will receive its auth token on its next poll.

", contentType: "text/html"); }); diff --git a/samples/MockPersonServer/README.md b/samples/MockPersonServer/README.md index 0aaf65db..e30e2fb7 100644 --- a/samples/MockPersonServer/README.md +++ b/samples/MockPersonServer/README.md @@ -177,4 +177,5 @@ dotnet run --project samples/AgentConsole -- \ | `AAuth:Issuer` | `http://localhost:5100` | PS issuer URL — must match what agents put in their agent token's `ps` claim | | `AAuth:SignatureWindow` | `60` | RFC 9421 `created` freshness window, in seconds | | `MockPersonServer:RequireConsent` | `false` | When `true`, `POST /token` returns `202 + Location` and the user must approve or deny via `/interaction/{approve,deny}` before the poll resolves. `make demo` sets this to `true`. | +| `MockPersonServer:GuestPerson` | `false` | The PS acts for one demo person who holds roles `calendar.owner` and `wallet.payer` and group `demo-users`, whichever agent asks. When `true`, it acts for a guest person with no roles or groups, so Calendar `/events/admin` returns `403` and the stub AS denies `wallet.charge`. | | `MockPersonServer:TrustedAccessServers` | `["http://localhost:5500"]` | Access Servers this PS will federate to in the four-party flow (resource token `aud` ≠ PS). Any other `aud` is rejected with `untrusted_access_server`. This sample pins one AS explicitly; the SDK default for an unset list is open (`null` ⇒ federate to the verified `aud`'s AS), an empty list is three-party only, and a non-empty list restricts. | diff --git a/samples/MockPersonServer/SampleIdentityClaimsAsserter.cs b/samples/MockPersonServer/SampleIdentityClaimsAsserter.cs index 86af1fa6..d4ad9baa 100644 --- a/samples/MockPersonServer/SampleIdentityClaimsAsserter.cs +++ b/samples/MockPersonServer/SampleIdentityClaimsAsserter.cs @@ -25,15 +25,17 @@ public static string DirectedSubject(string resource) => private readonly ConsentStore _consent; private readonly bool _requireConsent; - private readonly IReadOnlyList _demoRoles; - private readonly IReadOnlyList _demoGroups; + private readonly IReadOnlyList? _demoRoles; + private readonly IReadOnlyList? _demoGroups; private readonly IReadOnlyDictionary _demoUserClaims; + /// The demo person's roles; for a guest person. + /// The demo person's groups; for a guest person. public SampleIdentityClaimsAsserter( ConsentStore consent, bool requireConsent, - IReadOnlyList demoRoles, - IReadOnlyList demoGroups, + IReadOnlyList? demoRoles, + IReadOnlyList? demoGroups, IReadOnlyDictionary demoUserClaims) { _consent = consent; @@ -43,23 +45,12 @@ public SampleIdentityClaimsAsserter( _demoUserClaims = demoUserClaims; } - /// - /// The exact agent identifiers the demo treats as "admin" (AgentConsole). Matching is - /// exact and ordinal: a prefix test would let aauth:demo@attacker.example claim - /// the demo roles. A production PS resolves the bound principal's directory membership. - /// - public static IReadOnlySet<(string Issuer, string AgentId)> AdminAgents { get; } = - new HashSet<(string, string)> { ("https://ap.example", "aauth:demo@ap.example") }; - - /// Demo "admin" agents receive the demo roles/groups. - public static bool IsAdminAgent(string agentIssuer, string agentId) => AdminAgents.Contains((agentIssuer, agentId)); - public Task AssertAsync( IdentityAssertionRequest request, CancellationToken cancellationToken = default) { - var isAdmin = IsAdminAgent(request.AgentIssuer, request.AgentId); - var roles = isAdmin ? _demoRoles : null; - var groups = isAdmin ? _demoGroups : null; + // Roles and groups describe the person, so they do not depend on which agent asks. + var roles = _demoRoles; + var groups = _demoGroups; var subject = DirectedSubject(request.ResourceUrl); // Person token request: the demo PS acts for one person, so it names them // at once. The resource decides what identity alone is worth. diff --git a/samples/MockResourceServers/Calendar/README.md b/samples/MockResourceServers/Calendar/README.md index fb625d15..95943338 100644 --- a/samples/MockResourceServers/Calendar/README.md +++ b/samples/MockResourceServers/Calendar/README.md @@ -24,9 +24,10 @@ Port: `http://localhost:5001`. Trusts the Person Server at `/events/admin` enforces a role the PS asserts in the auth token's `roles` claim. If the PS issues a token **without** that role, the policy returns an unrecoverable **403**. Scope shortfalls on `/events/write` step up with a new -auth-token challenge; role shortfalls do not. The mock PS asserts -`calendar.owner` only for `aauth:demo@…` agents, so a non-admin agent -deliberately exercises the 403 path. +auth-token challenge; role shortfalls do not. Roles describe the person, not +the agent: the mock PS asserts `calendar.owner` for its demo person whichever +agent asks. Start the PS with `MockPersonServer:GuestPerson=true` to act for a +guest person without the role and exercise the 403 path. ## Running @@ -45,7 +46,8 @@ dotnet run --project samples/AgentConsole -- http://localhost:5001/events \ dotnet run --project samples/AgentConsole -- http://localhost:5001/events/write \ --ap http://localhost:5301 --ps http://localhost:5100 --sub aauth:demo@ap.example -# RBAC (role calendar.owner) — demo agent succeeds; a guest agent gets 403 +# RBAC (role calendar.owner) — the demo person succeeds; a guest person +# (PS started with MockPersonServer:GuestPerson=true) gets 403 dotnet run --project samples/AgentConsole -- http://localhost:5001/events/admin \ --ap http://localhost:5301 --ps http://localhost:5100 --sub aauth:demo@ap.example ``` diff --git a/samples/MockResourceServers/Wallet/README.md b/samples/MockResourceServers/Wallet/README.md index 7b589f01..42adea76 100644 --- a/samples/MockResourceServers/Wallet/README.md +++ b/samples/MockResourceServers/Wallet/README.md @@ -21,9 +21,12 @@ Port: `http://localhost:5003`. Trusts the Access Server at `/wallet/charge` is where the four-party model earns its keep: a real-world "only an authorized payer can spend money" gate, decided by the bank's own -Access Server rather than the resource. With the Keycloak policy engine, the -`demo`/`demo` user has the `wallet.payer` role (can charge) and `guest`/`guest` -does not (denied **403** on `/wallet/charge`). +Access Server rather than the resource. With the stub AS (`make demo`), the AS +asks the PS for the person's `roles` (§Claims Required); the mock PS's demo +person holds `wallet.payer`, and a guest person +(`MockPersonServer:GuestPerson=true`) is denied **403**. With the Keycloak +policy engine, the `demo`/`demo` user has the `wallet.payer` role (can charge) +and `guest`/`guest` does not (denied **403** on `/wallet/charge`). ## Running diff --git a/samples/README.md b/samples/README.md index 3086f439..54172921 100644 --- a/samples/README.md +++ b/samples/README.md @@ -291,14 +291,14 @@ dotnet run --project samples/AgentConsole -- http://localhost:5001/events/write --ap http://localhost:5301 --ps http://localhost:5100 --signing-mode jwt ``` -**Three-party with RBAC (`/events/admin`)** — the PS asserts roles `calendar.owner` and groups `demo-users`: +**Three-party with RBAC (`/events/admin`)** — the PS asserts its demo person's roles `calendar.owner` and `wallet.payer` and group `demo-users`: ```bash dotnet run --project samples/AgentConsole -- http://localhost:5001/events/admin \ --ap http://localhost:5301 --ps http://localhost:5100 --signing-mode jwt ``` -**Four-party with payment (`/wallet/charge`)** — the Access Server requires the `wallet.payer` role (log in as `demo`): +**Four-party with payment (`/wallet/charge`)** — the Access Server requires the person's `wallet.payer` role (stub AS: asked from the PS; Keycloak: log in as `demo`): ```bash dotnet run --project samples/AgentConsole -- http://localhost:5003/wallet/charge \ diff --git a/src/AAuth/Access/AccessServerClient.cs b/src/AAuth/Access/AccessServerClient.cs index 843f8e4d..15dcb98a 100644 --- a/src/AAuth/Access/AccessServerClient.cs +++ b/src/AAuth/Access/AccessServerClient.cs @@ -251,9 +251,10 @@ public async Task FederateAsync( if (response.StatusCode == HttpStatusCode.Forbidden && await IsDeniedAsync(response, cancellationToken).ConfigureAwait(false)) { + var detail = await AAuth.Agent.InteractionDenial.ReadDetailAsync(response, cancellationToken).ConfigureAwait(false); response.Dispose(); - throw new AAuthInteractionDeniedException( - "The Access Server denied the request after the claims push."); + throw new AAuthInteractionDeniedException(string.IsNullOrWhiteSpace(detail) + ? "The Access Server denied the request after the claims push." : detail); } } else @@ -277,9 +278,11 @@ public async Task FederateAsync( if (response.StatusCode == HttpStatusCode.Forbidden && await IsDeniedAsync(response, cancellationToken).ConfigureAwait(false)) { + var detail = await AAuth.Agent.InteractionDenial.ReadDetailAsync(response, cancellationToken).ConfigureAwait(false); response.Dispose(); + // The PS relays this message to the agent as the `denied` detail. throw new AAuthInteractionDeniedException( - "The user denied the AAuth interaction request."); + string.IsNullOrWhiteSpace(detail) ? AAuth.Agent.InteractionDenial.DefaultMessage : detail); } } } diff --git a/src/AAuth/Agent/AAuthInteractionExceptions.cs b/src/AAuth/Agent/AAuthInteractionExceptions.cs index 079f07f0..5b10407e 100644 --- a/src/AAuth/Agent/AAuthInteractionExceptions.cs +++ b/src/AAuth/Agent/AAuthInteractionExceptions.cs @@ -116,3 +116,33 @@ public AAuthClarificationLimitException(int maxRounds) MaxRounds = maxRounds; } } + +/// +/// Builds the message for a +/// §Polling Error Codes denied response, keeping the server's +/// detail (for example an Access Server policy reason) when present. +/// +internal static class InteractionDenial +{ + public const string DefaultMessage = "The user denied the AAuth interaction request."; + + public static string Message(string? detail) + => string.IsNullOrWhiteSpace(detail) ? DefaultMessage : $"The AAuth request was denied: {detail}"; + + /// Reads detail from a buffered problem-details body. + public static async System.Threading.Tasks.Task ReadDetailAsync( + System.Net.Http.HttpResponseMessage response, System.Threading.CancellationToken cancellationToken) + { + var body = await DeferredExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false); + try + { + return System.Text.Json.Nodes.JsonNode.Parse(body) is System.Text.Json.Nodes.JsonObject json + && json["detail"] is System.Text.Json.Nodes.JsonValue value && value.TryGetValue(out var detail) + ? detail : null; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } +} diff --git a/src/AAuth/Agent/DeferredExchange.cs b/src/AAuth/Agent/DeferredExchange.cs index f9d54a11..83aef10f 100644 --- a/src/AAuth/Agent/DeferredExchange.cs +++ b/src/AAuth/Agent/DeferredExchange.cs @@ -279,8 +279,7 @@ private async Task PollAsync( // §Polling Error Codes: `denied` (403) is an explicit user/approver // denial. Surface the semantic interaction-denied exception so callers // can distinguish it from a transport-level polling failure. - throw new AAuthInteractionDeniedException( - "The user denied the AAuth interaction request.", ex); + throw new AAuthInteractionDeniedException(InteractionDenial.Message(ex.Detail), ex); } catch (TimeoutException ex) { diff --git a/src/AAuth/Agent/TokenExchangeClient.cs b/src/AAuth/Agent/TokenExchangeClient.cs index 6e1a37b6..191a5685 100644 --- a/src/AAuth/Agent/TokenExchangeClient.cs +++ b/src/AAuth/Agent/TokenExchangeClient.cs @@ -164,7 +164,7 @@ void ValidateClarificationUpdate(ClarificationResponse answer) && await IsDeniedAsync(resp, ct).ConfigureAwait(false)) { throw new AAuthInteractionDeniedException( - "The user denied the AAuth interaction request."); + InteractionDenial.Message(await InteractionDenial.ReadDetailAsync(resp, ct).ConfigureAwait(false))); } }, }; @@ -268,7 +268,8 @@ public async Task RequestPersonTokenAsync( OnPolledResponse = async (resp, ct) => { if (resp.StatusCode == HttpStatusCode.Forbidden && await IsDeniedAsync(resp, ct).ConfigureAwait(false)) - throw new AAuthInteractionDeniedException("The user denied the AAuth interaction request."); + throw new AAuthInteractionDeniedException( + InteractionDenial.Message(await InteractionDenial.ReadDetailAsync(resp, ct).ConfigureAwait(false))); }, }, cancellationToken, request => { diff --git a/src/AAuth/Person/AAuthPersonServerEndpoints.cs b/src/AAuth/Person/AAuthPersonServerEndpoints.cs index 0bf59297..32de9624 100644 --- a/src/AAuth/Person/AAuthPersonServerEndpoints.cs +++ b/src/AAuth/Person/AAuthPersonServerEndpoints.cs @@ -2313,10 +2313,12 @@ await AppendMissionTokenAsync(app.Services.GetRequiredService(), gr entry.ErrorStatus = StatusCodes.Status408RequestTimeout; entry.Status = PersonPendingStatus.Denied; } - catch (AAuthInteractionDeniedException) + catch (AAuthInteractionDeniedException ex) { entry.Error = "denied"; entry.ErrorStatus = StatusCodes.Status403Forbidden; + // Relay why (for example the AS policy reason) to the polling agent. + entry.ErrorDetail = ex.Message == AAuth.Agent.InteractionDenial.DefaultMessage ? null : ex.Message; entry.Status = PersonPendingStatus.Denied; } catch (AAuthTokenExchangeException ex) diff --git a/tests/AAuth.Tests/Agent/DeferredExchangeTests.cs b/tests/AAuth.Tests/Agent/DeferredExchangeTests.cs index 06349246..f780bc8f 100644 --- a/tests/AAuth.Tests/Agent/DeferredExchangeTests.cs +++ b/tests/AAuth.Tests/Agent/DeferredExchangeTests.cs @@ -31,6 +31,27 @@ await Assert.ThrowsAsync(() => client.ExchangeA Assert.Equal(new[] { "GET", "POST", "GET" }, handler.Methods); } + [Theory] + [InlineData("{\"error\":\"denied\",\"detail\":\"scope 'wallet.charge' requires the 'wallet.payer' role\"}", + "The AAuth request was denied: scope 'wallet.charge' requires the 'wallet.payer' role")] + [InlineData("{\"error\":\"denied\"}", "The user denied the AAuth interaction request.")] + public async Task TokenExchange_DeniedPoll_KeepsServerDetail(string body, string message) + { + using var handler = new SequenceHandler( + _ => Json(HttpStatusCode.OK, "{\"issuer\":\"https://ps.example\",\"auth_token_endpoint\":\"https://ps.example/token\"}"), + _ => Pending("requirement=approval"), + _ => Json(HttpStatusCode.Forbidden, body)); + using var http = new InProcessHttpClient(handler); + var client = new TokenExchangeClient(http, new MetadataClient(http)); + var denied = await Assert.ThrowsAsync(() => client.ExchangeAsync( + "https://ps.example", TestTokens.Resource, new TokenExchangeRequest + { + PresentedToken = "presented", + PollerOptions = new DeferredPollerOptions { MinPollInterval = TimeSpan.Zero }, + })); + Assert.Equal(message, denied.Message); + } + [Fact] public async Task ApprovalThenNewInteractions_DispatchesEveryChangedUrlAndCode() { diff --git a/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md b/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md index 8b48ce01..4a140067 100644 --- a/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md +++ b/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md @@ -50,7 +50,7 @@ documentation change. | [docs/getting-started.md](../../../docs/getting-started.md) | `c613b20aba927d9a21e1086a6a42e8c0a2bce59b1d72cd3ee110824a24ec73e3` | 27 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/glossary.md](../../../docs/glossary.md) | `59f26b56ae854045dcf7f57a620d59695b33807381805deda7a99b13bb327c97` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/README.md](../../../docs/README.md) | `ca1e2b243ed4365ed376e0b906fd947ff046e0a25cb76cc39b607da6ef40718e` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [docs/reference/configuration.md](../../../docs/reference/configuration.md) | `06ad287b0bf6bf185fd4386a79ca13f5b2bc2c89272120d7a59e2353dc793491` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [docs/reference/configuration.md](../../../docs/reference/configuration.md) | `ae13c07f5e304db3a9d7968004d04ef95aea411cbbd31f50300796166cec3cf4` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/reference/dependency-injection.md](../../../docs/reference/dependency-injection.md) | `446c16e3991f2d096642789f0cd89d0f63905827e919d4f2bb5e0d34f0b0e40a` | 34 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/server/authn-authz.md](../../../docs/server/authn-authz.md) | `fa1e6ad0e701ff2ffc664106fd56094ea14e290b0239b16ddfe8a42d690c8ec5` | 9 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/server/authorization-policies.md](../../../docs/server/authorization-policies.md) | `03587128ad71eae07e799f264a44f53eb798a814322a7bb7cd54ed64ba0c66f6` | 6 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | @@ -80,7 +80,7 @@ documentation change. | [docs/workflows/rich-resource-requests.md](../../../docs/workflows/rich-resource-requests.md) | `7e18bd12fbf469d713f7b65fabdba12d69b9067346fa60bce95fca6de1f79ecb` | 4 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/workflows/wallet-protocol.md](../../../docs/workflows/wallet-protocol.md) | `071381a50e1fe01db06b152d092507f621782f160c4f429e2ed3666b607fb893` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [README.md](../../../README.md) | `f3706a4dd020ac18cfb1d909c32605732c9be4c3afcda18838bbbe9fdf92fb26` | 10 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/AgentConsole/README.md](../../../samples/AgentConsole/README.md) | `c21e175a15f51a47cc9dfb61b95604be87ef3d7ff07e6551c3af14c3c8cd3321` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/AgentConsole/README.md](../../../samples/AgentConsole/README.md) | `bb4a8aaf5ee01a27b2ee5c1d4a0c3f1e8679d808ad53341779c7b1035579cc37` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/CapabilitySupport/CatalogDemoSession.cs](../../../samples/CapabilitySupport/CatalogDemoSession.cs) | `54745a6dee95a60e7c368d16c8a339145c02451adbd2328059a3f00d69bc0db0` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/CapabilitySupport/CatalogWalkthrough.razor](../../../samples/CapabilitySupport/CatalogWalkthrough.razor) | `74a82a32bde728372990369f6666b9abf1b7ed99b5608ccd2b315d5fa7e1ad02` | 7 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/CapabilitySupport/DocumentDemoSession.cs](../../../samples/CapabilitySupport/DocumentDemoSession.cs) | `3cabc9b58632d8343c7cdd3d0dac00219cb6a2f7a37c036af39841b24b2876bd` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | @@ -146,20 +146,20 @@ documentation change. | [samples/GuidedTour/TourSession.Capabilities.cs](../../../samples/GuidedTour/TourSession.Capabilities.cs) | `b202680ef4974f8b65b228584fc952b5a5ca1b9f74c710dfdfa11f1a590ed43c` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/GuidedTour/TourSession.cs](../../../samples/GuidedTour/TourSession.cs) | `7cff25645341f824367a98d240e5dbda83e4d2ef7c9387b48004a3d84d44f34d` | 8 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MissionAgent/README.md](../../../samples/MissionAgent/README.md) | `ac36366a6e7bd5b910fd655365ee10bcf76f45845fb08c08198236045ab18105` | 9 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/MockAccessServers/Federated/README.md](../../../samples/MockAccessServers/Federated/README.md) | `042a3de9471d495c2875ff34f061cd92f0e99c786348e022de004b257780e0cd` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/MockAccessServers/Federated/README.md](../../../samples/MockAccessServers/Federated/README.md) | `677adbd2d4d7ad44ba9c1d4f6bd4e65104c8edc58d2a70882e4597ddd3e87eaa` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockAccessServers/README.md](../../../samples/MockAccessServers/README.md) | `08b43bfc4efb0efb68d38b8c130dbb0c76a353735dc7e95193cb0d3e39220b67` | 1 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockAgentProvider/README.md](../../../samples/MockAgentProvider/README.md) | `2d2cd4debdb23d67bf76f206b0d32d90cc6b4e8c1ce3428bb1269b81cb51606d` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/MockPersonServer/README.md](../../../samples/MockPersonServer/README.md) | `b91c9c0c8870a6275666d7e494dea0a6695da8f8c38df562c6de5a1efe15b1c4` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/MockPersonServer/README.md](../../../samples/MockPersonServer/README.md) | `49bb31ea85e6586469e72d9889e88fea288212ec1ef9b370d68dbfccbb4e4ece` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/Bookings/README.md](../../../samples/MockResourceServers/Bookings/README.md) | `8eaabf7d07e2495767d314907fd90cbf753addb874f756e79a257a877576f0f9` | 1 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/MockResourceServers/Calendar/README.md](../../../samples/MockResourceServers/Calendar/README.md) | `406cf46eb6fa630a12d2b6dec9fc8ef4c00c57f3175c41b33c5c0c88f5485bcb` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/MockResourceServers/Calendar/README.md](../../../samples/MockResourceServers/Calendar/README.md) | `2e47c27c6e6880f1c796bdb30e9a27e0254aae3939abd424cda0d47ef2a45363` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/Catalog/README.md](../../../samples/MockResourceServers/Catalog/README.md) | `b266c6531293b07b37ed652d6830c508dcff629c692da4089a538018126e7f9d` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/Documents/README.md](../../../samples/MockResourceServers/Documents/README.md) | `d350af9b418ef168c945a76dcafcdd84610d09cacce7b6a9c7c3aa116f37f6ee` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/Inbox/README.md](../../../samples/MockResourceServers/Inbox/README.md) | `a07af2ea6326622bc6b57ab46075d31738e2c1bc977d8f059b8e69d6a65f2893` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/Profile/README.md](../../../samples/MockResourceServers/Profile/README.md) | `7f557fa60b22c87701128caeeabfec4da1a37ce0b7249441fd35bfa0e21c3511` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/README.md](../../../samples/MockResourceServers/README.md) | `384560afe7b5ac16ae5377e86084865d1d20103adc5905b3c53af9d8ac2d4ce0` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/Trips/README.md](../../../samples/MockResourceServers/Trips/README.md) | `cfc34f623eef77ec41c54959f1f7c13f251c0679e5956f7b4e620e457c800666` | 1 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/MockResourceServers/Wallet/README.md](../../../samples/MockResourceServers/Wallet/README.md) | `211b19afe98ef47d20c0a61de690db2ec4b1fe722e6d3ac3e4e4bdeb5d1f948d` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/README.md](../../../samples/README.md) | `e80c260496b3da91283063653658c36a50c95721ce8a23a639378b8671455454` | 25 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/MockResourceServers/Wallet/README.md](../../../samples/MockResourceServers/Wallet/README.md) | `da78f3a77b29f53b3fea3c11aa49866b8028d8bd7199073fb221924c57c5cabb` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/README.md](../../../samples/README.md) | `bb745a03cb3d9d2a0d9fe5a16a368ccaf0d419ff08cdbbac8befb090d2882ef2` | 25 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/_Imports.razor](../../../samples/SampleApp/Components/_Imports.razor) | `b7b03c630e075d1c783acff669ceb9e9de268daf31740a988a4f5945f477c030` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/App.razor](../../../samples/SampleApp/Components/App.razor) | `e28bc9f11a4329d2689a64520db6550c34aaf9c042c478ef46b88398fe6e707a` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/ConsentProgress.razor](../../../samples/SampleApp/Components/ConsentProgress.razor) | `7b525cdb5ea92267e0b5ea5d8ff1196694e203ff18459338f8f86f0952143ff4` | 1 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | @@ -519,7 +519,7 @@ documentation change. | [README.md:fence-9](../../../README.md#L254) | csharp | `3a08c889801aad072136008295575e90391eace51459c0cf8432e440a3fec6c3` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [README.md:fence-10](../../../README.md#L301) | bash | `be9b05c3b3e1ca81497243c1dd96e2bdecc5e5d6dbea5feecf5ed021abe834a0` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/AgentConsole/README.md:fence-1](../../../samples/AgentConsole/README.md#L21) | bash | `da6dcde217b6c8f0a2407ed7d9108862f287daee9657046af43413a46152c472` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/AgentConsole/README.md:fence-2](../../../samples/AgentConsole/README.md#L60) | bash | `d05e133ab359ba8ab93d947b4612eae422d4efb66d48d006d9a8630ecba7c533` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/AgentConsole/README.md:fence-2](../../../samples/AgentConsole/README.md#L60) | bash | `4bc83c04446d79ae52b66feb135ac5e585eff02dc792bec84e53a7960a042c67` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/AgentConsole/README.md:fence-3](../../../samples/AgentConsole/README.md#L106) | bash | `eaf7d044a746e16e1235bdde143fc734a158006b49ff1625372406396c57dbd1` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/CapabilitySupport/CatalogWalkthrough.razor:pre-1](../../../samples/CapabilitySupport/CatalogWalkthrough.razor#L61) | dynamic | `f6681b8cd4bf54cf386ff594c5b078bad752d3962ddaa6f7a7ba49c5783c4108` | Dynamic Razor binding: build plus mapped scenario browser/captured-wire tests | [Catalog session](../../../samples/CapabilitySupport/CatalogDemoSession.cs), [both-app Catalog wrapper](../../../tests/e2e/helpers/catalog.ts), R3VocabularyTests | | [samples/CapabilitySupport/CatalogWalkthrough.razor:pre-2](../../../samples/CapabilitySupport/CatalogWalkthrough.razor#L66) | dynamic | `4c53b6a936934792c2af6215e2db6b9e045011808ac175948963fbddaf0ce6a5` | Dynamic Razor binding: build plus mapped scenario browser/captured-wire tests | [Catalog session](../../../samples/CapabilitySupport/CatalogDemoSession.cs), [both-app Catalog wrapper](../../../tests/e2e/helpers/catalog.ts), R3VocabularyTests | @@ -723,9 +723,9 @@ documentation change. | [samples/MissionAgent/README.md:fence-7](../../../samples/MissionAgent/README.md#L220) | bash | `65bda3d6cd1ce8e88a9e5b1713b70a5f5adf9e38fc952dd112e43bc0ffe45d13` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MissionAgent/README.md:fence-8](../../../samples/MissionAgent/README.md#L244) | bash | `427b1ed2e66a5a4f6a0aefc63af97fb7302faca5732b5075e0221597f6026f7e` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MissionAgent/README.md:fence-9](../../../samples/MissionAgent/README.md#L256) | bash | `484a6b139145eb4a4d9af3a00a3a49cd05ceffb2b2aa6551766927ee693ffb90` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/MockAccessServers/Federated/README.md:fence-1](../../../samples/MockAccessServers/Federated/README.md#L58) | bash | `32f77f60c1f6fc0cf6fa4bb1475f12e200abe5ff78c303c1d000148c91436309` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/MockAccessServers/Federated/README.md:fence-2](../../../samples/MockAccessServers/Federated/README.md#L103) | bash | `ef23884b8e86b32a20334a454f384e08564ead9deb5ecd297febd6a6b34b5400` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/MockAccessServers/Federated/README.md:fence-3](../../../samples/MockAccessServers/Federated/README.md#L109) | bash | `481ed1eda6a4d531693df0199810be3eaa64e4a006cdcf7a12a2b68cc47ceb5b` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/MockAccessServers/Federated/README.md:fence-1](../../../samples/MockAccessServers/Federated/README.md#L59) | bash | `32f77f60c1f6fc0cf6fa4bb1475f12e200abe5ff78c303c1d000148c91436309` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/MockAccessServers/Federated/README.md:fence-2](../../../samples/MockAccessServers/Federated/README.md#L104) | bash | `ef23884b8e86b32a20334a454f384e08564ead9deb5ecd297febd6a6b34b5400` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/MockAccessServers/Federated/README.md:fence-3](../../../samples/MockAccessServers/Federated/README.md#L110) | bash | `481ed1eda6a4d531693df0199810be3eaa64e4a006cdcf7a12a2b68cc47ceb5b` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MockAccessServers/README.md:fence-1](../../../samples/MockAccessServers/README.md#L52) | bash | `84bc1a5edb19022f5b273a7dca7a5110fa0986a0083c40d53de78ec7d1483348` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MockAgentProvider/README.md:fence-1](../../../samples/MockAgentProvider/README.md#L25) | bash | `016d1fa45df9833e0ba49e8da141e5bee17880475b58e33588eee4276e2a00d8` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MockAgentProvider/README.md:fence-2](../../../samples/MockAgentProvider/README.md#L33) | bash | `db82207af68a01be6c3d5a244232b509e1723c3af0e3697b2e9aaf0b55dd38bd` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | @@ -733,8 +733,8 @@ documentation change. | [samples/MockPersonServer/README.md:fence-1](../../../samples/MockPersonServer/README.md#L151) | bash | `b183ed4a8dfcaa44c29c99bc03fdfbd48a243beb13c4c5fa866387c24c5fd988` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MockPersonServer/README.md:fence-2](../../../samples/MockPersonServer/README.md#L159) | bash | `07d9570f472918f18e9d0ce724b66b667dce93392f42cc8e6361efb29bd9a33a` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MockResourceServers/Bookings/README.md:fence-1](../../../samples/MockResourceServers/Bookings/README.md#L73) | bash | `e985208fee44b1458815a5da292860d021ea36083336c8ef45496787bda7e1a9` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/MockResourceServers/Calendar/README.md:fence-1](../../../samples/MockResourceServers/Calendar/README.md#L33) | bash | `26cd5505c29422ddb1032d5c73126f4d17fd74dafedcc90f62ea708a60ef4748` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/MockResourceServers/Calendar/README.md:fence-2](../../../samples/MockResourceServers/Calendar/README.md#L39) | bash | `b796154a6bf6f11e544824eff3272f406c0d9201ec8c294ccafd658c6e4c0039` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/MockResourceServers/Calendar/README.md:fence-1](../../../samples/MockResourceServers/Calendar/README.md#L34) | bash | `26cd5505c29422ddb1032d5c73126f4d17fd74dafedcc90f62ea708a60ef4748` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/MockResourceServers/Calendar/README.md:fence-2](../../../samples/MockResourceServers/Calendar/README.md#L40) | bash | `ea757e4b948f6acd2bfd46f0ee6988435469a7729055c210ac0fbe65927adc7f` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MockResourceServers/Inbox/README.md:fence-1](../../../samples/MockResourceServers/Inbox/README.md#L42) | mermaid | `7a71808473223e00cb7c9984355f08a6b9606b1c89f49408f2b0b4e99c06aad3` | Sequence/flow source checked; actors/order reviewed against mapped scenario | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MockResourceServers/Inbox/README.md:fence-2](../../../samples/MockResourceServers/Inbox/README.md#L63) | bash | `c790576af4dcd14a1537ae6fa6674f2ab50661541b9fbdb9af096688ec51ba86` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MockResourceServers/Inbox/README.md:fence-3](../../../samples/MockResourceServers/Inbox/README.md#L69) | bash | `4350c348d8bcc4f8e36b66ded270a9b4a50bf6579e46d60d7196cec10c668997` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | @@ -743,8 +743,8 @@ documentation change. | [samples/MockResourceServers/README.md:fence-1](../../../samples/MockResourceServers/README.md#L76) | bash | `916b5235d2fe61a422594a603f3bb8044a31ce2e224831e131fb4939ff260ea8` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MockResourceServers/README.md:fence-2](../../../samples/MockResourceServers/README.md#L82) | bash | `721fe8dc2df254017febc2383bc5571d05b647334bcb9caf675f05984ec9b7b8` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/MockResourceServers/Trips/README.md:fence-1](../../../samples/MockResourceServers/Trips/README.md#L29) | bash | `8a22349beae27d0015ed2fcc8bbd9ee011ae7d305193b3ce3b02123596fb4ca0` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/MockResourceServers/Wallet/README.md:fence-1](../../../samples/MockResourceServers/Wallet/README.md#L30) | bash | `1ad360b8e2259599187dedb9b3fcf3cddef1d0affeab8b21d9f355dabd3ab059` | Shell syntax checked; side-effect commands not executed | [Wallet session](../../../samples/CapabilitySupport/WalletDemoSession.cs), [both-app Wallet wrapper](../../../tests/e2e/helpers/wallet-protocol.ts), DeferredFederationTests / RevocationLifecycleTests | -| [samples/MockResourceServers/Wallet/README.md:fence-2](../../../samples/MockResourceServers/Wallet/README.md#L39) | bash | `1c9e9ac5481e479bb2dc17031eb320f334638bf434050b2ae7e5596db71364d6` | Shell syntax checked; side-effect commands not executed | [Wallet session](../../../samples/CapabilitySupport/WalletDemoSession.cs), [both-app Wallet wrapper](../../../tests/e2e/helpers/wallet-protocol.ts), DeferredFederationTests / RevocationLifecycleTests | +| [samples/MockResourceServers/Wallet/README.md:fence-1](../../../samples/MockResourceServers/Wallet/README.md#L33) | bash | `1ad360b8e2259599187dedb9b3fcf3cddef1d0affeab8b21d9f355dabd3ab059` | Shell syntax checked; side-effect commands not executed | [Wallet session](../../../samples/CapabilitySupport/WalletDemoSession.cs), [both-app Wallet wrapper](../../../tests/e2e/helpers/wallet-protocol.ts), DeferredFederationTests / RevocationLifecycleTests | +| [samples/MockResourceServers/Wallet/README.md:fence-2](../../../samples/MockResourceServers/Wallet/README.md#L42) | bash | `1c9e9ac5481e479bb2dc17031eb320f334638bf434050b2ae7e5596db71364d6` | Shell syntax checked; side-effect commands not executed | [Wallet session](../../../samples/CapabilitySupport/WalletDemoSession.cs), [both-app Wallet wrapper](../../../tests/e2e/helpers/wallet-protocol.ts), DeferredFederationTests / RevocationLifecycleTests | | [samples/README.md:fence-1](../../../samples/README.md#L59) | csharp | `2b712ecb3492998075ff420dbe306e24d9301a11115d40ec85698f19c7c05f8f` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/README.md:fence-2](../../../samples/README.md#L128) | bash | `549ee4073d83ddc3114327176a7908e2a284a36b8fdd273b15c32ea6b359ee88` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/README.md:fence-3](../../../samples/README.md#L155) | bash | `6483e1a97b8691a784209ca40d7c0637d35249ea0b6efea72f8611406ff21604` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | diff --git a/tests/AAuth.Tests/Integration/CalendarFlowTests.cs b/tests/AAuth.Tests/Integration/CalendarFlowTests.cs index 1dba2477..e0643cf4 100644 --- a/tests/AAuth.Tests/Integration/CalendarFlowTests.cs +++ b/tests/AAuth.Tests/Integration/CalendarFlowTests.cs @@ -62,10 +62,18 @@ public class CalendarFlowTests : IAsyncLifetime private WebApplicationFactory? _ps; public Task InitializeAsync() + { + StartPair(guestPerson: false); + return Task.CompletedTask; + } + + // `guestPerson` makes the PS act for a person with no roles or groups. + private void StartPair(bool guestPerson) { _ps = new WebApplicationFactory().WithWebHostBuilder(b => { b.UseSetting("AAuth:Issuer", PsIssuer); + if (guestPerson) b.UseSetting("MockPersonServer:GuestPerson", "true"); b.ConfigureServices(services => { // The PS verifies the resource token per §"Resource Token @@ -110,7 +118,6 @@ public Task InitializeAsync() }); }); _calendar.CreateClient(); - return Task.CompletedTask; } public Task DisposeAsync() @@ -358,12 +365,14 @@ public async Task AdminScopeFlow_IssuesElevatedScope() [Fact] public async Task RoleFlow_ReturnsAssertedRoles() { + // The roles are the person's, so any agent acting for them sees them, + // including one with a provider-assigned identifier. var agentKey = AAuthKey.Generate(); var agentToken = await new AgentTokenBuilder { EgressPolicy = TestEgress.Policy, Issuer = ApIssuer, - Subject = "aauth:demo@ap.example", + Subject = "aauth:agent-7f3a@ap.example", KeyId = ApKeyId, Key = ApKey, ConfirmationKey = agentKey, @@ -382,19 +391,22 @@ public async Task RoleFlow_ReturnsAssertedRoles() } [Fact] - public async Task RoleFlow_Returns403_WhenAgentLacksRole() + public async Task RoleFlow_Returns403_WhenPersonLacksRole() { - // A non-admin demo agent (the mock PS only asserts the calendar.owner - // role for the exact agent `aauth:demo@ap.example`) completes the three-party flow - // and receives a valid auth token WITHOUT the role. The role policy - // on /events/admin must therefore reject it with 403 — exercising - // role-based DENIAL, not just the success path. + // Roles describe the person, not the agent. A PS acting for a guest + // person completes the three-party flow and issues a valid auth token + // WITHOUT the role, so the role policy on /events/admin must reject it + // with 403 — exercising role-based DENIAL, not just the success path. + _ps!.Dispose(); + _calendar!.Dispose(); + StartPair(guestPerson: true); + var agentKey = AAuthKey.Generate(); var agentToken = await new AgentTokenBuilder { EgressPolicy = TestEgress.Policy, Issuer = ApIssuer, - Subject = "aauth:guest@ap.example", + Subject = "aauth:demo@ap.example", KeyId = ApKeyId, Key = ApKey, ConfirmationKey = agentKey, diff --git a/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs b/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs index 4ea78a42..a04ccf8f 100644 --- a/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs +++ b/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs @@ -30,7 +30,8 @@ namespace AAuth.Tests.Integration; /// 3. The PS polls /pending/{id} → 200 auth_token (allow) or /// 403 denied (deny), mirroring the PS deferred shape. /// The stub Keycloak grants wallet.read to anyone and wallet.charge -/// only when the claim_token carries the wallet.payer role. +/// only to the logged-in demo user, who holds the wallet.payer realm +/// role (the real realm's role policy reads the user, never the agent). /// public class MockAccessServerKeycloakTests { @@ -38,8 +39,7 @@ public class MockAccessServerKeycloakTests private const string PsIssuer = "https://ps.test"; private const string ApIssuer = "https://ap.example"; private const string ResourceUrl = "https://wallet.test"; - private const string AdminAgentId = "aauth:demo@ap.example"; // admin by demo convention. - private const string GuestAgentId = "aauth:guest@ap.example"; // non-admin. + private const string AgentId = "aauth:demo@ap.example"; private const string PsKid = "ps-1"; private const string ApKid = "ap-1"; @@ -55,7 +55,7 @@ public async Task InteractiveFlow_GrantsWalletRead_AfterKeycloakLogin() using var factory = BuildFactory(); // 1. PS POSTs /token → expect 202 requirement=interaction. - var pendingPath = await StartInteractionAsync(factory, GuestAgentId, "wallet.read"); + var pendingPath = await StartInteractionAsync(factory, AgentId, "wallet.read"); // 2. The user completes the Keycloak login/consent round-trip. await CompleteCallbackAsync(factory, pendingPath); @@ -75,12 +75,12 @@ public async Task InteractiveFlow_GrantsWalletRead_AfterKeycloakLogin() } [Fact] - public async Task InteractiveFlow_GrantsAdminScope_ForAdminAgent() + public async Task InteractiveFlow_GrantsAdminScope_ForPayerUser() { using var factory = BuildFactory(); - var pendingPath = await StartInteractionAsync(factory, AdminAgentId, "wallet.charge"); - await CompleteCallbackAsync(factory, pendingPath); + var pendingPath = await StartInteractionAsync(factory, AgentId, "wallet.charge"); + await CompleteCallbackAsync(factory, pendingPath, user: "demo"); using var signed = BuildPsSignedClient(factory); var poll = await signed.GetAsync(pendingPath); @@ -93,14 +93,14 @@ public async Task InteractiveFlow_GrantsAdminScope_ForAdminAgent() } [Fact] - public async Task InteractiveFlow_DeniesAdminScope_ForNonAdminAgent() + public async Task InteractiveFlow_DeniesAdminScope_ForGuestUser() { using var factory = BuildFactory(); - // Guest agent requesting the elevated scope: Keycloak denies because - // the claim_token carries no wallet.payer role. - var pendingPath = await StartInteractionAsync(factory, GuestAgentId, "wallet.charge"); - await CompleteCallbackAsync(factory, pendingPath); + // The same agent, but the user who logs in at Keycloak is `guest`, who + // lacks the wallet.payer realm role, so Keycloak denies the elevated scope. + var pendingPath = await StartInteractionAsync(factory, AgentId, "wallet.charge"); + await CompleteCallbackAsync(factory, pendingPath, user: "guest"); using var signed = BuildPsSignedClient(factory); var poll = await signed.GetAsync(pendingPath); @@ -119,8 +119,8 @@ public async Task Token_ReturnsInteractionRequirement_BeforeLogin() using var signed = BuildPsSignedClient(factory); var response = await signed.PostAsJsonAsync("/token", new JsonObject { - ["agent_token"] = await BuildAgentTokenAsync(agentKey, GuestAgentId), - ["resource_token"] = await BuildResourceTokenAsync(agentKey, AsIssuer, GuestAgentId, "wallet.read"), + ["agent_token"] = await BuildAgentTokenAsync(agentKey, AgentId), + ["resource_token"] = await BuildResourceTokenAsync(agentKey, AsIssuer, AgentId, "wallet.read"), ["presented_token"] = await BuildPersonTokenAsync(agentKey), }); @@ -136,7 +136,7 @@ public async Task Token_ReturnsInteractionRequirement_BeforeLogin() public async Task KeycloakCannotBeBypassedByStubDecision(string action) { using var factory = BuildFactory(); - var pendingPath = await StartInteractionAsync(factory, GuestAgentId, "wallet.charge"); + var pendingPath = await StartInteractionAsync(factory, AgentId, "wallet.charge"); using var browser = factory.CreateClient(); using var bypass = await browser.PostAsync("/interaction/" + action, new FormUrlEncodedContent( new Dictionary { ["code"] = pendingPath.Split('/')[^1] })); @@ -150,7 +150,7 @@ public async Task KeycloakCannotBeBypassedByStubDecision(string action) public async Task KeycloakCallbackRejectsCodeWithoutInitiatingBrowser() { using var factory = BuildFactory(); - var pendingPath = await StartInteractionAsync(factory, GuestAgentId, "wallet.charge"); + var pendingPath = await StartInteractionAsync(factory, AgentId, "wallet.charge"); using var browser = factory.CreateClient(); using var bypass = await browser.GetAsync("/interaction/callback?code=fake-auth-code&state=" + pendingPath.Split('/')[^1]); Assert.Equal(HttpStatusCode.Unauthorized, bypass.StatusCode); @@ -179,7 +179,7 @@ private static async Task StartInteractionAsync( } private static async Task CompleteCallbackAsync( - WebApplicationFactory factory, string pendingPath) + WebApplicationFactory factory, string pendingPath, string user = "guest") { var id = pendingPath["/pending/".Length..]; using var browser = factory.CreateClient(new WebApplicationFactoryClientOptions @@ -194,7 +194,7 @@ private static async Task CompleteCallbackAsync( Assert.Equal(HttpStatusCode.Redirect, login.StatusCode); var state = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(login.Headers.Location!.Query)["state"].ToString(); Assert.NotEqual(id, state); - var callback = await browser.GetAsync($"/interaction/callback?code=fake-auth-code&state={state}"); + var callback = await browser.GetAsync($"/interaction/callback?code={user}-auth-code&state={state}"); Assert.True(callback.IsSuccessStatusCode, $"callback Status={(int)callback.StatusCode} {await callback.Content.ReadAsStringAsync()}"); Assert.Matches("

(Access granted|Access denied)

", await callback.Content.ReadAsStringAsync()); @@ -334,9 +334,9 @@ private static string Jwks(AAuthKey key, string kid) /// /// Stand-in for Keycloak's token endpoint. Handles the authorization-code - /// exchange (returns a fake access token) and the uma-ticket + /// exchange (the code names the user who logged in) and the uma-ticket /// decision request (grants wallet.read; grants wallet.charge only - /// when the pushed claim_token carries the wallet.payer role). + /// to the demo user, who holds the wallet.payer realm role). /// private sealed class StubKeycloakHandler : HttpMessageHandler { @@ -348,15 +348,16 @@ protected override async Task SendAsync( if (grantType == "authorization_code") { - return Json(HttpStatusCode.OK, new JsonObject { ["access_token"] = "fake-user-token" }); + var user = form.GetValueOrDefault("code") == "demo-auth-code" ? "demo" : "guest"; + return Json(HttpStatusCode.OK, new JsonObject { ["access_token"] = "user-token:" + user }); } if (grantType == "urn:ietf:params:oauth:grant-type:uma-ticket") { var permission = form.GetValueOrDefault("permission") ?? ""; var elevated = permission.Contains("wallet.charge", StringComparison.Ordinal); - var hasAdminRole = HasAdminRole(form.GetValueOrDefault("claim_token")); - return (!elevated || hasAdminRole) + var isPayer = request.Headers.Authorization?.Parameter == "user-token:demo"; + return (!elevated || isPayer) ? Json(HttpStatusCode.OK, new JsonObject { ["result"] = true }) : Json(HttpStatusCode.Forbidden, new JsonObject { ["error"] = "denied" }); } @@ -364,38 +365,6 @@ protected override async Task SendAsync( return new HttpResponseMessage(HttpStatusCode.BadRequest); } - private static bool HasAdminRole(string? claimTokenB64) - { - if (string.IsNullOrEmpty(claimTokenB64)) - { - return false; - } - - try - { - var json = Encoding.UTF8.GetString(Convert.FromBase64String(claimTokenB64)); - var roles = JsonNode.Parse(json)?["roles"] as JsonArray; - if (roles is null) - { - return false; - } - - foreach (var role in roles) - { - if ((string?)role == "wallet.payer") - { - return true; - } - } - } - catch (FormatException) - { - return false; - } - - return false; - } - private static async Task> ParseFormAsync( HttpRequestMessage request, CancellationToken cancellationToken) { diff --git a/tests/AAuth.Tests/Integration/MockAccessServerTests.cs b/tests/AAuth.Tests/Integration/MockAccessServerTests.cs index 2cf4f971..7e849c6d 100644 --- a/tests/AAuth.Tests/Integration/MockAccessServerTests.cs +++ b/tests/AAuth.Tests/Integration/MockAccessServerTests.cs @@ -321,55 +321,66 @@ public async Task Token_RejectsUntrustedPersonServer() } [Fact] - public async Task Token_GrantsElevatedScope_ForAdminAgent() + public async Task Token_GrantsElevatedScope_WhenPersonIsPayer() { - // The default stub policy grants wallet.charge to an admin agent - // (the demo convention: the exact agent id "aauth:demo@ap.example"). - var agentKey = AAuthKey.Generate(); - var agentToken = await BuildAgentTokenAsync(agentKey, AgentId); - var resourceToken = await BuildResourceTokenAsync(agentKey, audience: AsIssuer, agent: AgentId, scope: "wallet.charge"); - - using var http = BuildPsSignedClient(); - var response = await http.PostAsJsonAsync("/token", new JsonObject + // Roles describe the person, so the stub AS asks the PS for them + // (§Claims Required) and grants wallet.charge once the PS pushes the + // wallet.payer role, whichever agent is asking. + var (http, pendingPath) = await StartChargeAsync(); + using (http) { - ["agent_token"] = agentToken, - ["resource_token"] = resourceToken, - ["presented_token"] = await BuildPersonTokenAsync(agentKey), - }); + var push = await http.PostAsJsonAsync(pendingPath, new JsonObject + { + ["roles"] = new JsonArray("calendar.owner", "wallet.payer"), + }); - Assert.True(response.IsSuccessStatusCode, - $"Status={(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); - var body = await response.Content.ReadFromJsonAsync(); - var payload = (JsonObject)JsonNode.Parse( - Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes( - ((string?)body!["auth_token"])!.Split('.')[1]))!; - Assert.Equal("wallet.charge", (string?)payload["scope"]); + Assert.True(push.IsSuccessStatusCode, + $"Status={(int)push.StatusCode} {await push.Content.ReadAsStringAsync()}"); + var body = await push.Content.ReadFromJsonAsync(); + var payload = (JsonObject)JsonNode.Parse( + Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes( + ((string?)body!["auth_token"])!.Split('.')[1]))!; + Assert.Equal("wallet.charge", (string?)payload["scope"]); + } } - [Fact] - public async Task Token_DeniesElevatedScope_ForNonAdminAgent() + [Theory] + [InlineData("{}")] + [InlineData("{\"roles\":[\"calendar.owner\"]}")] + public async Task Token_DeniesElevatedScope_WhenPersonIsNotPayer(string pushed) { - // A non-admin agent requesting wallet.charge is denied by the stub - // policy (no wallet.payer role) → 403 denied. - const string GuestId = "aauth:guest@ap.example"; - var agentKey = AAuthKey.Generate(); - var agentToken = await BuildAgentTokenAsync(agentKey, GuestId); - var resourceToken = await BuildResourceTokenAsync(agentKey, audience: AsIssuer, agent: GuestId, scope: "wallet.charge"); + // A person without wallet.payer (a guest pushes no roles at all) is + // denied by the stub policy instead of being asked again → 403 denied. + var (http, pendingPath) = await StartChargeAsync(); + using (http) + { + var response = await http.PostAsJsonAsync(pendingPath, JsonNode.Parse(pushed)); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal("denied", (string?)body!["error"]); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.False(string.IsNullOrWhiteSpace((string?)body["detail"])); + Assert.False(response.Headers.Contains("Signature-Error")); + } + } - using var http = BuildPsSignedClient(); - var response = await http.PostAsJsonAsync("/token", new JsonObject + private async Task<(HttpClient Http, string PendingPath)> StartChargeAsync() + { + var agentKey = AAuthKey.Generate(); + var http = BuildPsSignedClient(); + var token = await http.PostAsJsonAsync("/token", new JsonObject { - ["agent_token"] = agentToken, - ["resource_token"] = resourceToken, + ["agent_token"] = await BuildAgentTokenAsync(agentKey, AgentId), + ["resource_token"] = await BuildResourceTokenAsync(agentKey, audience: AsIssuer, agent: AgentId, scope: "wallet.charge"), ["presented_token"] = await BuildPersonTokenAsync(agentKey), }); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(); - Assert.Equal("denied", (string?)body!["error"]); - Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); - Assert.False(string.IsNullOrWhiteSpace((string?)body["detail"])); - Assert.False(response.Headers.Contains("Signature-Error")); + Assert.Equal(HttpStatusCode.Accepted, token.StatusCode); + Assert.Contains("requirement=claims", token.Headers.GetValues("AAuth-Requirement").Single()); + var requirement = await token.Content.ReadFromJsonAsync(); + Assert.Equal(["roles"], requirement!["required_claims"]!.AsArray().Select(name => (string?)name)); + return (http, token.Headers.Location!.OriginalString); } [Fact] diff --git a/tests/AAuth.Tests/Integration/SampleIdentityClaimsAsserterTests.cs b/tests/AAuth.Tests/Integration/SampleIdentityClaimsAsserterTests.cs index e7c61f3a..ee91aadf 100644 --- a/tests/AAuth.Tests/Integration/SampleIdentityClaimsAsserterTests.cs +++ b/tests/AAuth.Tests/Integration/SampleIdentityClaimsAsserterTests.cs @@ -7,51 +7,46 @@ namespace AAuth.Tests.Integration; /// -/// SMP-01 negative control: the demo "admin" roles are granted on an exact agent -/// identifier, never on a prefix an arbitrary agent provider could mint. +/// Roles and groups are identity claims about the person (RFC 9068 / SCIM): the demo +/// PS asserts the demo person's roles whichever agent asks, and none for a guest person. /// public class SampleIdentityClaimsAsserterTests { [Theory] - [InlineData("aauth:demo@ap.example", true)] - [InlineData("aauth:demo@attacker.example", false)] - [InlineData("aauth:demo@ap.example.attacker.example", false)] - [InlineData("aauth:demo-evil@ap.example", false)] - public async Task AdminRoles_RequireExactAgentIdentifier(string agentId, bool admin) + [InlineData("aauth:demo@ap.example", "https://ap.example")] + [InlineData("aauth:agent-0123@localhost", "http://localhost:5301")] + [InlineData("aauth:guest@attacker.example", "https://attacker.example")] + public async Task Roles_BelongToThePerson_NotTheAgent(string agentId, string agentIssuer) { var asserter = new SampleIdentityClaimsAsserter(new ConsentStore(), requireConsent: false, - demoRoles: ["calendar.owner"], demoGroups: ["demo-users"], + demoRoles: ["calendar.owner", "wallet.payer"], demoGroups: ["demo-users"], demoUserClaims: new Dictionary()); - var assertion = await asserter.AssertAsync(new IdentityAssertionRequest - { - ResourceUrl = "https://calendar.example", - Scope = "calendar.read", - AgentId = agentId, - AgentIssuer = "https://ap.example", - }); + var assertion = await asserter.AssertAsync(Request(agentId, agentIssuer)); Assert.Equal(IdentityAssertionKind.Assert, assertion.Kind); - Assert.Equal(admin, assertion.Roles is not null); - Assert.Equal(admin, assertion.Groups is not null); + Assert.Equal(["calendar.owner", "wallet.payer"], assertion.Roles); + Assert.Equal(["demo-users"], assertion.Groups); } [Fact] - public async Task AdminRoles_RequireExactAgentIssuer() + public async Task GuestPerson_HasNoRolesOrGroups() { var asserter = new SampleIdentityClaimsAsserter(new ConsentStore(), requireConsent: false, - demoRoles: ["calendar.owner"], demoGroups: ["demo-users"], - demoUserClaims: new Dictionary()); + demoRoles: null, demoGroups: null, demoUserClaims: new Dictionary()); - var assertion = await asserter.AssertAsync(new IdentityAssertionRequest - { - ResourceUrl = "https://calendar.example", - Scope = "calendar.read", - AgentId = "aauth:demo@ap.example", - AgentIssuer = "https://attacker.example", - }); + var assertion = await asserter.AssertAsync(Request("aauth:demo@ap.example", "https://ap.example")); + Assert.Equal(IdentityAssertionKind.Assert, assertion.Kind); Assert.Null(assertion.Roles); Assert.Null(assertion.Groups); } + + private static IdentityAssertionRequest Request(string agentId, string agentIssuer) => new() + { + ResourceUrl = "https://calendar.example", + Scope = "calendar.read", + AgentId = agentId, + AgentIssuer = agentIssuer, + }; } From c2e3c1ae36eb1834d09e02add0204a70b75c41f1 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Thu, 1 Oct 2026 18:08:48 +0000 Subject: [PATCH 4/5] fix(samples): make AgentConsole consent and Concierge chain docs work - AgentConsole prints its AP-assigned agent ID. /admin/consent keys consent on that ID plus the agent's key thumbprint, so the docs now pass both instead of the old aauth:demo@ap.example label. - An explicit trailing "/" now targets the resource root. Before, the documented http://localhost:5200 Concierge call was sent to /events and returned 404. - PS flows now follow a resource's 202 + requirement=interaction, which the Concierge uses to relay downstream consent. - The Concierge README documents the two consent hops. Pre-granting the second hop isn't practical because the Concierge generates a new key on every start. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/workflows/call-chaining.md | 5 ++- samples/AgentConsole/Program.cs | 24 ++++++++---- samples/AgentConsole/README.md | 38 ++++++++++++++----- samples/Concierge/README.md | 27 +++++++------ samples/README.md | 4 +- .../Api/DocumentationInventory.snapshot.md | 27 ++++++------- 6 files changed, 81 insertions(+), 44 deletions(-) diff --git a/docs/workflows/call-chaining.md b/docs/workflows/call-chaining.md index 3cb3959a..525a8275 100644 --- a/docs/workflows/call-chaining.md +++ b/docs/workflows/call-chaining.md @@ -307,10 +307,11 @@ dotnet run --project samples/AgentConsole -- http://localhost:5001/events \ --upstream-token "eyJ..." ``` -Or test the full call chain through the Concierge: +Or test the full call chain through the Concierge (the trailing `/` targets +its root; without it AgentConsole appends its default `/events` path): ```bash -dotnet run --project samples/AgentConsole -- http://localhost:5200 \ +dotnet run --project samples/AgentConsole -- http://localhost:5200/ \ --ap http://localhost:5301 --ps http://localhost:5100 ``` diff --git a/samples/AgentConsole/Program.cs b/samples/AgentConsole/Program.cs index e5aed32b..e8539c74 100644 --- a/samples/AgentConsole/Program.cs +++ b/samples/AgentConsole/Program.cs @@ -159,6 +159,8 @@ agentTokenKid = result.AgentTokenKid; agentJwksUri = result.JwksUri; Console.WriteLine($"Enrolled successfully. Local key handle: {localKeyHandle}"); +// Consent is recorded for the AP-assigned identity, not the --sub cache label. +Console.WriteLine($"Agent ID (AP-assigned): {result.AgentId}"); // Persist only metadata — key lives in the keystore, token is short-lived Directory.CreateDirectory(Path.GetDirectoryName(enrollCacheFile)!); @@ -211,9 +213,14 @@ if (upstreamToken is not null) options.UpstreamTokenProvider = () => upstreamToken; if (resourceManaged) { - // Resource-managed (two-party) opaque-token flow: capture/replay AAuth-Access - // and drive the resource's own consent handshake. + // Resource-managed (two-party) opaque-token flow: capture/replay AAuth-Access. options.EnableResourceManagedAccess = true; + } + if (resourceManaged || personServer is not null) + { + // A resource may itself defer with 202 + requirement=interaction: its own + // consent (resource-managed Inbox) or a downstream hop's consent relayed + // by an intermediary (the Concierge call chain). options.HandleInteractions = true; options.Interaction.MinPollInterval = TimeSpan.FromMilliseconds(200); options.Interaction.OnInteractionRequired = (interaction, ct) => @@ -264,10 +271,12 @@ async Task JktJwtAgentAsync() Console.WriteLine("Upstream token provided for call chaining."); } -// If the target URL has no path (or just "/"), append the signing-mode-specific -// path. The identity-based modes target the Aria Profile server, whose paths -// describe the *outcome* the resource concludes (not the scheme name); the -// default jwt mode targets the Calendar's three-party `/events` endpoint. +// If the target URL has no path at all, append the signing-mode-specific +// path. An explicit trailing "/" (e.g. http://localhost:5200/ for the +// Concierge chain) targets the root instead. The identity-based modes target +// the Aria Profile server, whose paths describe the *outcome* the resource +// concludes (not the scheme name); the default jwt mode targets the +// Calendar's three-party `/events` endpoint. // // SIGNING MODE PROFILE PATH MEANING // hwk → /pseudonymous key thumbprint only (pseudonym) @@ -275,7 +284,8 @@ async Task JktJwtAgentAsync() // jkt-jwt → /anchored ephemeral key anchored to a durable key // jwt → /events three-party Calendar read (calendar.read) var targetUrl = url; -if (url.AbsolutePath is "/" or "") +var typedPath = args[0][(args[0].IndexOf("://", StringComparison.Ordinal) + 3)..]; +if (url.AbsolutePath == "/" && !typedPath.Contains('/')) { targetUrl = resourceManaged ? new Uri(url, "/messages") // resource-managed two-party (Inbox) diff --git a/samples/AgentConsole/README.md b/samples/AgentConsole/README.md index 150f1a07..89fa91fd 100644 --- a/samples/AgentConsole/README.md +++ b/samples/AgentConsole/README.md @@ -34,8 +34,10 @@ dotnet run --project samples/AgentConsole -- --ap [op ## Signing-mode → path mapping -When the target URL has no path (or just `/`), AgentConsole appends the path -that routes to the matching verification pipeline. The pseudonymous and +When the target URL has no path at all (for example `http://localhost:5001`), +AgentConsole appends the path that routes to the matching verification +pipeline. An explicit trailing `/` (for example `http://localhost:5200/`) +targets the root instead. The pseudonymous and agent-identity modes target the **Profile** server (port 5000); the default three-party `jwt` mode targets the **Calendar** server (port 5001); the `--resource-managed` flag targets the **Inbox** server (port 5004): @@ -97,24 +99,42 @@ dotnet run --project samples/AgentConsole -- \ ## Granting consent -The isolated demo admin endpoint can pre-grant consent for the AP-assigned -agent, resource and scope. Replace the illustrative `agent` values below with -the assigned ID printed by enrollment, not the `--sub` local cache label. These are local demo operations, -not production authorization APIs. Normal browser consent binds authenticated -person/session/key/account context; the code alone is not approval. +`make demo` runs the PS with `RequireConsent=true`, so each new +agent/resource/scope prints an interaction URL and a PS dashboard link. +Approve either in a browser and the agent's poll completes. + +To pre-grant instead, use the isolated demo admin endpoint. The PS records +consent for the exact agent, resource, scope and agent key, so copy the two +values AgentConsole prints at startup: + +```text +Agent ID (AP-assigned): aauth:agent-1b98…@localhost +Public JWK thumbprint: Mhbryez6sAJLSDE-pAonOXX1KsaLjjAIinII_G2AaSU +``` + +Use the AP-assigned agent ID, not the `--sub` local cache label. These are +local demo operations, not production authorization APIs. Normal browser +consent binds authenticated person/session/key/account context; the code alone +is not approval. ```bash +AGENT='' +KEY='' + # Baseline / RBAC endpoints use scope "calendar.read" curl -X POST http://localhost:5100/admin/consent \ -H 'content-type: application/json' \ - -d '{"agent":"aauth:demo@ap.example","resource":"http://localhost:5001","scope":"calendar.read"}' + -d "{\"agent\":\"$AGENT\",\"resource\":\"http://localhost:5001\",\"scope\":\"calendar.read\",\"key\":\"$KEY\"}" # The /events/write endpoint requires the elevated scope curl -X POST http://localhost:5100/admin/consent \ -H 'content-type: application/json' \ - -d '{"agent":"aauth:demo@ap.example","resource":"http://localhost:5001","scope":"calendar.write"}' + -d "{\"agent\":\"$AGENT\",\"resource\":\"http://localhost:5001\",\"scope\":\"calendar.write\",\"key\":\"$KEY\"}" ``` +A cached enrollment keeps the same agent ID and key across runs. Clearing it +(`make agent-reset`) creates a new identity that needs fresh consent. + ## Enrollment lifetime AgentConsole caches the local key handle and endpoint metadata, not the token. diff --git a/samples/Concierge/README.md b/samples/Concierge/README.md index 53e7d236..e6c32fbc 100644 --- a/samples/Concierge/README.md +++ b/samples/Concierge/README.md @@ -89,20 +89,25 @@ dotnet run --project samples/Concierge ## Using with AgentConsole ```bash -# Pre-grant consent for both hops -curl -X POST http://localhost:5100/admin/consent \ - -H "Content-Type: application/json" \ - -d '{"agent":"aauth:demo@ap.example","resource":"http://localhost:5200"}' - -curl -X POST http://localhost:5100/admin/consent \ - -H "Content-Type: application/json" \ - -d '{"agent":"aauth:concierge@localhost","resource":"http://localhost:5001"}' - -# Call through the chain -dotnet run --project samples/AgentConsole -- http://localhost:5200 \ +# Call through the chain. The trailing "/" targets the Concierge root; without it +# AgentConsole would append its default /events path. +dotnet run --project samples/AgentConsole -- http://localhost:5200/ \ --ap http://localhost:5301 --ps http://localhost:5100 ``` +Under `make demo` (PS `RequireConsent=true`) the chain asks for consent twice: + +1. **Agent → Concierge** (scope `concierge`): AgentConsole prints a PS + interaction URL and dashboard link. +2. **Concierge → Calendar** (scope `calendar.read`): the Concierge relays the + downstream prompt as its own `202` + `requirement=interaction`, and + AgentConsole prints a `/chain-interaction/...` URL that redirects to the PS. + +Approve each in the browser or on the PS dashboard +(`http://localhost:5100/dashboard`). Pre-granting through `/admin/consent` is +not practical for the second hop: consent is keyed by the agent's key, and +the Concierge generates a new key every time it starts. + ## Key Implementation Details 1. **Self-issued identity**: The Concierge acts as its own AP per spec §Self-Hosted Agents — it publishes agent metadata at `/.well-known/aauth-agent.json` and self-signs agent tokens with its published key. diff --git a/samples/README.md b/samples/README.md index 54172921..7060686a 100644 --- a/samples/README.md +++ b/samples/README.md @@ -312,12 +312,12 @@ dotnet run --project samples/AgentConsole -- http://localhost:5003/wallet/charge > [interaction] Or decide on the PS dashboard: http://localhost:5100/dashboard?code=... > ``` > -> Open either URL in a browser and click **Approve**, or pre-approve programmatically: +> Open either URL in a browser and click **Approve**, or pre-approve programmatically with the `Agent ID (AP-assigned)` and `Public JWK thumbprint` AgentConsole prints at startup (see [Granting consent](AgentConsole/README.md#granting-consent)): > > ```bash > curl -X POST http://localhost:5100/admin/consent \ > -H "Content-Type: application/json" \ -> -d '{"agent":"aauth:demo@ap.example","resource":"http://localhost:5001","scope":"calendar.read"}' +> -d '{"agent":"","resource":"http://localhost:5001","scope":"calendar.read","key":""}' > ``` > > To skip consent entirely, start MockPersonServer separately without the flag: `dotnet run --project samples/MockPersonServer` diff --git a/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md b/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md index 4a140067..799422a5 100644 --- a/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md +++ b/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md @@ -19,7 +19,7 @@ documentation change. ## Complete Inventory -175 files; 707 blocks. Counts by validation class: +175 files; 708 blocks. Counts by validation class: - 32: API excerpt: source member/type/sealed checks; not executable - 1: C# comment-only narrative: reviewed against the associated scenario; no executable statements @@ -28,7 +28,7 @@ documentation change. - 1: External template: Azure.Security.KeyVault.Secrets/Azure.Core required; exportable Ed25519 secrets, not HSM signing; syntax checked only. - 1: External template: OpenTelemetry.Extensions.Hosting and Instrumentation.AspNetCore required; syntax checked, exporter not executed. - 3: Illustrative platform adapter: IWebAuthnService/DeviceCheck are host placeholders; IPlatformAttestor signature checked separately; no hardware or AP challenge/retry claim. -- 20: Illustrative text/HTTP fragment: placeholder bytes, current contract check; not a signed request +- 21: Illustrative text/HTTP fragment: placeholder bytes, current contract check; not a signed request - 200: Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program - 1: JSON member fragment parsed inside an explicit object - 6: JSON parsed; displayed identifiers/claims are illustrative @@ -67,7 +67,7 @@ documentation change. | [docs/signing-modes/overview.md](../../../docs/signing-modes/overview.md) | `c719b15fc2aa113ae9f42ab9acb800ed83c43a2a47c8abdabcb1d40bb30d27e9` | 4 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/signing-modes/pseudonymous-hwk.md](../../../docs/signing-modes/pseudonymous-hwk.md) | `a951e22eb57ab03cb3bb0a6f37bc07e2cdb8775127ae3418434b8fad658f0f51` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/workflows/bootstrap-enrollment.md](../../../docs/workflows/bootstrap-enrollment.md) | `d6fb3d8e957a74483ff27bcd70800b90b53c10dbf7cd8ed7e35b8c7830b63d7b` | 13 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [docs/workflows/call-chaining.md](../../../docs/workflows/call-chaining.md) | `50c47a6e74b60cc3cbce4b4ca569f3018ed0c7e42ae0d8caa14609d95915a4c1` | 14 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [docs/workflows/call-chaining.md](../../../docs/workflows/call-chaining.md) | `123f4e83e3bbcc43bae6ef206a14bd639d6a3ef676e05e44af8420451b55ed95` | 14 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/workflows/catalog-gateway.md](../../../docs/workflows/catalog-gateway.md) | `a57683c9ce79bb71de2e51a1f9f51f12cd5cdfdf5c09db0e8bc1bc349b20118a` | 1 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/workflows/deferred-consent.md](../../../docs/workflows/deferred-consent.md) | `731465d3976bbb3f47f969277ff12e8a522f6ae4bca339cd0f69a1574f99afae` | 7 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/workflows/document-release.md](../../../docs/workflows/document-release.md) | `3462f5f4fbd3dbe1ee6102ddb67ea4452204c720f6dfd9ab95fb5e8d48dbcda0` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | @@ -80,7 +80,7 @@ documentation change. | [docs/workflows/rich-resource-requests.md](../../../docs/workflows/rich-resource-requests.md) | `7e18bd12fbf469d713f7b65fabdba12d69b9067346fa60bce95fca6de1f79ecb` | 4 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/workflows/wallet-protocol.md](../../../docs/workflows/wallet-protocol.md) | `071381a50e1fe01db06b152d092507f621782f160c4f429e2ed3666b607fb893` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [README.md](../../../README.md) | `f3706a4dd020ac18cfb1d909c32605732c9be4c3afcda18838bbbe9fdf92fb26` | 10 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/AgentConsole/README.md](../../../samples/AgentConsole/README.md) | `bb4a8aaf5ee01a27b2ee5c1d4a0c3f1e8679d808ad53341779c7b1035579cc37` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/AgentConsole/README.md](../../../samples/AgentConsole/README.md) | `bed0f9559be905f97abf79c5304c681a11e2f2cc64102984236e6016d62e2c67` | 4 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/CapabilitySupport/CatalogDemoSession.cs](../../../samples/CapabilitySupport/CatalogDemoSession.cs) | `54745a6dee95a60e7c368d16c8a339145c02451adbd2328059a3f00d69bc0db0` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/CapabilitySupport/CatalogWalkthrough.razor](../../../samples/CapabilitySupport/CatalogWalkthrough.razor) | `74a82a32bde728372990369f6666b9abf1b7ed99b5608ccd2b315d5fa7e1ad02` | 7 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/CapabilitySupport/DocumentDemoSession.cs](../../../samples/CapabilitySupport/DocumentDemoSession.cs) | `3cabc9b58632d8343c7cdd3d0dac00219cb6a2f7a37c036af39841b24b2876bd` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | @@ -89,7 +89,7 @@ documentation change. | [samples/CapabilitySupport/WalletDemoSession.cs](../../../samples/CapabilitySupport/WalletDemoSession.cs) | `9d741fc6ec7f3d5e503e3f990eafe731317bc9fee1b5e478e66a2845ac33280a` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/CapabilitySupport/WalletScenarioCode.cs](../../../samples/CapabilitySupport/WalletScenarioCode.cs) | `74e1af5dedc6c3385b552f192e521684ba407064bef541dc124a194911ddd7cc` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/CapabilitySupport/WalletWalkthrough.razor](../../../samples/CapabilitySupport/WalletWalkthrough.razor) | `048e60297c2190da7265394bc9d5a6c833f1cc59bc250abbd241fcec288415b5` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/Concierge/README.md](../../../samples/Concierge/README.md) | `d71451f3e3e4cbf9274272e1b7badf8dd31b979f7882adb0a412bed5483cbe07` | 5 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/Concierge/README.md](../../../samples/Concierge/README.md) | `ece877362b86dc3902c469dde5e94c616201ffcc7db1a6801b60ffc575f3dff3` | 5 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/EventAgent/README.md](../../../samples/EventAgent/README.md) | `f08fcb576a8ffbe4e39b6171a09a416ebea1f11f3704253814f70c34c00789b1` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/EventSupport/BookingsEvents.cs](../../../samples/EventSupport/BookingsEvents.cs) | `41f85f55c0e6e1249f8df21293bfe6c9ffc6a77f5d8fb82b5a6334fbdfdce5fd` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/EventSupport/EventDemoCode.cs](../../../samples/EventSupport/EventDemoCode.cs) | `f01c558744ac72c61946bc9b6241b9d54b0e080b072470af3fa266ca1b462fd8` | 7 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | @@ -159,7 +159,7 @@ documentation change. | [samples/MockResourceServers/README.md](../../../samples/MockResourceServers/README.md) | `384560afe7b5ac16ae5377e86084865d1d20103adc5905b3c53af9d8ac2d4ce0` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/Trips/README.md](../../../samples/MockResourceServers/Trips/README.md) | `cfc34f623eef77ec41c54959f1f7c13f251c0679e5956f7b4e620e457c800666` | 1 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockResourceServers/Wallet/README.md](../../../samples/MockResourceServers/Wallet/README.md) | `da78f3a77b29f53b3fea3c11aa49866b8028d8bd7199073fb221924c57c5cabb` | 2 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/README.md](../../../samples/README.md) | `bb745a03cb3d9d2a0d9fe5a16a368ccaf0d419ff08cdbbac8befb090d2882ef2` | 25 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/README.md](../../../samples/README.md) | `55869778658388717330a370fda744df37fe732318fe81d330fdf8c855549d52` | 25 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/_Imports.razor](../../../samples/SampleApp/Components/_Imports.razor) | `b7b03c630e075d1c783acff669ceb9e9de268daf31740a988a4f5945f477c030` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/App.razor](../../../samples/SampleApp/Components/App.razor) | `e28bc9f11a4329d2689a64520db6550c34aaf9c042c478ef46b88398fe6e707a` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/ConsentProgress.razor](../../../samples/SampleApp/Components/ConsentProgress.razor) | `7b525cdb5ea92267e0b5ea5d8ff1196694e203ff18459338f8f86f0952143ff4` | 1 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | @@ -457,9 +457,9 @@ documentation change. | [docs/workflows/call-chaining.md:fence-9](../../../docs/workflows/call-chaining.md#L268) | json | `d2000275cef2cb3f874f046d72b6ae1f2e6b1c5969be4200277c1cf57ccb44ea` | JSON parsed; displayed identifiers/claims are illustrative | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [docs/workflows/call-chaining.md:fence-10](../../../docs/workflows/call-chaining.md#L283) | csharp | `2c6bdef05c55da2226972582700f89b8fb025067727cb5b1416f0e2bdfede533` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [docs/workflows/call-chaining.md:fence-11](../../../docs/workflows/call-chaining.md#L304) | bash | `584c7ab06338eba6c7c88794909d17a2f2dbc4fca04948a7eb4a17c45f5c5b1d` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [docs/workflows/call-chaining.md:fence-12](../../../docs/workflows/call-chaining.md#L312) | bash | `35620666721b67e7a7edbce46bfc3b8451b13f68f922020b71826907b6890d95` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [docs/workflows/call-chaining.md:fence-13](../../../docs/workflows/call-chaining.md#L336) | csharp | `7c9633bdfee513e0cdeea7531550937d780732c875209af5fd02c568f47d3b83` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [docs/workflows/call-chaining.md:fence-14](../../../docs/workflows/call-chaining.md#L387) | csharp | `92d695d50666ca53491f029fe04428c8f10f46713315a0361657ca70c55cb62c` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [docs/workflows/call-chaining.md:fence-12](../../../docs/workflows/call-chaining.md#L313) | bash | `a31e747613effa9e519d05ddeee1a2b51e44da3933d3bdac9b434e4294be8a0e` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [docs/workflows/call-chaining.md:fence-13](../../../docs/workflows/call-chaining.md#L337) | csharp | `7c9633bdfee513e0cdeea7531550937d780732c875209af5fd02c568f47d3b83` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [docs/workflows/call-chaining.md:fence-14](../../../docs/workflows/call-chaining.md#L388) | csharp | `92d695d50666ca53491f029fe04428c8f10f46713315a0361657ca70c55cb62c` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [docs/workflows/catalog-gateway.md:fence-1](../../../docs/workflows/catalog-gateway.md#L34) | mermaid | `367fcb5803b1bbcd08d973b2d25567d165ad725961c4a99d45fe8529be5bdf0a` | Sequence/flow source checked; actors/order reviewed against mapped scenario | [Catalog session](../../../samples/CapabilitySupport/CatalogDemoSession.cs), [both-app Catalog wrapper](../../../tests/e2e/helpers/catalog.ts), R3VocabularyTests | | [docs/workflows/deferred-consent.md:fence-1](../../../docs/workflows/deferred-consent.md#L7) | mermaid | `facef28cce19d0488885dfdbad9bb2740c257e19a58c435313cd0ea27338f235` | Sequence/flow source checked; actors/order reviewed against mapped scenario | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [docs/workflows/deferred-consent.md:fence-2](../../../docs/workflows/deferred-consent.md#L35) | http | `8dc00c28314748acf8f17c808a49e1eb44a414fd9926c7f6ebd54bef770d3149` | Illustrative text/HTTP fragment: placeholder bytes, current contract check; not a signed request | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | @@ -519,8 +519,9 @@ documentation change. | [README.md:fence-9](../../../README.md#L254) | csharp | `3a08c889801aad072136008295575e90391eace51459c0cf8432e440a3fec6c3` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [README.md:fence-10](../../../README.md#L301) | bash | `be9b05c3b3e1ca81497243c1dd96e2bdecc5e5d6dbea5feecf5ed021abe834a0` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/AgentConsole/README.md:fence-1](../../../samples/AgentConsole/README.md#L21) | bash | `da6dcde217b6c8f0a2407ed7d9108862f287daee9657046af43413a46152c472` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/AgentConsole/README.md:fence-2](../../../samples/AgentConsole/README.md#L60) | bash | `4bc83c04446d79ae52b66feb135ac5e585eff02dc792bec84e53a7960a042c67` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/AgentConsole/README.md:fence-3](../../../samples/AgentConsole/README.md#L106) | bash | `eaf7d044a746e16e1235bdde143fc734a158006b49ff1625372406396c57dbd1` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/AgentConsole/README.md:fence-2](../../../samples/AgentConsole/README.md#L62) | bash | `4bc83c04446d79ae52b66feb135ac5e585eff02dc792bec84e53a7960a042c67` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/AgentConsole/README.md:fence-3](../../../samples/AgentConsole/README.md#L110) | text | `ba4287988fc2717865919c866169cd0c6a61c522147e7d886c9bb4fa6256fb17` | Illustrative text/HTTP fragment: placeholder bytes, current contract check; not a signed request | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/AgentConsole/README.md:fence-4](../../../samples/AgentConsole/README.md#L120) | bash | `2d70dd15c45ffc7375e1dfa6e3242b78bb93f6c4064a61a343c29851872a71b9` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/CapabilitySupport/CatalogWalkthrough.razor:pre-1](../../../samples/CapabilitySupport/CatalogWalkthrough.razor#L61) | dynamic | `f6681b8cd4bf54cf386ff594c5b078bad752d3962ddaa6f7a7ba49c5783c4108` | Dynamic Razor binding: build plus mapped scenario browser/captured-wire tests | [Catalog session](../../../samples/CapabilitySupport/CatalogDemoSession.cs), [both-app Catalog wrapper](../../../tests/e2e/helpers/catalog.ts), R3VocabularyTests | | [samples/CapabilitySupport/CatalogWalkthrough.razor:pre-2](../../../samples/CapabilitySupport/CatalogWalkthrough.razor#L66) | dynamic | `4c53b6a936934792c2af6215e2db6b9e045011808ac175948963fbddaf0ce6a5` | Dynamic Razor binding: build plus mapped scenario browser/captured-wire tests | [Catalog session](../../../samples/CapabilitySupport/CatalogDemoSession.cs), [both-app Catalog wrapper](../../../tests/e2e/helpers/catalog.ts), R3VocabularyTests | | [samples/CapabilitySupport/CatalogWalkthrough.razor:pre-3](../../../samples/CapabilitySupport/CatalogWalkthrough.razor#L69) | dynamic | `633543aca320bce45c6e82b8d3aa56136e73f9de186c0a154d2361f43a6245af` | Dynamic Razor binding: build plus mapped scenario browser/captured-wire tests | [Catalog session](../../../samples/CapabilitySupport/CatalogDemoSession.cs), [both-app Catalog wrapper](../../../tests/e2e/helpers/catalog.ts), R3VocabularyTests | @@ -545,7 +546,7 @@ documentation change. | [samples/Concierge/README.md:fence-2](../../../samples/Concierge/README.md#L47) | json | `77cccae9185619b0b7b5743042b54017a62a6364298ef22f1a181fab50344459` | JSON parsed; displayed identifiers/claims are illustrative | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/Concierge/README.md:fence-3](../../../samples/Concierge/README.md#L68) | bash | `46f8207a3d5d546392406e73b274d1452d11ae3ba32335923e51aec841a493b3` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/Concierge/README.md:fence-4](../../../samples/Concierge/README.md#L74) | bash | `a9d9984ea1a54ebd2e00a96a2fbf0b35b241a95ba3daf27b5dff57cef5193177` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/Concierge/README.md:fence-5](../../../samples/Concierge/README.md#L91) | bash | `959040f0ddf0c8885eecc319426933702020aa35b7c7317eb3ca803c8322bda3` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/Concierge/README.md:fence-5](../../../samples/Concierge/README.md#L91) | bash | `6a5363709a4adb9fe98479d418af1f9da5894b20e2fc7a7846ed1c1a99d835ec` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/EventSupport/EventDemoCode.cs:Discover-1](../../../samples/EventSupport/EventDemoCode.cs#L6) | csharp | `5b258ce7cddd75784a45739428d11b903ad67a81a4382885b2886c2eb08a45c5` | Exact C# compiled with typed prior-step/host inputs | [Event session](../../../samples/EventSupport/EventDemoSession.cs), [both-app Events wrapper](../../../tests/e2e/helpers/events.ts), EventHttpTests / EventPersistenceTests | | [samples/EventSupport/EventDemoCode.cs:SubscriptionUrl-2](../../../samples/EventSupport/EventDemoCode.cs#L20) | csharp | `cde3f3c7649cbcdc775940aaa1107b23c68c01ab6d6c732b878c6c6db76a1b38` | Exact C# compiled with typed prior-step/host inputs | [Event session](../../../samples/EventSupport/EventDemoSession.cs), [both-app Events wrapper](../../../tests/e2e/helpers/events.ts), EventHttpTests / EventPersistenceTests | | [samples/EventSupport/EventDemoCode.cs:SubscribeToken-3](../../../samples/EventSupport/EventDemoCode.cs#L44) | csharp | `b9d46a407f0f8959746908cb93d0f2520b5189a40588b3ccbefafa162441c7bf` | Exact C# compiled with typed prior-step/host inputs | [Event session](../../../samples/EventSupport/EventDemoSession.cs), [both-app Events wrapper](../../../tests/e2e/helpers/events.ts), EventHttpTests / EventPersistenceTests | @@ -763,7 +764,7 @@ documentation change. | [samples/README.md:fence-16](../../../samples/README.md#L296) | bash | `12e0b452b94aa11c170fda876cc292cbaf09dae6d866a1a999cff725c83caf50` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/README.md:fence-17](../../../samples/README.md#L303) | bash | `83238081ecaf4bd0cedbbba71f255837877b6616abfef5690d922265ca760f56` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/README.md:fence-18](../../../samples/README.md#L310) | | `e67e2bfc97e30b3dcb7664e75f45a04a3291072f9e7d35e9218698e9cfd59143` | Illustrative text/HTTP fragment: placeholder bytes, current contract check; not a signed request | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [samples/README.md:fence-19](../../../samples/README.md#L317) | bash | `57d37b25e0c69f2937389103209b47d1afdd0921ded21c9ced1e56b125d0abb4` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [samples/README.md:fence-19](../../../samples/README.md#L317) | bash | `f096cc417bb361e1c66c26c0a6eab86c14fb5df9c9b90bfc90d32c904d6c9350` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/README.md:fence-20](../../../samples/README.md#L337) | bash | `686dc78fa36af733b25698df0c76b97922036ec767388988bc60f99f2cc0c37d` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/README.md:fence-21](../../../samples/README.md#L345) | bash | `016d1fa45df9833e0ba49e8da141e5bee17880475b58e33588eee4276e2a00d8` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [samples/README.md:fence-22](../../../samples/README.md#L353) | bash | `4478cd8fcf98e455297048a70cec99c07521e8e69d842c61bd638e0e8fe88bc7` | Shell syntax checked; side-effect commands not executed | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | From ca105e29eb16a4107cda18d1ca7789d1f6cec2c2 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Thu, 1 Oct 2026 18:48:56 +0000 Subject: [PATCH 5/5] fix(sdk): keep chained downstream requests alive instead of re-sending them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interaction chaining aborted the intermediary's downstream token exchange on the first 202 and re-ran the whole chain on every caller poll. Each re-run sent a new token request to the PS, so one AgentConsole run through the Concierge left ~80 duplicate consent requests. Draft-11 §Interaction Chaining: the intermediary "completes the original request and returns the result at its pending URL" once it obtains the downstream token. §Polling with GET: after a 202 the agent "switches to GET for all subsequent requests to the Location URL and does not resend the original request". - AAuthChainedOperation runs the downstream work in the background. Its per-request interaction handler records the downstream interaction and returns normally, so the SDK keeps polling the downstream Location with GET. It publishes versioned interaction snapshots, cancels on expiry, host shutdown or Cancel(), and observes failures nobody awaits. - AAuthRequestOptions.UpstreamToken passes the upstream token per request, so downstream work no longer needs the inbound HttpContext. - AAuthChainedInteractions: Park(Interaction), Rekey for a new downstream step (new code, same id/Location), and PollingFailure mapping downstream outcomes to §Polling Error Codes. - A request with its own interaction handler no longer joins another request's in-flight token acquisition, which would never call its handler. - Concierge (/, /mission, /wallet): polls read the operation's state and never re-run the chain. Entries re-key atomically, earlier codes stay valid, and DELETE cancels the downstream work. - Docs, SampleApp CallChain snippet and GuidedTour text describe the new flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/advanced/interaction-chaining.md | 261 ++++++++-------- samples/Concierge/PendingStore.cs | 112 +++++-- samples/Concierge/Program.cs | 187 ++++++------ samples/GuidedTour/TourSession.cs | 7 +- .../Components/Pages/CallChain.razor | 51 ++-- src/AAuth/Agent/AAuthRequestOptions.cs | 7 + src/AAuth/Agent/AAuthTokenHolder.cs | 17 +- src/AAuth/Agent/ChallengeHandler.cs | 3 +- src/AAuth/Agent/MissionForwardingHandler.cs | 3 +- .../CallChaining/ChainedInteractions.cs | 64 +++- .../Server/CallChaining/ChainedOperation.cs | 133 ++++++++ .../Agent/ChainedOperationTests.cs | 286 ++++++++++++++++++ .../Api/DocumentationInventory.snapshot.md | 26 +- .../ConciergePendingSecurityTests.cs | 42 +++ 14 files changed, 905 insertions(+), 294 deletions(-) create mode 100644 src/AAuth/Server/CallChaining/ChainedOperation.cs create mode 100644 tests/AAuth.Tests/Agent/ChainedOperationTests.cs diff --git a/docs/advanced/interaction-chaining.md b/docs/advanced/interaction-chaining.md index 495fe7d1..84ee7fdc 100644 --- a/docs/advanced/interaction-chaining.md +++ b/docs/advanced/interaction-chaining.md @@ -18,10 +18,17 @@ and the host's `ResourceInteractionSessions` configuration contract. The intermediary returns its own pending `Location`, its own interaction URL, and its own interaction code. The user visits the intermediary interaction URL, which validates the intermediary code and redirects the browser to the -downstream PS/AS interaction. The sample aborts the downstream exchange on -interaction and re-drives it when the original caller polls, rather than -retaining a downstream poll connection. -See [Interaction Chaining](../../aauth-spec/v11/draft-hardt-oauth-aauth-protocol.md#interaction-chaining). +downstream PS/AS interaction. "When the user completes interaction and the +resource obtains the downstream auth token, the resource completes the original +request and returns the result at its pending URL." + +Toward the downstream PS the intermediary is the agent, so §Polling with GET +applies: "After receiving a `202`, the agent switches to `GET` for all +subsequent requests to the `Location` URL and does not resend the original +request body." The intermediary keeps polling the downstream pending URL; it +never re-sends the downstream token request when its own caller polls. +See [Interaction Chaining](../../aauth-spec/v11/draft-hardt-oauth-aauth-protocol.md#interaction-chaining) +and [Deferred Responses](../../aauth-spec/v11/draft-hardt-oauth-aauth-protocol.md#deferred-responses). ## Flow Diagram @@ -34,7 +41,7 @@ sequenceDiagram A->>C: request (auth token) C->>PS: exchange for downstream auth token - PS-->>C: 202 + requirement=interaction + PS-->>C: 202 + Location + requirement=interaction C-->>A: 202 + own Location, own interaction URL/code A->>U: open Concierge interaction URL in browser @@ -42,43 +49,49 @@ sequenceDiagram C-->>U: redirect to downstream PS interaction URL/code U->>PS: complete consent - loop poll until resolved - A->>C: GET Location (pending URL) - C->>PS: re-drive downstream exchange - PS-->>C: still pending / auth token - C-->>A: 202 (still pending) + par Concierge polls the downstream + loop until resolved + C->>PS: GET downstream Location + PS-->>C: 202 (pending) / 200 auth token + end + Note over C: retries the downstream call with the auth token + and Agent A polls the Concierge + loop until resolved + A->>C: GET own Location + C-->>A: 202 (still pending) + end end - Note over C,PS: Concierge obtains the downstream auth token,
retries the downstream call + A->>C: GET own Location C-->>A: 200 (final result) ``` -## SDK Support: throw `AAuthInteractionChainedException` +## SDK Support: `AAuthChainedOperation` -When the downstream PS/AS requires consent, the intermediary's exchange surfaces an -`onInteractionRequired` callback. The intermediary cannot block and poll on the caller's -behalf — there is no user attached to the inbound request to relay the consent URL to. -Instead, the callback **throws** `AAuthInteractionChainedException` to abort the exchange -*before* the SDK starts its blocking poll. The endpoint catches that exception, parks the -flow, and re-emits its **own** `202 Accepted` to the caller: +`AAuthChainedOperation` runs the intermediary's downstream work and +returns as soon as it either finishes or first needs downstream user +interaction. Attach its `InteractionHandler` to each downstream request: when the +downstream answers `202` + `requirement=interaction`, the handler records the +interaction and returns normally, so the SDK keeps polling the downstream +`Location` with `GET` in the background. The endpoint parks the operation and +answers with its **own** `202`: ```csharp -async Task RunChainAsync(HttpContext ctx, string upstreamToken) +async Task RunChainAsync(string upstream, IAAuthInteractionHandler interactions, CancellationToken ct) { using var downstream = AAuthClientBuilder.SelfIssuing(conciergeKey) - .As(conciergeUrl, agentId) + .As(conciergeUrl, intermediaryAgentId) .WithKid(conciergeKid) - .WithPersonServer(psUrl) - .WithCallChaining(upstreamToken) - .WithChallengeHandling(opts => - { - // No user to relay to — abort the exchange and re-emit upward. - opts.OnInteractionRequired = (interaction, _) => - throw new AAuthInteractionChainedException(interaction); - }) + .WithPersonServer(ps) + .WithCallChaining(upstream) + .WithChallengeHandling(opts => opts.Capabilities = []) .Build(); - var response = await downstream.GetAsync($"{downstreamUrl}/events"); - var body = await response.Content.ReadFromJsonAsync(); + // The operation outlives this inbound request: use only captured values + // and the operation's cancellation token, never the request's HttpContext. + using var request = new HttpRequestMessage(HttpMethod.Get, downstreamUrl); + request.Options.Set(AAuthRequestOptions.InteractionHandler, interactions); + using var response = await downstream.SendAsync(request, ct); + var body = await response.Content.ReadFromJsonAsync(ct); return Results.Ok(new { chain = "ok", downstream = body }); } @@ -87,28 +100,33 @@ app.MapGet("/", async (HttpContext ctx, PendingStore pending) => var upstream = ctx.Features.Get()?.Token; if (upstream is null) return Results.Unauthorized(); - try - { - return await RunChainAsync(ctx, upstream); - } - catch (AAuthInteractionChainedException ex) - { - // Downstream needs consent. Park serializable operation state and the - // downstream interaction, then re-emit our OWN 202 to the caller. - var chained = AAuthChainedInteractions.Park( - conciergeUrl, "/pending", "/chain-interaction", ex, - "calendar.events", new JsonObject { ["path"] = "/events" }, - DateTimeOffset.UtcNow.AddMinutes(10)); - var entry = pending.Add(upstream, chained); - return ReEmitChainedInteraction(ctx, entry); - } + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(10); + var operation = await AAuthChainedOperation.StartAsync( + (interactions, ct) => RunChainAsync(upstream, interactions, ct), + expiresAt, app.Lifetime.ApplicationStopping); + if (operation.Completion.IsCompleted) + return await operation.Completion; + + // Downstream needs consent and is being polled. Park the operation under + // an intermediary-owned code and pending URL, then answer with our OWN 202. + // Read the interaction once; the operation may publish a newer one meanwhile. + var snapshot = operation.Interaction!; + var chained = AAuthChainedInteractions.Park( + conciergeUrl, "/pending", "/chain-interaction", snapshot.Downstream, + "calendar.events", new JsonObject { ["path"] = "/events" }, expiresAt); + var entry = pending.Add(upstream, chained, "/pending", operation, snapshot.Version); + return ReEmitChainedInteraction(ctx, entry); }); ``` -Throwing from the callback is what makes this work: the exchange wraps the callback in -`try { await onInteractionRequired(...) } finally { ... }` with **no** `catch`, so the -exception unwinds before `DeferredPoller.PollAsync` runs. There is no blocked poll and no -double-write to the response. +When the downstream request can outlive the inbound request, pass the upstream +token explicitly (`WithCallChaining(upstream)` as above, or +`AAuthRequestOptions.UpstreamToken` on the request for an agent registered with +`ChainFromHttpContext`) rather than reading it from the inbound `HttpContext`. + +`AAuthChainedOperation` is an in-memory coordinator: on restart the operation is +lost while the downstream PS may still hold its pending request. Persist the +parked entry durably if callers must survive restarts. ### Re-emitting the chained 202 @@ -123,17 +141,26 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry) app.MapGet("/chain-interaction/{id}", (string id, string? code, PendingStore pending) => { var entry = pending.Get(id); - if (entry is null || !AAuthInteractionCode.Matches(entry.Interaction.Code, code ?? "")) + if (entry is null || !entry.MatchesCode(code)) return AAuthProblemDetails.Polling(PollingErrorCode.InvalidCode); return AAuthChainedInteractions.RedirectToDownstream(entry.Interaction); }); ``` -### Resuming at the poll endpoint +If the downstream moves to a new interaction (for example an Access Server step +after Person Server consent), `operation.Interaction.Version` increases. Re-key +the parked entry with `AAuthChainedInteractions.Rekey` — a new intermediary +code, the same id and pending URL — so the caller's interaction handler surfaces +the new URL. Keep earlier codes valid and redirect them to the latest step. -When the agent polls `/pending/{id}`, the intermediary retries the chain. If consent has -been granted the exchange now succeeds and the final result is returned; if it is still -pending the same chained `202` is re-emitted; a denial maps to `403`: +### Answering polls from the operation + +When the agent polls `/pending/{id}`, the intermediary reads the operation's +state. It never re-runs the chain: while the downstream is pending it re-emits +its `202`; once the operation finishes it returns the result, or maps a +downstream denial, expiry or revocation to the matching §Polling Error Codes +response with `AAuthChainedInteractions.PollingFailure`. `DELETE` cancels the +background operation: ```csharp app.MapMethods("/pending/{id}", ["GET", "DELETE"], async (HttpContext ctx, string id, PendingStore pending) => @@ -147,24 +174,24 @@ app.MapMethods("/pending/{id}", ["GET", "DELETE"], async (HttpContext ctx, strin { if (HttpMethods.IsDelete(ctx.Request.Method)) { + entry.Operation?.Cancel(); entry.Lifecycle.Cancel(); return Results.NoContent(); } - try { return await RunChainAsync(ctx, entry.UpstreamToken); } - catch (AAuthInteractionChainedException) { return ReEmitChainedInteraction(ctx, entry); } - catch (AAuthInteractionDeniedException) - { - return AAuth.Server.AAuthProblemDetails.Create("denied", statusCode: 403); - } + if (entry.Operation is not { Completion.IsCompleted: true } operation) + return ReEmitChainedInteraction(ctx, entry); + try { return await operation.Completion; } + catch (Exception ex) when (AAuthChainedInteractions.PollingFailure(ex) is { } failure) { return failure; } }); }); ``` -> **Why not write the `202` from inside the callback?** Returning normally from -> `onInteractionRequired` tells the SDK to *block and poll* for the downstream token. An -> intermediary has no user to wait on, so it would hang for the full polling budget and -> then try to complete a response the endpoint may have already written. Throwing -> `AAuthInteractionChainedException` is the correct, non-blocking abort. +> **Why not throw from the callback?** `AAuthInteractionChainedException` still +> aborts an exchange before it polls, for an intermediary that cannot keep work +> running between requests. Aborting abandons the downstream pending request, so +> finishing later means sending a new token request, and each one asks the user +> again. Prefer `AAuthChainedOperation`, which keeps the single downstream request +> and polls it as the spec requires. ## Agent side: surfacing the chained 202 @@ -198,56 +225,44 @@ straight through unless `WithInteractionHandling` is also configured. ## Manual Pattern (Without Builder) -For full control over the interaction-chaining flow using `CallChainingHandler` directly, -apply the same throw-to-abort rule inside the `onInteractionRequired` callback. The -intermediary first requests a downstream person token with the caller's token as -`upstream_token` (at the PS that token names), presents it downstream, and passes the -resulting resource token **and** that person token (`presentedToken`) to the exchange: +`CallChainingHandler` works the same way: run it inside +`AAuthChainedOperation.StartAsync` and pass the operation's handler as +`onInteractionRequired`. The intermediary first requests a downstream person +token with the caller's token as `upstream_token` (at the PS that token names), +presents it downstream, and passes the resulting resource token **and** that +person token (`presentedToken`) to the exchange: ```csharp -app.MapGet("/", async (HttpContext ctx, PendingStore pending) => +async Task ExchangeDownstreamAsync(string upstream, IAAuthInteractionHandler interactions, CancellationToken ct) { - var upstream = ctx.Features.Get()!; var chainHandler = new CallChainingHandler(exchangeClient, chainingOptions); - try - { - // Person token for the downstream resource, requested under the upstream token. - var downstreamPersonToken = await exchangeClient.RequestPersonTokenAsync( - CallChainingRouter.ResolveDownstreamServer(upstream.Token, exchangeClient.EgressPolicy), - downstreamResource, - new TokenExchangeRequest { UpstreamToken = upstream.Token }); - - // ...present downstreamPersonToken downstream; its 401 carries resourceToken... - var chainedToken = await chainHandler.ExchangeForDownstreamAsync( - upstream.Token, - resourceToken, - downstreamPersonToken, - onInteractionRequired: (interaction, _) => - // Abort before the blocking poll; the endpoint re-emits its own 202. - throw new AAuthInteractionChainedException(interaction), - pollerOptions: new DeferredPollerOptions - { - MaxTotalWait = TimeSpan.FromMinutes(5), - PreferWaitSeconds = 45, - }); - - // Exchange succeeded — call downstream with the chained token. - using var client = new AAuthClientBuilder(myKey) - .UseJwt(chainedToken) - .Build(); - return Results.Ok(await client.GetFromJsonAsync(downstreamUrl)); - } - catch (AAuthInteractionChainedException ex) - { - var chained = AAuthChainedInteractions.Park( - "https://intermediary.example", "/pending", "/chain-interaction", ex, - "downstream.read", new JsonObject { ["resource"] = downstreamUrl }, - DateTimeOffset.UtcNow.AddMinutes(10)); - var entry = pending.Add(upstream.Token, chained); - return ReEmitChainedInteraction(ctx, entry); - } -}); + // Person token for the downstream resource, requested under the upstream token. + var downstreamPersonToken = await exchangeClient.RequestPersonTokenAsync( + CallChainingRouter.ResolveDownstreamServer(upstream, exchangeClient.EgressPolicy), + downstreamResource, + new TokenExchangeRequest { UpstreamToken = upstream }, + ct); + + // ...present downstreamPersonToken downstream; its 401 carries resourceTokenJwt... + // A downstream 202 + requirement=interaction is recorded by the operation and + // then polled with GET until the user decides. + return await chainHandler.ExchangeForDownstreamAsync( + upstream, + resourceTokenJwt, + downstreamPersonToken, + onInteractionRequired: interactions.OnInteractionRequiredAsync, + pollerOptions: new DeferredPollerOptions + { + MaxTotalWait = TimeSpan.FromMinutes(5), + PreferWaitSeconds = 45, + }, + cancellationToken: ct); +} + +var operation = await AAuthChainedOperation.StartAsync( + (interactions, ct) => ExchangeDownstreamAsync(upstreamToken, interactions, ct), + DateTimeOffset.UtcNow.AddMinutes(10)); ``` > **Note:** With `PreferWaitSeconds` set on a directly constructed `TokenExchangeClient`/`DeferredPoller`, ensure the underlying `HttpClient.Timeout` is greater than `PreferWaitSeconds` (or `Timeout.InfiniteTimeSpan`). A default `HttpClient` (100s timeout) would abort the in-flight long-poll with a `TaskCanceledException`. Clients built via `AAuthClientBuilder` already use `Timeout.InfiniteTimeSpan`. @@ -256,17 +271,21 @@ app.MapGet("/", async (HttpContext ctx, PendingStore pending) => The intermediary must manage pending requests: -1. **Store**: When `onInteractionRequired` fires, store the operation name, - JSON state, and downstream interaction details behind an intermediary-owned - code (`AAuthChainedInteractions.Park` returns this serializable entry). -2. **Poll endpoint**: Expose a `/pending/{id}` endpoint that the original agent polls. -3. **Background completion**: When user consent completes, the downstream PS issues the token. The intermediary completes the original request. -4. **Cleanup**: Expire stale pending requests. - -The SDK owns the wire mechanics for the chained `202`, code generation, and -downstream redirect. Applications still own durable persistence and operation -resume policy because different architectures (stateless, queue-backed, -actor-based) need different stores. +1. **Store**: When the operation first needs interaction, store it with the + operation name, JSON state and downstream interaction behind an + intermediary-owned code (`AAuthChainedInteractions.Park` returns this entry). +2. **Poll endpoint**: Expose a `/pending/{id}` endpoint that the original agent + polls; answer it from the operation's state. +3. **Background completion**: The operation keeps polling the downstream pending + URL. When the user consents, the downstream PS issues the token and the + operation completes the original request. +4. **Cleanup**: Cancel the operation on `DELETE`, at expiry and on host + shutdown, and expire stale pending entries. + +The SDK owns the wire mechanics for the chained `202`, code generation, +downstream redirect and downstream polling. Applications still own durable +persistence and resume policy because different architectures (stateless, +queue-backed, actor-based) need different stores. ## See Also diff --git a/samples/Concierge/PendingStore.cs b/samples/Concierge/PendingStore.cs index 9d7b8631..6a732de9 100644 --- a/samples/Concierge/PendingStore.cs +++ b/samples/Concierge/PendingStore.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using AAuth.Server; using AAuth.Server.CallChaining; +using Microsoft.AspNetCore.Http; using Microsoft.IdentityModel.Tokens; namespace Concierge; @@ -13,53 +14,102 @@ namespace Concierge; /// /// When the Concierge's downstream token exchange returns /// 202 requirement=interaction, the Concierge (which has no user of -/// its own) cannot relay the interaction. Instead it persists an entry here, -/// re-emits its own 202 to the caller with -/// Location=/pending/{id} and an intermediary interaction URL. That URL -/// redirects the browser to the downstream PS interaction; the caller polls -/// GET /pending/{id} until consent resolves. +/// its own) keeps polling the downstream pending URL in the background +/// () and stores an entry here. It +/// answers the caller with its own 202, Location=/pending/{id} +/// and an intermediary interaction URL that redirects the browser to the +/// downstream interaction. The caller's polls read the operation's state; they +/// never re-send the downstream request. /// A production intermediary would persist these durably and expire them -/// on a timer; this demo store is in-memory and never GCs. +/// on a timer; this demo store is in-memory. /// public sealed class PendingStore { - public sealed record Entry( - string Id, - string UpstreamToken, - ChainedInteractionEntry Interaction, - string DownstreamBase, - string DownstreamPath, - string PendingPrefix) + public sealed class Entry { - public DateTimeOffset ExpiresAt { get; } = DateTimeOffset.FromUnixTimeSeconds( - JsonNode.Parse(Base64UrlEncoder.DecodeBytes(UpstreamToken.Split('.')[1]))!["exp"]!.GetValue()); + private readonly object _gate = new(); + private readonly HashSet _codes = new(StringComparer.Ordinal); + private ChainedInteractionEntry _interaction; + private long _version; + + internal Entry(string upstreamToken, ChainedInteractionEntry interaction, string pendingPrefix, + AAuthChainedOperation? operation, long interactionVersion) + { + UpstreamToken = upstreamToken; + PendingPrefix = pendingPrefix; + Operation = operation; + _interaction = interaction; + _codes.Add(interaction.Code); + _version = interactionVersion; + ExpiresAt = DateTimeOffset.FromUnixTimeSeconds( + JsonNode.Parse(Base64UrlEncoder.DecodeBytes(upstreamToken.Split('.')[1]))!["exp"]!.GetValue()); + } + + public string Id => _interaction.Id; + public string UpstreamToken { get; } + public string PendingPrefix { get; } + + /// The background downstream work, or null when nothing is running. + public AAuthChainedOperation? Operation { get; } + + public DateTimeOffset ExpiresAt { get; } public DeferredState Lifecycle { get; } = new(); + + /// + /// The Concierge's current interaction. When the downstream asked for a different + /// interaction (for example an AS step after PS consent), it is re-keyed with a new code, + /// atomically with its downstream redirect target. + /// + public ChainedInteractionEntry Interaction + { + get + { + lock (_gate) + { + if (Operation?.Interaction is { } latest && latest.Version > _version) + { + _interaction = AAuthChainedInteractions.Rekey(_interaction, latest.Downstream); + _codes.Add(_interaction.Code); + _version = latest.Version; + } + return _interaction; + } + } + } + + /// Any code this entry issued stays valid and leads to the latest downstream step. + public bool MatchesCode(string? code) + { + if (string.IsNullOrEmpty(code)) return false; + lock (_gate) + foreach (var issued in _codes) + if (AAuth.Server.AAuthInteractionCode.Matches(issued, code)) return true; + return false; + } + public bool Matches(string? upstreamToken) => string.Equals(UpstreamToken, upstreamToken, StringComparison.Ordinal); } private readonly ConcurrentDictionary _entries = new(); /// - /// Create a pending entry capturing the upstream auth token (used to - /// re-drive the chained call on each poll) and the SDK-owned chained - /// interaction. + - /// are the downstream resource origin and - /// path re-driven on each poll (e.g. Calendar /events or the - /// mission-aware Trips /trips); - /// is the caller-facing poll route prefix (e.g. /pending or - /// /mission-pending). + /// Create a pending entry capturing the upstream auth token (the caller must + /// re-present it on every poll), the SDK-owned chained interaction and the + /// running downstream . + /// is the caller-facing poll route prefix (e.g. /pending or /mission-pending). + /// is the version of the operation's interaction that + /// was parked from; a newer one re-keys the entry. /// public Entry Add( string upstreamToken, ChainedInteractionEntry interaction, - string downstreamBase = "http://localhost:5001", - string downstreamPath = "/events", - string pendingPrefix = "/pending") + string pendingPrefix = "/pending", + AAuthChainedOperation? operation = null, + long interactionVersion = 0) { foreach (var pair in _entries) if (pair.Value.ExpiresAt.AddHours(1) <= DateTimeOffset.UtcNow) _entries.TryRemove(pair.Key, out _); - var entry = new Entry( - interaction.Id, upstreamToken, interaction, downstreamBase, downstreamPath, pendingPrefix); + var entry = new Entry(upstreamToken, interaction, pendingPrefix, operation, interactionVersion); _entries[interaction.Id] = entry; return entry; } @@ -71,5 +121,9 @@ public void Remove(string id) => _entries.TryRemove(id, out _); /// Drop all pending entries back to the empty baseline. - public void Clear() => _entries.Clear(); + public void Clear() + { + foreach (var entry in _entries.Values) entry.Operation?.Cancel(); + _entries.Clear(); + } } diff --git a/samples/Concierge/Program.cs b/samples/Concierge/Program.cs index e02eb544..e75afef2 100644 --- a/samples/Concierge/Program.cs +++ b/samples/Concierge/Program.cs @@ -41,13 +41,16 @@ builder.Services.AddSingleton(conciergeKey); builder.Services.AddSingleton(); // No user to relay to: a downstream interaction is chained back to the caller (§Interaction Chaining). +// Every chained call attaches its AAuthChainedOperation's handler per request; this fallback aborts +// any other downstream request that would otherwise wait on a user the Concierge doesn't have. builder.Services.AddSingleton(); // The downstream agent: one registration for every inbound request. It self-issues its agent -// token (iss = conciergeUrl, §Call Chaining Identity), chains the verified upstream auth token of -// the current request (ChainFromHttpContext), and routes each exchange to the PS that token names. -// The registered ChainInteractionHandler chains a downstream consent back to the caller, so the -// agent declares no `interaction` capability (§AAuth-Capabilities). +// token (iss = conciergeUrl, §Call Chaining Identity), chains the caller's upstream auth token +// (set per request with AAuthRequestOptions.UpstreamToken; ChainFromHttpContext is the fallback), +// and routes each exchange to the PS that token names. A downstream consent is chained back to +// the caller rather than shown to a user here, so the agent declares no `interaction` +// capability (§AAuth-Capabilities). builder.Services.AddAAuthAgent(DownstreamAgent, options => { options.Signer = conciergeKey; @@ -137,23 +140,26 @@ // the exchange to the correct PS/AS using the upstream auth token. // // Interaction Chaining (AAuth §Interaction Chaining): the Concierge has no -// user of its own, so it CANNOT relay a downstream consent prompt. Its -// OnInteractionRequired callback therefore throws -// AAuthInteractionChainedException, which aborts the in-flight exchange before -// it blocks-polls. The handler catches it, parks a pending entry, and re-emits -// its OWN 202 + requirement=interaction to the caller. The user first visits a +// user of its own, so it CANNOT relay a downstream consent prompt. It runs the +// downstream call as an AAuthChainedOperation: when the downstream PS (or AS) +// answers 202 + requirement=interaction, the operation records the interaction +// and the SDK keeps polling the downstream pending URL with GET (§Polling with +// GET) in the background. The handler parks a pending entry and returns its OWN +// 202 + requirement=interaction to the caller. The user first visits a // Concierge interaction URL, which redirects to the downstream PS interaction. +// When the downstream auth token arrives, the operation completes the call and +// the caller's next poll gets the result. The downstream request is never re-sent. // ----------------------------------------------------------------------- -// Run the downstream chained call with the given upstream auth token. Returns -// the combined chain result on success; throws AAuthInteractionChainedException -// when the downstream PS defers for user consent, or -// AAuthInteractionDeniedException when the user denied. -// + select the downstream resource — Calendar -// "/events" for the plain chain or the mission-aware Trips "/trips" for a -// mission-governed chain. WithCallChaining routes every downstream request to the -// PS the upstream token names (its `ps`); a `mission_s256` in the upstream token -// governs every hop (§Call Chaining). +// Run the downstream chained call for one inbound request. It may outlive that +// request (it keeps polling a downstream consent), so it uses only what was +// captured up front: the upstream auth token travels per request +// (AAuthRequestOptions.UpstreamToken), and the interaction handler is the +// operation's. + +// select the downstream resource — Calendar "/events" for the plain chain or the +// mission-aware Trips "/trips" for a mission-governed chain. The SDK routes every +// downstream request to the PS the upstream token names (its `ps`); a +// `mission_s256` in the upstream token governs every hop (§Call Chaining). app.UseWhen(ctx => IsWalletPath(ctx.Request.Path), branch => branch.UseAAuthIntermediary( verification => { @@ -175,21 +181,21 @@ challenge.ScopeDescriptions = new Dictionary { ["wallet.read"] = "Read the travel wallet through the concierge" }; })); -async Task RunChainAsync(HttpContext ctx, string downstreamBase, string downstreamPath) +async Task RunChainAsync(string upstreamToken, AAuthVerificationResult? upstreamResult, + IAAuthInteractionHandler interactions, string downstreamBase, string downstreamPath, CancellationToken cancellationToken) { - // The registered agent chains this request's upstream auth token (the one the pending - // routes re-verify, equal to the parked entry's) into its person token and auth token - // requests (§Call Chaining). A chained consent unwinds as AAuthInteractionChainedException. var exchanges = ChainCaptureHandler.Begin(); - var downstream = ctx.RequestServices.GetRequiredService().CreateClient(DownstreamAgent); + var downstream = app.Services.GetRequiredService().CreateClient(DownstreamAgent); - using var response = await downstream.GetAsync($"{downstreamBase.TrimEnd('/')}{downstreamPath}", ctx.RequestAborted); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{downstreamBase.TrimEnd('/')}{downstreamPath}"); + request.Options.Set(AAuthRequestOptions.UpstreamToken, upstreamToken); + request.Options.Set(AAuthRequestOptions.InteractionHandler, interactions); + using var response = await downstream.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsStringAsync(); + var body = await response.Content.ReadAsStringAsync(cancellationToken); JsonNode? downstreamJson = null; try { downstreamJson = JsonNode.Parse(body); } catch { } - var upstreamResult = ctx.GetAAuthVerification(); var downstreamName = downstreamPath.StartsWith("/wallet", StringComparison.Ordinal) ? "Wallet" : downstreamPath.StartsWith("/trips", StringComparison.Ordinal) ? "Trips" : "Calendar"; return Results.Ok(new @@ -217,40 +223,24 @@ async Task RunChainAsync(HttpContext ctx, string downstreamBase, string } // Re-emit the Concierge's own 202 requirement=interaction for a parked -// chained request: its own Location, interaction URL and interaction code. +// chained request: its own Location, interaction URL and interaction code +// (re-keyed when the downstream moved to a new interaction). IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry) => AAuthChainedInteractions.Accepted(ctx, entry.Interaction, SampleEgress.Policy); -ChainedInteractionEntry ParkChainedInteraction(AAuthInteractionChainedException ex, string upstreamToken, - string pendingPrefix, string downstreamBase, string downstreamPath) - => AAuthChainedInteractions.Park(conciergeUrl, pendingPrefix, "/chain-interaction", ex, - "concierge.downstream", - new JsonObject - { - ["downstream_base"] = downstreamBase, - ["downstream_path"] = downstreamPath, - }, - DateTimeOffset.FromUnixTimeSeconds( - JsonNode.Parse(Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(upstreamToken.Split('.')[1]))!["exp"]!.GetValue())); - -app.MapGet("/wallet", async (HttpContext context, PendingStore pending) => +// The finished operation's outcome: its result, or the §Polling Error Codes +// response for a downstream denial, expiry or revocation. +async Task CompletedChainAsync(AAuthChainedOperation operation) { - var upstream = context.Features.Get()?.Token; - if (upstream is null) return AAuthProblemDetails.Create("invalid_request", statusCode: 403); - try - { - return await RunChainAsync(context, walletUrl, "/wallet"); - } - catch (AAuthInteractionChainedException ex) - { - var chained = ParkChainedInteraction(ex, upstream, "/wallet-pending", walletUrl, "/wallet"); - var entry = pending.Add(upstream, chained, - downstreamBase: walletUrl, downstreamPath: "/wallet", pendingPrefix: "/wallet-pending"); - return ReEmitChainedInteraction(context, entry); - } -}); + try { return await operation.Completion; } + catch (Exception ex) when (AAuthChainedInteractions.PollingFailure(ex) is { } failure) { return failure; } +} -app.MapGet("/", async (HttpContext ctx, PendingStore pending) => +// Start the downstream chain for an inbound request. If it finishes without +// downstream interaction, answer with its result; otherwise park it under a +// Concierge-owned code and pending URL and answer with the Concierge's own 202. +async Task StartChainAsync(HttpContext ctx, PendingStore pending, string pendingPrefix, + string downstreamBase, string downstreamPath) { var upstreamToken = ctx.Features.Get()?.Token; if (string.IsNullOrEmpty(upstreamToken)) @@ -258,70 +248,73 @@ ChainedInteractionEntry ParkChainedInteraction(AAuthInteractionChainedException return AAuth.Server.AAuthProblemDetails.Create("invalid_request", "missing upstream auth token", statusCode: StatusCodes.Status401Unauthorized); } - try + var upstreamResult = ctx.GetAAuthVerification(); + var expiresAt = DateTimeOffset.FromUnixTimeSeconds( + JsonNode.Parse(Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(upstreamToken.Split('.')[1]))!["exp"]!.GetValue()); + var operation = await AAuthChainedOperation.StartAsync( + (interactions, ct) => RunChainAsync(upstreamToken, upstreamResult, interactions, downstreamBase, downstreamPath, ct), + expiresAt, app.Lifetime.ApplicationStopping); + if (operation.Completion.IsCompleted) { - return await RunChainAsync(ctx, downstreamUrl, "/events"); + return await CompletedChainAsync(operation); } - catch (AAuthInteractionChainedException ex) - { - // Downstream needs the user's consent. Park it and chain the 202 up. - var chained = ParkChainedInteraction(ex, upstreamToken, "/pending", downstreamUrl, "/events"); - var entry = pending.Add(upstreamToken, chained); - return ReEmitChainedInteraction(ctx, entry); - } -}); + + // Read the interaction once: the operation may publish a newer one while we park. + var snapshot = operation.Interaction!; + var chained = AAuthChainedInteractions.Park(conciergeUrl, pendingPrefix, "/chain-interaction", + snapshot.Downstream, + "concierge.downstream", + new JsonObject + { + ["downstream_base"] = downstreamBase, + ["downstream_path"] = downstreamPath, + }, + expiresAt); + var entry = pending.Add(upstreamToken, chained, pendingPrefix, operation, snapshot.Version); + return ReEmitChainedInteraction(ctx, entry); +} + +app.MapGet("/wallet", (HttpContext context, PendingStore pending) => + StartChainAsync(context, pending, "/wallet-pending", walletUrl, "/wallet")); + +app.MapGet("/", (HttpContext ctx, PendingStore pending) => + StartChainAsync(ctx, pending, "/pending", downstreamUrl, "/events")); // GET /mission — the mission-governed twin of "/". Identical chaining, but the // downstream hop targets the mission-aware Trips "/trips" so a mission present // in the upstream auth token is forwarded and re-bound at each hop (§Mission // Context at Resources, §Call Chaining). -app.MapGet("/mission", async (HttpContext ctx, PendingStore pending) => -{ - var upstreamToken = ctx.Features.Get()?.Token; - if (string.IsNullOrEmpty(upstreamToken)) - { - return AAuth.Server.AAuthProblemDetails.Create("invalid_request", "missing upstream auth token", statusCode: StatusCodes.Status401Unauthorized); - } - - try - { - return await RunChainAsync(ctx, missionDownstreamUrl, "/trips"); - } - catch (AAuthInteractionChainedException ex) - { - var chained = ParkChainedInteraction(ex, upstreamToken, "/mission-pending", missionDownstreamUrl, "/trips"); - var entry = pending.Add( - upstreamToken, chained, - downstreamBase: missionDownstreamUrl, downstreamPath: "/trips", pendingPrefix: "/mission-pending"); - return ReEmitChainedInteraction(ctx, entry); - } -}); +app.MapGet("/mission", (HttpContext ctx, PendingStore pending) => + StartChainAsync(ctx, pending, "/mission-pending", missionDownstreamUrl, "/trips")); app.MapGet("/chain-interaction/{id}", (string id, string? code, PendingStore pending) => { var entry = pending.Get(id); - if (entry is null || !AAuthInteractionCode.Matches(entry.Interaction.Code, code ?? string.Empty)) + if (entry is null || !entry.MatchesCode(code)) return AAuth.Server.AAuthProblemDetails.Polling(AAuth.Errors.PollingErrorCode.InvalidCode, extensions: new Dictionary { ["id"] = id }); + // Any code this entry issued leads to the latest downstream interaction. return AAuthChainedInteractions.RedirectToDownstream(entry.Interaction); }); // ----------------------------------------------------------------------- // GET /pending/{id} — the caller polls here while its user approves the // downstream consent at the PS interaction page. Signed + auth-token gated by -// the same middleware as "/". Each poll RE-DRIVES the chained call with the -// stored upstream token (idempotent; consent is keyed by agent/resource/scope -// at the PS). Returns: -// * 202 + same requirement=interaction while still unconsented downstream +// the same middleware as "/". Each poll reads the background operation, which +// is polling the downstream pending URL itself; nothing is re-sent downstream. +// Returns: +// * 202 + requirement=interaction while the downstream is still pending +// (a new code when the downstream moved to a new interaction) // * 200 + combined chain result once the downstream auth token resolves -// * 403 denied if the user denied +// * 403 denied / abandoned / revoked, or 408 expired, from the downstream outcome // * 410 invalid_code if the pending id is unknown, mismatched or already consumed +// DELETE cancels the background operation. // ----------------------------------------------------------------------- app.MapMethods("/pending/{id}", ["GET", "DELETE"], HandlePendingAsync); // GET /mission-pending/{id} — the mission chain's poll route. Identical to // "/pending/{id}" but for entries whose downstream hop is the mission-aware -// Trips "/trips" (each poll re-drives RunChainAsync with the stored path). +// Trips "/trips". app.MapMethods("/mission-pending/{id}", ["GET", "DELETE"], HandlePendingAsync); // GET /wallet-pending/{id} — the four-party /wallet chain's poll route, verified @@ -342,16 +335,16 @@ async Task HandlePendingAsync(HttpContext ctx, string id, PendingStore { if (HttpMethods.IsDelete(ctx.Request.Method)) { + entry.Operation?.Cancel(); entry.Lifecycle.Cancel(); return Results.NoContent(); } // entry.Matches(...) above proved this request re-presents the parked upstream token. - try { return await RunChainAsync(ctx, entry.DownstreamBase, entry.DownstreamPath); } - catch (AAuthInteractionChainedException) { return ReEmitChainedInteraction(ctx, entry); } - catch (AAuthInteractionDeniedException) + if (entry.Operation is { Completion.IsCompleted: true } operation) { - return AAuthProblemDetails.Create("denied", "the user denied this request", statusCode: StatusCodes.Status403Forbidden); + return await CompletedChainAsync(operation); } + return ReEmitChainedInteraction(ctx, entry); }); } diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs index 15f3ac51..9e977936 100644 --- a/samples/GuidedTour/TourSession.cs +++ b/samples/GuidedTour/TourSession.cs @@ -455,7 +455,7 @@ public IReadOnlyList Plan new(11, "Retry Concierge → 202 (hop 2 chained)", "Concierge calls Calendar; that hop needs consent too, so it re-emits its OWN 202 (interaction chaining).", Actor.Agent, Actor.Concierge), new(12, "Direct user to interaction URL (hop 2)", "Agent relays the Concierge's chained interaction URL to approve Concierge → Calendar.", Actor.Agent, Actor.Agent), new(13, "User approves hop 2 at the PS", "User approves Concierge → Calendar at the PS; PS records consent for the chained hop.", Actor.PersonServer, Actor.PersonServer), - new(14, "Poll Concierge pending → 200", "Signed GETs to the Concierge's pending URL until it re-drives the chain and returns 200.", Actor.Agent, Actor.Concierge), + new(14, "Poll Concierge pending → 200", "Signed GETs to the Concierge's pending URL until its downstream poll resolves and it returns 200.", Actor.Agent, Actor.Concierge), new(15, "Inspect multi-agent result", "Review the combined response showing the full Agent → Concierge → Calendar chain.", Actor.Agent, Actor.Agent), }; @@ -3302,8 +3302,9 @@ private Task StepCallChainPollHop2Async(CancellationToken ct) => Narrative = "With the second approval recorded, the agent polls the " + "Concierge's pending URL (signed with the Concierge-audience " + - "auth_token). The Concierge re-drives its downstream exchange: the " + - "PS now mints a Calendar auth_token for the **same person** — same " + + "auth_token). Meanwhile the Concierge has kept polling the PS pending " + + "URL of its downstream exchange with GET (it never re-sends the token " + + "request): the PS now mints a Calendar auth_token for the **same person** — same " + "`ps`, but a `sub` directed at Calendar rather than the Concierge's. " + "There is no `act` chain; the Concierge authenticates with its own " + "agent token and the PS records the upstream token it was given. " + diff --git a/samples/SampleApp/Components/Pages/CallChain.razor b/samples/SampleApp/Components/Pages/CallChain.razor index d36e49d4..40f51b99 100644 --- a/samples/SampleApp/Components/Pages/CallChain.razor +++ b/samples/SampleApp/Components/Pages/CallChain.razor @@ -72,10 +72,13 @@ var response = await client.SendAsync(request);
Concierge Handler (interaction chaining)
// The Concierge is BOTH a resource AND an agent,
-// but has NO user — so its downstream client CHAINS
-// the interaction instead of relaying it: the callback
-// THROWS, unwinding the exchange before it blocks.
-async Task<IResult> RunChainAsync(HttpContext ctx, string upstream)
+// but has NO user — so it CHAINS a downstream consent
+// back to the caller. AAuthChainedOperation keeps the
+// downstream exchange alive: it records the interaction
+// and the SDK keeps polling the PS pending URL with GET
+// (§Polling with GET) while the Concierge answers 202.
+async Task<IResult> RunChainAsync(string upstream,
+    IAAuthInteractionHandler interactions, CancellationToken ct)
 {
     using var downstream = AAuthClientBuilder
         .SelfIssuing(conciergeKey)
@@ -85,15 +88,13 @@ async Task<IResult> RunChainAsync(HttpContext ctx, string upstream)
         .WithCallChaining(upstream)   // sends upstream_token to the PS it
                                       // names (a mission_s256 would travel
                                       // on; omitted here)
-        .WithChallengeHandling(opts =>
-        {
-            opts.OnInteractionRequired = (i, _) =>
-                throw new AAuthInteractionChainedException(i);
-            opts.Capabilities = Array.Empty<string>();
-        })
+        .WithChallengeHandling(opts => opts.Capabilities = [])
         .Build();
 
-    var r = await downstream.GetAsync($"{calendarUrl}/events");
+    using var request = new HttpRequestMessage(
+        HttpMethod.Get, $"{calendarUrl}/events");
+    request.Options.Set(AAuthRequestOptions.InteractionHandler, interactions);
+    var r = await downstream.SendAsync(request, ct);
     return Results.Ok(/* combined chain result */);
 }
 
@@ -101,20 +102,20 @@ app.MapGet("/", async (HttpContext ctx, PendingStore pending) =>
 {
     var upstream = ctx.Features
         .Get<UpstreamAuthTokenFeature>()!.Token;
-    try
-    {
-        return await RunChainAsync(ctx, upstream);
-    }
-    catch (AAuthInteractionChainedException ex)
-    {
-        // Downstream needs consent. Park it and
-        // re-emit our OWN code, URL and poll Location.
-        var chained = AAuthChainedInteractions.Park(
-            conciergeUrl, "/pending", "/chain-interaction", ex,
-            "calendar.events", new JsonObject(), DateTimeOffset.UtcNow.AddMinutes(10));
-        var e = pending.Add(upstream, chained);
-        return ReEmitChainedInteraction(ctx, e);
-    }
+    var op = await AAuthChainedOperation<IResult>.StartAsync(
+        (interactions, ct) => RunChainAsync(upstream, interactions, ct),
+        DateTimeOffset.UtcNow.AddMinutes(10));
+    if (op.Completion.IsCompleted) return await op.Completion;
+
+    // Downstream needs consent and is being polled. Park it
+    // and re-emit our OWN code, URL and poll Location. Polls
+    // of /pending/{id} read op.Completion; nothing is re-sent.
+    var chained = AAuthChainedInteractions.Park(
+        conciergeUrl, "/pending", "/chain-interaction",
+        op.Interaction!.Downstream, "calendar.events",
+        new JsonObject(), DateTimeOffset.UtcNow.AddMinutes(10));
+    var e = pending.Add(upstream, chained, "/pending", op);
+    return ReEmitChainedInteraction(ctx, e);
 });
diff --git a/src/AAuth/Agent/AAuthRequestOptions.cs b/src/AAuth/Agent/AAuthRequestOptions.cs index 7736b812..52838d96 100644 --- a/src/AAuth/Agent/AAuthRequestOptions.cs +++ b/src/AAuth/Agent/AAuthRequestOptions.cs @@ -15,6 +15,13 @@ public static class AAuthRequestOptions /// Per-request interaction handler; beats the agent's configured handler (for example, the current user's session). public static readonly HttpRequestOptionsKey InteractionHandler = new("AAuth.InteractionHandler"); + /// + /// Per-request upstream token for call chaining (§Call Chaining); beats the agent's configured + /// provider, including ChainFromHttpContext. Set it when the downstream call can outlive the + /// inbound request, for example an interaction-chained operation that keeps polling. + /// + public static readonly HttpRequestOptionsKey UpstreamToken = new("AAuth.UpstreamToken"); + /// Per-request clarification handler; beats the agent's configured handler. public static readonly HttpRequestOptionsKey ClarificationHandler = new("AAuth.ClarificationHandler"); diff --git a/src/AAuth/Agent/AAuthTokenHolder.cs b/src/AAuth/Agent/AAuthTokenHolder.cs index f11d2628..ac02233b 100644 --- a/src/AAuth/Agent/AAuthTokenHolder.cs +++ b/src/AAuth/Agent/AAuthTokenHolder.cs @@ -72,11 +72,24 @@ internal async Task AcquireAsync(System.Net.Http.HttpRequestMessage requ Func> acquire, System.Threading.CancellationToken cancellationToken) { request.Options.TryGetValue(SourceToken, out var agentToken); - var token = agentToken is not null + var key = agentToken is not null && request.Options.TryGetValue(AAuth.HttpSig.AAuthSigningHandler.SigningKeyContext, out var signingKey) - && Key(request, agentToken, signingKey.ComputeJwkThumbprint()) is { } key + ? Key(request, agentToken, signingKey.ComputeJwkThumbprint()) : null; + string token; + if (key is not null && request.Options.TryGetValue(AAuthRequestOptions.InteractionHandler, out _)) + { + // A per-request interaction handler owns this request's consent. Another request's + // in-flight acquisition would never call it, so reuse only a finished token. + token = _cache.Get(key) is { } cached && cached != presented + ? cached : await acquire(cancellationToken).ConfigureAwait(false); + _cache.Set(key, token, ExpiresAt(token)); + } + else + { + token = key is not null ? await _cache.AcquireAsync(key, presented, acquire, cancellationToken).ConfigureAwait(false) : await acquire(cancellationToken).ConfigureAwait(false); + } if (!IsUsable(token)) { token = await acquire(cancellationToken).ConfigureAwait(false); diff --git a/src/AAuth/Agent/ChallengeHandler.cs b/src/AAuth/Agent/ChallengeHandler.cs index b44e0ceb..1042ef9b 100644 --- a/src/AAuth/Agent/ChallengeHandler.cs +++ b/src/AAuth/Agent/ChallengeHandler.cs @@ -162,7 +162,8 @@ protected override async Task SendAsync( if (!request.Options.TryGetValue(MissionForwardingHandler.UpstreamAuthorization, out var upstreamToken)) { - upstreamToken = _upstreamTokenProvider?.Invoke(); + upstreamToken = request.Options.TryGetValue(AAuthRequestOptions.UpstreamToken, out var explicitToken) + ? explicitToken : _upstreamTokenProvider?.Invoke(); request.Options.Set(MissionForwardingHandler.UpstreamAuthorization, upstreamToken); } var response = await SendWithAdaptiveSigningAsync(request, cancellationToken) diff --git a/src/AAuth/Agent/MissionForwardingHandler.cs b/src/AAuth/Agent/MissionForwardingHandler.cs index a75f3f57..7eb46d46 100644 --- a/src/AAuth/Agent/MissionForwardingHandler.cs +++ b/src/AAuth/Agent/MissionForwardingHandler.cs @@ -26,7 +26,8 @@ public MissionForwardingHandler(System.Func upstreamTokenProvider) /// protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - request.Options.Set(UpstreamAuthorization, _upstreamTokenProvider()); + request.Options.Set(UpstreamAuthorization, + request.Options.TryGetValue(AAuthRequestOptions.UpstreamToken, out var explicitToken) ? explicitToken : _upstreamTokenProvider()); return base.SendAsync(request, cancellationToken); } } diff --git a/src/AAuth/Server/CallChaining/ChainedInteractions.cs b/src/AAuth/Server/CallChaining/ChainedInteractions.cs index 176c970d..a7eb749b 100644 --- a/src/AAuth/Server/CallChaining/ChainedInteractions.cs +++ b/src/AAuth/Server/CallChaining/ChainedInteractions.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using AAuth.Agent; using AAuth.Discovery; +using AAuth.Errors; using AAuth.Headers; using Microsoft.AspNetCore.Http; @@ -32,11 +33,29 @@ public static ChainedInteractionEntry Park( string operationName, JsonObject state, DateTimeOffset expiresAt) + { + ArgumentNullException.ThrowIfNull(exception); + return Park(intermediaryBaseUrl, pendingPrefix, interactionPrefix, exception.DownstreamInteraction, + operationName, state, expiresAt); + } + + /// + /// Park a downstream interaction (for example ) + /// under an intermediary-owned code and pending URL. + /// + public static ChainedInteractionEntry Park( + string intermediaryBaseUrl, + string pendingPrefix, + string interactionPrefix, + Interaction downstreamInteraction, + string operationName, + JsonObject state, + DateTimeOffset expiresAt) { ArgumentException.ThrowIfNullOrWhiteSpace(intermediaryBaseUrl); ArgumentException.ThrowIfNullOrWhiteSpace(pendingPrefix); ArgumentException.ThrowIfNullOrWhiteSpace(interactionPrefix); - ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(downstreamInteraction); ArgumentException.ThrowIfNullOrWhiteSpace(operationName); ArgumentNullException.ThrowIfNull(state); @@ -48,7 +67,7 @@ public static ChainedInteractionEntry Park( code, $"{baseUrl}/{interactionPrefix.Trim('/')}/{id}", $"{pendingPrefix.TrimEnd('/')}/{id}", - exception.DownstreamInteraction, + downstreamInteraction, operationName, state, expiresAt); @@ -73,4 +92,45 @@ public static IResult RedirectToDownstream(ChainedInteractionEntry entry) ArgumentNullException.ThrowIfNull(entry); return Results.Redirect(entry.DownstreamInteraction.BuildUserUrl()); } + + /// + /// Re-key a parked entry for a new downstream interaction (for example an Access Server step after + /// Person Server consent): a fresh intermediary code, the same id and pending URL. The caller's + /// interaction handler sees a new user URL and surfaces it again. + /// + public static ChainedInteractionEntry Rekey(ChainedInteractionEntry entry, Interaction downstream) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(downstream); + return entry with { Code = AAuthInteractionCode.Generate(26), DownstreamInteraction = downstream }; + } + + /// + /// Map why a chained operation failed to the §Polling Error Codes response for the intermediary's + /// pending URL, keeping the downstream detail. Returns for failures + /// that are not a protocol outcome (the caller answers server_error). + /// + public static IResult? PollingFailure(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + return exception switch + { + AAuthInteractionDeniedException denied => AAuthProblemDetails.Polling(PollingErrorCode.Denied, + (denied.InnerException as PollingErrorException)?.Detail ?? denied.Message), + AAuthInteractionTimeoutException timeout => AAuthProblemDetails.Polling(PollingErrorCode.Expired, timeout.Message), + PollingErrorException polling => polling.ErrorCode switch + { + PollingErrorCode.Denied or PollingErrorCode.Abandoned or PollingErrorCode.Revoked or PollingErrorCode.Expired + or PollingErrorCode.ServerError => AAuthProblemDetails.Polling(polling.ErrorCode, polling.Detail), + // The downstream pending request is gone; the caller MAY start a fresh request. + PollingErrorCode.InvalidCode => AAuthProblemDetails.Polling(PollingErrorCode.Expired, polling.Detail), + _ => null, + }, + AAuthTokenExchangeException exchange when PollingErrorException.TryParseCode(exchange.ErrorCode, out var code) + && code is PollingErrorCode.Denied or PollingErrorCode.Abandoned or PollingErrorCode.Revoked or PollingErrorCode.Expired + => AAuthProblemDetails.Polling(code, exchange.Detail), + OperationCanceledException => AAuthProblemDetails.Polling(PollingErrorCode.Expired), + _ => null, + }; + } } diff --git a/src/AAuth/Server/CallChaining/ChainedOperation.cs b/src/AAuth/Server/CallChaining/ChainedOperation.cs new file mode 100644 index 00000000..2d4c988e --- /dev/null +++ b/src/AAuth/Server/CallChaining/ChainedOperation.cs @@ -0,0 +1,133 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Headers; + +namespace AAuth.Server.CallChaining; + +/// One published downstream interaction of an . +/// Increases each time the downstream asks for a different interaction. +/// The downstream PS or AS interaction the user must complete. +public sealed record AAuthChainedInteractionSnapshot(long Version, Interaction Downstream); + +/// +/// Runs an intermediary's downstream work for §Interaction Chaining. When the downstream PS or AS +/// answers with 202 + requirement=interaction, the operation publishes the interaction +/// and lets the SDK keep polling the downstream pending URL with GET (§Polling with GET), so +/// the intermediary can return its own 202 at once and later "completes the original request +/// and returns the result at its pending URL". The downstream request is never re-sent. +/// +/// +/// The operation runs in memory and outlives the inbound request, so it must not use that request's +/// HttpContext, features, services or RequestAborted after it starts. Pass the upstream +/// token with and attach +/// with on every downstream request. +/// +public sealed class AAuthChainedOperation +{ + private static readonly TimeSpan MaxTimerDelay = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + private readonly object _gate = new(); + private readonly CancellationTokenSource _cancellation; + private readonly TaskCompletionSource _parked = new(TaskCreationOptions.RunContinuationsAsynchronously); + private AAuthChainedInteractionSnapshot? _interaction; + + private AAuthChainedOperation(CancellationTokenSource cancellation) + { + _cancellation = cancellation; + InteractionHandler = new PublishingHandler(this); + Completion = Task.FromCanceled(new CancellationToken(true)); + } + + /// The downstream work; completes with its result or faults with its failure. + public Task Completion { get; private set; } + + /// The latest downstream interaction, or before the first one. + public AAuthChainedInteractionSnapshot? Interaction + { + get { lock (_gate) return _interaction; } + } + + /// + /// The handler to attach to each downstream request. It records the interaction and returns + /// normally, so the exchange keeps polling instead of aborting. + /// + public IAAuthInteractionHandler InteractionHandler { get; } + + /// + /// Start and wait until it either completes or first needs downstream + /// user interaction. Check : if it is not complete, park the operation and + /// answer the caller with the intermediary's own 202. + /// + /// The downstream work, given the interaction handler and its cancellation token. + /// When to stop polling downstream, for example the upstream token's expiry. + /// Stops the operation early, for example on host shutdown. + public static async Task> StartAsync( + Func> operation, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(operation); + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + AAuthChainedOperation chained; + try + { + var lifetime = expiresAt - DateTimeOffset.UtcNow; + if (lifetime <= TimeSpan.Zero) cancellation.Cancel(); + // Timers cannot run past ~49.7 days; beyond that only Cancel or the caller's token stop it. + else if (lifetime < MaxTimerDelay) cancellation.CancelAfter(lifetime); + chained = new AAuthChainedOperation(cancellation); + } + catch + { + cancellation.Dispose(); + throw; + } + + chained.Completion = chained.RunAsync(operation); + // A caller may abandon a parked operation; observe a failure nobody awaits. + _ = chained.Completion.ContinueWith(static task => _ = task.Exception, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + await Task.WhenAny(chained.Completion, chained._parked.Task).ConfigureAwait(false); + return chained; + } + + /// Stop polling downstream, for example when the caller DELETEs its pending URL. + public void Cancel() + { + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + } + + private async Task RunAsync(Func> operation) + { + try + { + return await operation(InteractionHandler, _cancellation.Token).ConfigureAwait(false); + } + finally + { + _cancellation.Dispose(); + } + } + + private void Publish(Interaction interaction) + { + lock (_gate) + { + if (_interaction is { } current && current.Downstream.BuildUserUrl() == interaction.BuildUserUrl()) return; + _interaction = new AAuthChainedInteractionSnapshot((_interaction?.Version ?? 0) + 1, interaction); + } + _parked.TrySetResult(); + } + + private sealed class PublishingHandler(AAuthChainedOperation owner) : IAAuthInteractionHandler + { + public Task OnInteractionRequiredAsync(Interaction interaction, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(interaction); + owner.Publish(interaction); + return Task.CompletedTask; + } + } +} diff --git a/tests/AAuth.Tests/Agent/ChainedOperationTests.cs b/tests/AAuth.Tests/Agent/ChainedOperationTests.cs new file mode 100644 index 00000000..a5d3bd31 --- /dev/null +++ b/tests/AAuth.Tests/Agent/ChainedOperationTests.cs @@ -0,0 +1,286 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Agent; +using AAuth.Discovery; +using AAuth.Errors; +using AAuth.Headers; +using AAuth.Server.CallChaining; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AAuth.Tests.Agent; + +/// +/// §Interaction Chaining with : the intermediary keeps +/// polling the downstream pending URL with GET (§Polling with GET) while it answers its caller with +/// its own 202, and never re-sends the downstream request. +/// +public class ChainedOperationTests +{ + private const string PsUrl = "http://localhost:5555"; + private static readonly Interaction First = new("http://localhost:5555/interaction", "FIRST"); + private static readonly Interaction Second = new("http://localhost:5555/interaction", "SECOND"); + + [Fact] + public async Task CompletesWithoutInteraction_ReturnsCompletedOperation() + { + var operation = await AAuthChainedOperation.StartAsync( + (_, _) => Task.FromResult("done"), DateTimeOffset.UtcNow.AddMinutes(5)); + + Assert.True(operation.Completion.IsCompletedSuccessfully); + Assert.Equal("done", await operation.Completion); + Assert.Null(operation.Interaction); + } + + [Fact] + public async Task ParksOnInteraction_ThenCompletesInBackground() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var operation = await AAuthChainedOperation.StartAsync(async (interactions, ct) => + { + await interactions.OnInteractionRequiredAsync(First, ct); + return await release.Task.WaitAsync(ct); + }, DateTimeOffset.UtcNow.AddMinutes(5)); + + Assert.False(operation.Completion.IsCompleted); + Assert.Equal(new AAuthChainedInteractionSnapshot(1, First), operation.Interaction); + + release.SetResult("done"); + Assert.Equal("done", await operation.Completion.WaitAsync(TimeSpan.FromSeconds(5))); + } + + [Fact] + public async Task NewDownstreamInteraction_BumpsVersion_SameOneDoesNot() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + IAAuthInteractionHandler? handler = null; + var operation = await AAuthChainedOperation.StartAsync(async (interactions, ct) => + { + handler = interactions; + await interactions.OnInteractionRequiredAsync(First, ct); + return await release.Task.WaitAsync(ct); + }, DateTimeOffset.UtcNow.AddMinutes(5)); + + await handler!.OnInteractionRequiredAsync(First with { Source = InteractionSource.PersonServer }, default); + Assert.Equal(1, operation.Interaction!.Version); + await handler.OnInteractionRequiredAsync(Second, default); + Assert.Equal(new AAuthChainedInteractionSnapshot(2, Second), operation.Interaction); + release.SetResult("done"); + await operation.Completion; + } + + [Fact] + public async Task Cancel_StopsTheOperation_AndMapsToExpired() + { + var operation = await AAuthChainedOperation.StartAsync(async (interactions, ct) => + { + await interactions.OnInteractionRequiredAsync(First, ct); + await Task.Delay(Timeout.Infinite, ct); + return "unreachable"; + }, DateTimeOffset.UtcNow.AddMinutes(5)); + + operation.Cancel(); + var failure = await Assert.ThrowsAnyAsync(() => operation.Completion.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Equal(StatusCodes.Status408RequestTimeout, + ((IStatusCodeHttpResult)AAuthChainedInteractions.PollingFailure(failure)!).StatusCode); + operation.Cancel(); + } + + [Fact] + public async Task ExpiredOperation_IsCancelled() + { + var operation = await AAuthChainedOperation.StartAsync(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + return "unreachable"; + }, DateTimeOffset.UtcNow.AddSeconds(-1)); + + await Assert.ThrowsAnyAsync(() => operation.Completion.WaitAsync(TimeSpan.FromSeconds(5))); + } + + [Fact] + public async Task FarFutureExpiry_DoesNotThrow() + { + var operation = await AAuthChainedOperation.StartAsync( + (_, _) => Task.FromResult("done"), DateTimeOffset.MaxValue); + Assert.Equal("done", await operation.Completion); + } + + [Fact] + public async Task RequestWithOwnInteractionHandler_DoesNotJoinAnotherRequestsAcquisition() + { + // Two inbound requests chain the same upstream token. The first is parked on downstream + // consent; the second must run its own acquisition so its own handler can see its 202. + var holder = new AAuthTokenHolder(); + var key = AAuth.Crypto.AAuthKey.Generate(); + var token = $"e30.{Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode( + $"{{\"exp\":{DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds()}}}")}.c2ln"; + HttpRequestMessage Request() + { + var request = new HttpRequestMessage(HttpMethod.Get, "https://calendar.example/events"); + holder.SelectForRequest(request, "agent-token", key.ComputeJwkThumbprint()); + request.Options.Set(AAuth.HttpSig.AAuthSigningHandler.SigningKeyContext, key); + request.Options.Set(AAuthRequestOptions.InteractionHandler, new AAuthChainedOperationProbe()); + return request; + } + + var parked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var first = holder.AcquireAsync(Request(), null, _ => parked.Task, default); + var second = holder.AcquireAsync(Request(), null, _ => Task.FromResult(token), default); + + Assert.Equal(token, await second.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.False(first.IsCompleted); + parked.SetResult(token); + await first; + } + + private sealed class AAuthChainedOperationProbe : IAAuthInteractionHandler + { + public Task OnInteractionRequiredAsync(Interaction interaction, CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Fact] + public async Task DownstreamExchange_PostsOnce_ThenPollsLocationWithGet() + { + var ps = new DeferredPersonServer(); + var http = new InProcessHttpClient(ps); + var exchange = new TokenExchangeClient(http, new MetadataClient(http)); + + var operation = await AAuthChainedOperation.StartAsync( + (interactions, ct) => exchange.ExchangeAsync(PsUrl, TestTokens.Resource, new TokenExchangeRequest + { + PresentedToken = "presented", + OnInteractionRequired = interactions.OnInteractionRequiredAsync, + PollerOptions = new DeferredPollerOptions { MinPollInterval = TimeSpan.Zero }, + }, ct), + DateTimeOffset.UtcNow.AddMinutes(5)); + + // The caller can be answered with the intermediary's own 202 while the + // downstream exchange keeps polling. + Assert.False(operation.Completion.IsCompleted); + Assert.Equal("CONSENT", operation.Interaction!.Downstream.Code); + await ps.Polled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + ps.Approve(); + + // The fake auth token fails verification; what matters is how it was reached. + await Assert.ThrowsAnyAsync(() => operation.Completion.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Equal(1, ps.TokenPosts); + Assert.True(ps.PendingGets >= 2); + Assert.Equal(0, ps.OtherRequests); + } + + [Theory] + [InlineData(PollingErrorCode.Expired, 408, "expired")] + [InlineData(PollingErrorCode.Revoked, 403, "revoked")] + [InlineData(PollingErrorCode.Abandoned, 403, "abandoned")] + [InlineData(PollingErrorCode.ServerError, 500, "server_error")] + [InlineData(PollingErrorCode.InvalidCode, 408, "expired")] + public async Task PollingFailure_MapsDownstreamPollingErrors(PollingErrorCode code, int status, string error) + { + var result = AAuthChainedInteractions.PollingFailure(new PollingErrorException(code, 400, detail: "why"))!; + var (actualStatus, body) = await ExecuteAsync(result); + Assert.Equal(status, actualStatus); + Assert.Equal(error, (string?)body["error"]); + Assert.Equal("why", (string?)body["detail"]); + } + + [Fact] + public async Task PollingFailure_KeepsDeniedDetail() + { + var denied = new AAuthInteractionDeniedException("The AAuth request was denied: no", + new PollingErrorException(PollingErrorCode.Denied, 403, detail: "no")); + var (status, body) = await ExecuteAsync(AAuthChainedInteractions.PollingFailure(denied)!); + Assert.Equal(403, status); + Assert.Equal("denied", (string?)body["error"]); + Assert.Equal("no", (string?)body["detail"]); + Assert.Null(AAuthChainedInteractions.PollingFailure(new InvalidOperationException())); + } + + [Fact] + public void Rekey_IssuesNewCode_KeepsIdAndPendingUrl() + { + var entry = AAuthChainedInteractions.Park("http://localhost:5200", "/pending", "/chain-interaction", First, + "op", new JsonObject(), DateTimeOffset.UtcNow.AddMinutes(5)); + var rekeyed = AAuthChainedInteractions.Rekey(entry, Second); + + Assert.NotEqual(entry.Code, rekeyed.Code); + Assert.Equal(entry.Id, rekeyed.Id); + Assert.Equal(entry.PendingUrl, rekeyed.PendingUrl); + Assert.Equal(entry.InteractionUrl, rekeyed.InteractionUrl); + Assert.Equal(Second, rekeyed.DownstreamInteraction); + } + + private static async Task<(int Status, JsonObject Body)> ExecuteAsync(IResult result) + { + var context = new DefaultHttpContext(); + context.RequestServices = new ServiceCollection() + .AddLogging().AddOptions().BuildServiceProvider(); + context.Response.Body = new System.IO.MemoryStream(); + await result.ExecuteAsync(context); + context.Response.Body.Position = 0; + return (context.Response.StatusCode, (JsonObject)(await JsonNode.ParseAsync(context.Response.Body))!); + } + + /// + /// A PS whose token endpoint defers with requirement=interaction and whose pending URL stays + /// pending until , then returns an auth token. Counts every request. + /// + private sealed class DeferredPersonServer : HttpMessageHandler + { + private volatile bool _approved; + public int TokenPosts; + public int PendingGets; + public int OtherRequests; + public TaskCompletionSource Polled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Approve() => _approved = true; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var path = request.RequestUri!.AbsolutePath; + if (path.Contains("well-known", StringComparison.Ordinal)) + return Task.FromResult(Json(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = PsUrl, + ["auth_token_endpoint"] = $"{PsUrl}/token", + })); + if (path == "/token" && request.Method == HttpMethod.Post) + { + Interlocked.Increment(ref TokenPosts); + return Task.FromResult(Pending(withInteraction: true)); + } + if (path == "/pending/1" && request.Method == HttpMethod.Get && request.Content is null) + { + if (Interlocked.Increment(ref PendingGets) >= 2) Polled.TrySetResult(); + return Task.FromResult(_approved + ? Json(HttpStatusCode.OK, new JsonObject { ["auth_token"] = "fake-auth-token" }) + : Pending(withInteraction: false)); + } + Interlocked.Increment(ref OtherRequests); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)); + } + + private static HttpResponseMessage Pending(bool withInteraction) + { + var response = Json(HttpStatusCode.Accepted, new JsonObject { ["status"] = "pending" }); + response.Headers.Location = new Uri($"{PsUrl}/pending/1"); + response.Headers.TryAddWithoutValidation("Retry-After", "0"); + response.Headers.TryAddWithoutValidation("Cache-Control", "no-store"); + if (withInteraction) + response.Headers.TryAddWithoutValidation(AAuthRequirementHeader.Name, + Interaction.Format("http://localhost:5555/interaction", "CONSENT", TestEgress.Policy)); + return response; + } + + private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body) => new(status) + { + Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), + }; + } +} diff --git a/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md b/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md index 799422a5..d5b8ee92 100644 --- a/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md +++ b/tests/AAuth.Tests/Api/DocumentationInventory.snapshot.md @@ -40,7 +40,7 @@ documentation change. |---|---|---|---| | [docs/advanced/clarification-chat.md](../../../docs/advanced/clarification-chat.md) | `e7519ea5a70cd598f1ecd137c119f3d7a5b8032e1b702011e34513e38b79d9b6` | 7 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/advanced/error-handling.md](../../../docs/advanced/error-handling.md) | `1af69a6836aba7fd621ffc3c97ac7f22451a172ed3f0f999335568bcff255f66` | 17 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [docs/advanced/interaction-chaining.md](../../../docs/advanced/interaction-chaining.md) | `8f6e2c30c95dc9f5618dce3e290dd1c41e6f3157119db8df0fb49e895ec024d0` | 6 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [docs/advanced/interaction-chaining.md](../../../docs/advanced/interaction-chaining.md) | `f2d3dd5c07d743df2aa6c4dea06fa90e722b72b944970f45903cb7dc993746c6` | 6 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/advanced/key-management.md](../../../docs/advanced/key-management.md) | `518e87828d95ad9c6d148647844a5f53f9c3c58970f7993e84b2193773a1e1ff` | 8 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/advanced/mission-governance-clients.md](../../../docs/advanced/mission-governance-clients.md) | `795e0e835936a27d0437205303d6f8d3ce623dd3857b117df2b6aa3eb8c2ba19` | 8 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [docs/advanced/missions.md](../../../docs/advanced/missions.md) | `3082b5ca14ea8edf95804423bcaf9e8db4a8f54386c2306006b94846b1979837` | 7 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | @@ -144,7 +144,7 @@ documentation change. | [samples/GuidedTour/StepRecord.cs](../../../samples/GuidedTour/StepRecord.cs) | `1528521c5b98f1bd9168120dceab7bb18c766069b1a2f01eabf1e8a833c9e801` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/GuidedTour/TourOptions.cs](../../../samples/GuidedTour/TourOptions.cs) | `f9b75a75ae8d00f4a716993fcbd49d2171dca47ae75aa95e0e34b0bc891182a3` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/GuidedTour/TourSession.Capabilities.cs](../../../samples/GuidedTour/TourSession.Capabilities.cs) | `b202680ef4974f8b65b228584fc952b5a5ca1b9f74c710dfdfa11f1a590ed43c` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/GuidedTour/TourSession.cs](../../../samples/GuidedTour/TourSession.cs) | `7cff25645341f824367a98d240e5dbda83e4d2ef7c9387b48004a3d84d44f34d` | 8 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/GuidedTour/TourSession.cs](../../../samples/GuidedTour/TourSession.cs) | `e7c9baeed5226d0811f2ae748b3fe7d0b625e9bce6fba8c3a1609a979f0bbe73` | 8 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MissionAgent/README.md](../../../samples/MissionAgent/README.md) | `ac36366a6e7bd5b910fd655365ee10bcf76f45845fb08c08198236045ab18105` | 9 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockAccessServers/Federated/README.md](../../../samples/MockAccessServers/Federated/README.md) | `677adbd2d4d7ad44ba9c1d4f6bd4e65104c8edc58d2a70882e4597ddd3e87eaa` | 3 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/MockAccessServers/README.md](../../../samples/MockAccessServers/README.md) | `08b43bfc4efb0efb68d38b8c130dbb0c76a353735dc7e95193cb0d3e39220b67` | 1 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | @@ -167,7 +167,7 @@ documentation change. | [samples/SampleApp/Components/Layout/NavMenu.razor](../../../samples/SampleApp/Components/Layout/NavMenu.razor) | `6d6cd8f53e0c1a6d77068839c27423538e1a6609f96bab3828fe293dabda57d4` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/Layout/ReconnectModal.razor](../../../samples/SampleApp/Components/Layout/ReconnectModal.razor) | `e1c8308c1ec6656c8f5cd50df3f1879b614e43109ad6b1e23ab26d6f1007c6db` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/Pages/Bookings.razor](../../../samples/SampleApp/Components/Pages/Bookings.razor) | `8e29aa02e8774f4cf3ded5ff7013c45c7cd3cd66fff60ce48ef1451bbb4fdd4b` | 17 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | -| [samples/SampleApp/Components/Pages/CallChain.razor](../../../samples/SampleApp/Components/Pages/CallChain.razor) | `9d8156b747f90f35014807a4b8951ed2142c9dbacc541d243a1324707b476c9f` | 13 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | +| [samples/SampleApp/Components/Pages/CallChain.razor](../../../samples/SampleApp/Components/Pages/CallChain.razor) | `9c9035e498ab6a17f5f41d2e6f091173021ffc3fc2923b2de5a0ad71ec5d5ab3` | 13 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/Pages/Catalog.razor](../../../samples/SampleApp/Components/Pages/Catalog.razor) | `df6f9ce0cd9882f8a6b37c06317144ed358243c60a3e3eec2965bfe79c0c6ebb` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/Pages/Deferred.razor](../../../samples/SampleApp/Components/Pages/Deferred.razor) | `4885c622e8985d2149f00b45adf5fbc52cbcaa16451d2e2ce89bda96d6950825` | 6 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | | [samples/SampleApp/Components/Pages/Documents.razor](../../../samples/SampleApp/Components/Pages/Documents.razor) | `da426700ace55e9931a0af23c3be4771847ce9146ffe993f99689d5fd2df373f` | 0 | Frozen source; links/patterns; blocks below; capability matrix for runtime/browser evidence | @@ -240,12 +240,12 @@ documentation change. | [docs/advanced/error-handling.md:fence-15](../../../docs/advanced/error-handling.md#L371) | csharp | `1d857fef0e313b9c8ae9797157cb0cc0f7bef3fae7334d3267fd5a773d7ca9ea` | API excerpt: source member/type/sealed checks; not executable | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [docs/advanced/error-handling.md:fence-16](../../../docs/advanced/error-handling.md#L400) | csharp | `f20b4e55cd9acef5619bdd7b500b1daf34893d76e3a18ed2dc2404127b7bbd80` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [docs/advanced/error-handling.md:fence-17](../../../docs/advanced/error-handling.md#L412) | csharp | `69087d67b02e6bffbca2985aa9578940aa1fd60a49359e576c260b7cc71ce79b` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [docs/advanced/interaction-chaining.md:fence-1](../../../docs/advanced/interaction-chaining.md#L28) | mermaid | `3834648481beaeeb67f090a6fbc243b579153e41effc18b4a1b82d75d5674539` | Sequence/flow source checked; actors/order reviewed against mapped scenario | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [docs/advanced/interaction-chaining.md:fence-2](../../../docs/advanced/interaction-chaining.md#L64) | csharp | `8c3eb5aedfebd7a40212875a7ced3e6c273bee9c3c3beb92d55a1ff60d8f1b32` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [docs/advanced/interaction-chaining.md:fence-3](../../../docs/advanced/interaction-chaining.md#L119) | csharp | `1ea823e517bd34cb510a02f7a07f4ec5e8fa4e8f2fd4037cfabc31bd2301dc3d` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [docs/advanced/interaction-chaining.md:fence-4](../../../docs/advanced/interaction-chaining.md#L138) | csharp | `caa6deff7c69980217869804676714fe45b699559d9def52cb6400e40232e15e` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [docs/advanced/interaction-chaining.md:fence-5](../../../docs/advanced/interaction-chaining.md#L176) | csharp | `ce74991bce79ecccc39532cfcf3804a0896e5ce502f4feefd152547769c3f318` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | -| [docs/advanced/interaction-chaining.md:fence-6](../../../docs/advanced/interaction-chaining.md#L207) | csharp | `dc6a5ae381c23460fbeb8876da427a7590fe81366505c41a79fd665c3ff7760c` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [docs/advanced/interaction-chaining.md:fence-1](../../../docs/advanced/interaction-chaining.md#L35) | mermaid | `5eb7a6d92cd55afc1b6a4b788858919e2967b0e2789d6eec666c83136b618304` | Sequence/flow source checked; actors/order reviewed against mapped scenario | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [docs/advanced/interaction-chaining.md:fence-2](../../../docs/advanced/interaction-chaining.md#L78) | csharp | `2c1ed0be797e08797b8ba85c113aabdfccd79d7f67b8ea98cef6c7e28b9b90af` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [docs/advanced/interaction-chaining.md:fence-3](../../../docs/advanced/interaction-chaining.md#L137) | csharp | `4d4f62aef74d9df3b8939db5a97ac77a5bc31c361a1a970fae9d68011f29d96a` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [docs/advanced/interaction-chaining.md:fence-4](../../../docs/advanced/interaction-chaining.md#L165) | csharp | `e4ac1b8b0faedf7809275806e57dda9c52d68bf46a3d09f51fdbc843e5755e09` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [docs/advanced/interaction-chaining.md:fence-5](../../../docs/advanced/interaction-chaining.md#L203) | csharp | `ce74991bce79ecccc39532cfcf3804a0896e5ce502f4feefd152547769c3f318` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | +| [docs/advanced/interaction-chaining.md:fence-6](../../../docs/advanced/interaction-chaining.md#L235) | csharp | `4e739ad874405e2f8e5cba15cb39f73be8909babecb8ddb1f720ee8ebc17cb21` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [docs/advanced/key-management.md:fence-1](../../../docs/advanced/key-management.md#L36) | csharp | `e9793b79d5fe459380b892beaec00598c2e90f9abf1dc1e36a726939de72eae1` | API excerpt: source member/type/sealed checks; not executable | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [docs/advanced/key-management.md:fence-2](../../../docs/advanced/key-management.md#L52) | csharp | `6fd5ae59073ba7176ae15702664b8a8436c545c2ebbd0778e4c53f5c9c770a83` | Exact C# compiled with typed prior-step/host inputs | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | | [docs/advanced/key-management.md:fence-3](../../../docs/advanced/key-management.md#L62) | csharp | `b0b8acd9af234ff216fee116a9388a52ba3da03bb610d050974c8d6d333aaff5` | API excerpt: source member/type/sealed checks; not executable | [API owning-source map](api-surface-map.md), [section-specific source/tests](conformance-ledger.md), [exact compiler](../../../tests/AAuth.Tests/Api/SnippetCompilationTests.cs) | @@ -790,8 +790,8 @@ documentation change. | [samples/SampleApp/Components/Pages/Bookings.razor:inline-13](../../../samples/SampleApp/Components/Pages/Bookings.razor#L135) | inline-code | `1b926447cdc6ac734336c3f96612a4ebb43930848e698b46dbb925a3deadfdd0` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | | [samples/SampleApp/Components/Pages/Bookings.razor:inline-14](../../../samples/SampleApp/Components/Pages/Bookings.razor#L136) | inline-code | `dcaadad1cfce437735b81ab025f776e5857e48558c47f6960e6a5f2595664a85` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | | [samples/SampleApp/Components/Pages/CallChain.razor:pre-1](../../../samples/SampleApp/Components/Pages/CallChain.razor#L60) | csharp | `5e1ebbd72779d5d3215d0a3d6accf98e8915b0a578ec0f84f4048408fa77c03e` | Exact C# compiled with typed prior-step/host inputs | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | -| [samples/SampleApp/Components/Pages/CallChain.razor:pre-2](../../../samples/SampleApp/Components/Pages/CallChain.razor#L74) | csharp | `ada4b14d6c40147ee4a5d7be6f147fad3d312bde6c8880052e63d3fd6edc9b57` | Exact C# compiled with typed prior-step/host inputs | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | -| [samples/SampleApp/Components/Pages/CallChain.razor:pre-3](../../../samples/SampleApp/Components/Pages/CallChain.razor#L174) | dynamic | `031f5bdf4c2be7d2ebe51bf0bfe5931c404f567cad8704483cb4a0514a3b5d8e` | Dynamic Razor binding: build plus mapped scenario browser/captured-wire tests | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | +| [samples/SampleApp/Components/Pages/CallChain.razor:pre-2](../../../samples/SampleApp/Components/Pages/CallChain.razor#L74) | csharp | `c3ab7352c2bf6ecad5f7831f1dfc57ea54682a9f5335af61714178c7ce209e47` | Exact C# compiled with typed prior-step/host inputs | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | +| [samples/SampleApp/Components/Pages/CallChain.razor:pre-3](../../../samples/SampleApp/Components/Pages/CallChain.razor#L175) | dynamic | `031f5bdf4c2be7d2ebe51bf0bfe5931c404f567cad8704483cb4a0514a3b5d8e` | Dynamic Razor binding: build plus mapped scenario browser/captured-wire tests | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | | [samples/SampleApp/Components/Pages/CallChain.razor:inline-1](../../../samples/SampleApp/Components/Pages/CallChain.razor#L31) | inline-code | `cbe370481704cef068c18bdcbe262329c59824d6c87e96510a53f022e899ab04` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | | [samples/SampleApp/Components/Pages/CallChain.razor:inline-2](../../../samples/SampleApp/Components/Pages/CallChain.razor#L33) | inline-code | `2505b184cfaffd55bf75d2cd98718f94d14d4924b1773eab7f072a1fcb6bdf9b` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | | [samples/SampleApp/Components/Pages/CallChain.razor:inline-3](../../../samples/SampleApp/Components/Pages/CallChain.razor#L34) | inline-code | `c17edaae86e4016a583e098582f6dbf3eccade8ef83747df9ba617ded9d31309` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | @@ -800,8 +800,8 @@ documentation change. | [samples/SampleApp/Components/Pages/CallChain.razor:inline-6](../../../samples/SampleApp/Components/Pages/CallChain.razor#L48) | inline-code | `6527c9361a2f469c5275afcb5d06e53013367cd231995de13dc7218711388382` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | | [samples/SampleApp/Components/Pages/CallChain.razor:inline-7](../../../samples/SampleApp/Components/Pages/CallChain.razor#L49) | inline-code | `39158e0110cbcadec00557639c5ff0adf32f6285c46c235eb259dc13c8d31b45` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | | [samples/SampleApp/Components/Pages/CallChain.razor:inline-8](../../../samples/SampleApp/Components/Pages/CallChain.razor#L51) | inline-code | `cbdd8637f090e7de25403ed8482aae56b66be61046629696f01c8ca3d3a03d63` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | -| [samples/SampleApp/Components/Pages/CallChain.razor:inline-9](../../../samples/SampleApp/Components/Pages/CallChain.razor#L123) | inline-code | `5cc17726782872bd0c1afe0afe7a75ab11881a254e9b70fbb52f519584f27129` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | -| [samples/SampleApp/Components/Pages/CallChain.razor:inline-10](../../../samples/SampleApp/Components/Pages/CallChain.razor#L150) | inline-code | `772e10a0c0a795b97d1391d2d7c4b0cb1fde19ac5027c48de6008e10dede67e3` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | +| [samples/SampleApp/Components/Pages/CallChain.razor:inline-9](../../../samples/SampleApp/Components/Pages/CallChain.razor#L124) | inline-code | `5cc17726782872bd0c1afe0afe7a75ab11881a254e9b70fbb52f519584f27129` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | +| [samples/SampleApp/Components/Pages/CallChain.razor:inline-10](../../../samples/SampleApp/Components/Pages/CallChain.razor#L151) | inline-code | `772e10a0c0a795b97d1391d2d7c4b0cb1fde19ac5027c48de6008e10dede67e3` | Inline Razor label/expression: source inventory, build and scenario display checks; not a standalone program | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | | [samples/SampleApp/Components/Pages/Deferred.razor:pre-1](../../../samples/SampleApp/Components/Pages/Deferred.razor#L33) | csharp | `d2cf8d52a72dcc3125d20b9a25af67ad12b9ee840c686403db7363d821b8d501` | Exact C# compiled with typed prior-step/host inputs | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | | [samples/SampleApp/Components/Pages/Deferred.razor:pre-2](../../../samples/SampleApp/Components/Pages/Deferred.razor#L51) | csharp | `89b1010f45dc274e277c21612e25c5701aacf0588b97b865de1ab46b45718b99` | Exact C# compiled with typed prior-step/host inputs | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | | [samples/SampleApp/Components/Pages/Deferred.razor:pre-3](../../../samples/SampleApp/Components/Pages/Deferred.razor#L133) | dynamic | `031f5bdf4c2be7d2ebe51bf0bfe5931c404f567cad8704483cb4a0514a3b5d8e` | Dynamic Razor binding: build plus mapped scenario browser/captured-wire tests | [capability-to-flow/spec map](capability-scenarios.md), [tour state](../../../samples/GuidedTour/TourSession.cs), [app specs](../../../samples/SampleApp/playwright-tests/), [tour specs](../../../samples/GuidedTour/playwright-tests/) | diff --git a/tests/AAuth.Tests/Integration/ConciergePendingSecurityTests.cs b/tests/AAuth.Tests/Integration/ConciergePendingSecurityTests.cs index d16d258c..10acaf14 100644 --- a/tests/AAuth.Tests/Integration/ConciergePendingSecurityTests.cs +++ b/tests/AAuth.Tests/Integration/ConciergePendingSecurityTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json.Nodes; +using AAuth.Agent; using AAuth.Crypto; using AAuth.Discovery; using AAuth.Headers; @@ -40,6 +41,47 @@ public async Task ChainedInteractionPendingBody_UsesPendingStatus() Assert.Equal("/pending/pending-test", context.Response.Headers.Location.ToString()); } + [Fact] + public async Task ChainedEntry_RekeysOnNewDownstreamInteraction_AndKeepsEarlierCodesValid() + { + var first = new Interaction("https://ps.example/interaction", "PSCODE"); + var second = new Interaction("https://as.example/interaction/login", "ASCODE"); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + IAAuthInteractionHandler? interactions = null; + var operation = await AAuthChainedOperation.StartAsync(async (handler, ct) => + { + interactions = handler; + await handler.OnInteractionRequiredAsync(first, ct); + return await release.Task.WaitAsync(ct); + }, DateTimeOffset.UtcNow.AddMinutes(10)); + var parked = AAuthChainedInteractions.Park("https://concierge.example", "/pending", "/chain-interaction", + operation.Interaction!.Downstream, "test", new JsonObject(), DateTimeOffset.UtcNow.AddMinutes(10)); + var issuerKey = AAuthKey.Generate(); + var upstream = await new AuthTokenBuilder + { + Issuer = "https://ps.example", Audience = "https://concierge.example", PersonServer = "https://ps.example", + Subject = "aauth:owner@ap.example", AgentConfirmationKey = AAuthKey.Generate(), + AgentTokenExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), Key = issuerKey, KeyId = "ps-key", Scope = "concierge", + }.BuildAsync(); + var entry = new Concierge.PendingStore().Add(upstream, parked, "/pending", operation, operation.Interaction!.Version); + + Assert.Equal(parked, entry.Interaction); + await interactions!.OnInteractionRequiredAsync(second, default); + + var rekeyed = entry.Interaction; + Assert.NotEqual(parked.Code, rekeyed.Code); + Assert.Equal(parked.Id, rekeyed.Id); + Assert.Equal(parked.PendingUrl, rekeyed.PendingUrl); + Assert.Equal(second, rekeyed.DownstreamInteraction); + Assert.Same(rekeyed, entry.Interaction); + Assert.True(entry.MatchesCode(parked.Code)); + Assert.True(entry.MatchesCode(rekeyed.Code)); + Assert.False(entry.MatchesCode("WRONGCODE")); + + release.SetResult(Results.Ok()); + await operation.Completion; + } + [Theory] [InlineData("agent", "GET")] [InlineData("key", "GET")]