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/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/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/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 bde47328..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): @@ -84,12 +86,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 @@ -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/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/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/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/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/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/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 92c438f1..7060686a 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 \ @@ -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` @@ -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/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/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/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/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/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/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/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/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 84106661..d5b8ee92 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 @@ -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 | @@ -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 | @@ -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) | `c21e175a15f51a47cc9dfb61b95604be87ef3d7ff07e6551c3af14c3c8cd3321` | 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 | @@ -144,22 +144,22 @@ 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) | `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) | `1bd1e1a46c3ac04072a577197afd1e687f9d0a50cef7f09c016b6e6748e3ae0b` | 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) | `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 | @@ -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) | @@ -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 | `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-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 | @@ -723,9 +724,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 +734,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 +744,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) | @@ -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) | @@ -789,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/) | @@ -799,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/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/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")] 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, + }; } 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]