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]