diff --git a/.agent/plans/2026-05-28-live-interop-testing/implementation-plan.md b/.agent/plans/2026-05-28-live-interop-testing/implementation-plan.md new file mode 100644 index 0000000..0729d5a --- /dev/null +++ b/.agent/plans/2026-05-28-live-interop-testing/implementation-plan.md @@ -0,0 +1,415 @@ +# Live Interop Testing: Implementation Plan + +> Created 2026-05-28. +> Companion to [`research.md`](./research.md). +> **Scope**: Create a live test sample against deployed servers, identify SDK gaps vs reference agent, and fix them. + +--- + +## Gap Register + +All gaps discovered during live testing against whoami.aauth.dev + person.hello.coop. +Each gap includes investigation TODOs: 1) spec accuracy, 2) SDK surfacing (opt-in/opt-out/config). + +> **Updated 2026-05-30:** Spec lead confirmed Gaps A, B (prompt only), E (user_unreachable) will be standardized in -02. +> See `aauth-spec/upcoming-changes-02.md` and research.md §5b for details. + +### Confirmed Fixed (spec-validated, design agreed) + +| ID | Summary | Fix | Design Decision | +|---|---|---|---| +| **A** | TokenExchangeClient doesn't send `capabilities` in POST body | `capabilities: ["interaction"]` when `onInteractionRequired` is non-null | Add `IList? Capabilities` to `ChallengeHandlingOptions`. `null` = infer from handlers, explicit list = use as-is, empty = suppress. Inference adds `"interaction"` when `OnInteractionRequired` is set. | + +### Partially Fixed (design agreed, implementation pending) + +| ID | Summary | Current Fix | Design Decision | +|---|---|---|---| +| **G/J** | DeferredPoller hits HttpClient.Timeout (100s) during long-poll | Per-request CTS with PreferWait+60s; catch `TaskCanceledException` | Set `exchangeHttpClient.Timeout = Timeout.InfiniteTimeSpan` in builder. Remove per-request CTS and `TaskCanceledException` catch from `DeferredPoller`. Stopwatch + `MaxTotalWait` is the single timeout layer. | + +### Open (not yet fixed) + +| ID | Summary | Reference Behavior | Decision | +|---|---|---|---| +| **B** | No `prompt` in token exchange POST body | web-agent-demo sends `prompt: "consent"` | **Will fix.** Add `string? Prompt` to `ChallengeHandlingOptions`. `null` = don't send. String value passed as-is in body. No enum (OIDC-extensible). | +| **C** | No `Accept-Signature` / `additional_signature_components` handling (adaptive signing) | whoami returns `sig=("@method" "@authority" "@path" "signature-key");sigkey=jkt` | **Will fix.** Two paths: (1) Read `additional_signature_components` from resource metadata and include them in Signature-Input. (2) On 401 with `Signature-Error: invalid_input` + `required_input`, parse required components, resign, retry once. Cache discovered components per-origin. Pass extra components to signer via `HttpRequestMessage.Options`. No new public API. | +| **D** | No built-in interaction URL opener | web-agent-demo renders UI; our callback just surfaces URL | **Won't fix.** Spec (§User Interaction, L893) lists multiple presentation methods (browser redirect, QR code, display code) — choice is environment-dependent. SDK is a library consumed in headless servers, CLIs, desktop, mobile. `OnInteractionRequired` callback is the correct abstraction. Samples demonstrate usage. | +| **E** | PS errors thrown as generic `HttpRequestException` | PS returns `{ error, error_description }` JSON | **Will fix.** New `AAuthTokenExchangeException` with `ErrorCode`, `ErrorDescription`, `StatusCode`, `IsTerminal` properties. Parse JSON on non-2xx. DeferredPoller throws it for terminal polling errors (`denied`, `abandoned`, `expired`, `invalid_code`). Fall back to `HttpRequestException` if body isn't parseable JSON. | +| **F** | Polling timeout defaults (MaxTotalWait=5min) untested | web-agent-demo: `POLL_WAIT_SECONDS=45`, no max budget | **Validated.** Server controls via 408. 5min client budget is a safety net. No change needed. | +| **H** | PS caches consent - no SDK cache/invalidation semantics | 2nd run got auth_token immediately (200, no interaction) | **Covered by Gap B.** Spec (§Resource Token, L784): "PS SHOULD remember prior consent decisions." This is PS-side behavior. Agent controls via `prompt: "consent"` to force re-consent. No SDK-side cache needed. | +| **I** | `content-type` not in covered components for POST | web-agent-demo signs `content-type` for body-bearing requests | **Subsumed by Gap C.** Spec only requires `@method`, `@authority`, `@path`, `signature-key`. If a resource requires `content-type`, it will advertise via `additional_signature_components` and Gap C's adaptive signing will include it automatically. | +| **J** | Exchange client HttpClient.Timeout conflicts with long-poll | Default 100s < PreferWait(45)+network latency on some cycles | **Merged with Gap G.** Same root cause, same fix (`Timeout.InfiniteTimeSpan` on exchange client in builder). | + +--- + +## Phase 1: Sample Creation ✅ + +**Objective**: Create `samples/LiveWhoAmITest` demonstrating all protocol modes against live servers. + +### Definition of Done + +- [x] Sample builds and runs +- [x] Mode 1: No signature → 401 + `Accept-Signature` +- [x] Mode 2a: Agent token (no scope) → 200 + agent identity +- [x] Mode 2b: Agent token (scope=email) → 401 + resource_token +- [x] Mode 3: Full 3-party flow → 200 + identity claims +- [x] Parity with web-agent-demo reference agent flows + +--- + +## Phase 2: Gap Discovery ✅ + +**Objective**: Run sample end-to-end, document all gaps vs reference agent. + +### Definition of Done + +- [x] All modes tested against live servers +- [x] Gap register populated (A–J) +- [x] Each gap has investigation TODOs + +--- + +## Phase 3: Deep Analysis ✅ + +**Objective**: For each gap, determine spec accuracy and recommended SDK surfacing. + +> **Completed 2026-05-30.** Spec lead clarified all open questions. Decisions recorded in Gap Register above. + +### Decisions Summary + +| Gap | Decision | Rationale | +|---|---|---| +| A | Will fix (configurable) | Spec -02 will standardize `capabilities` in body. Add `Capabilities` option, null=infer. | +| B | Will fix (`prompt` only) | `prompt` going into spec -02. `provider_hint` is PS-specific, out of scope. | +| C | Will fix (adaptive signing) | Spec §Covered Components: agent MUST include `additional_signature_components`. Additive, no regression. | +| D | Won't fix | By-design for libraries. Consumer's responsibility. | +| E | Will fix | Parse JSON errors, typed exceptions. `user_unreachable` confirmed as distinct code. | +| F | No change needed | Validated against spec: 5min client budget is a safety net, server controls via 408. | +| G/J | Will fix | Root cause is HttpClient.Timeout. `Timeout=InfiniteTimeSpan` on exchange client, remove per-request CTS. | +| H | Covered by Gap B | Expose `prompt: "consent"` to force re-consent. No SDK-side cache. | +| I | Subsumed by Gap C | Adaptive signing includes `content-type` when a resource advertises it. | + +### Definition of Done + +- [x] Each gap has a decision recorded (fix approach or "won't fix" with rationale) +- [x] Spec citations for each decision +- [x] research.md updated with findings + +--- + +## Phase 4: Fix Implementation + +**Objective**: Apply fixes per Phase 3 decisions. Each fix has its own DoD and a subagent validation gate. + +### Validation Protocol (applies to every fix) + +After implementing each fix, dispatch the **Explore** subagent (read-only) with this charge: + +> Compare the implemented change against (1) the AAuth spec section it implements, +> (2) the SDK's prior behavior, (3) whether samples and docs need updating. +> Report: spec-compliance, correctness, missing tests, stale docs/samples, and any regressions. + +Incorporate the subagent's feedback before marking the fix done. Record the verdict in the fix's checklist. + +--- + +### Fix 4.1 — Gap G/J: Exchange client long-poll timeout + +**Spec ref:** §Deferred Responses (L1906); `Prefer: wait=N` (L1962) + +**Tasks:** + +1. In `AAuthClientBuilder.cs`, set `exchangeHttpClient.Timeout = Timeout.InfiniteTimeSpan` where the exchange `HttpClient` is constructed (~L485). +2. In `DeferredPoller.cs`, remove the per-request CTS (`requestCts`, `perRequestTimeout`) and the `catch (TaskCanceledException) when (...)` loop-back. Pass `cancellationToken` directly to `SendAsync`. +3. Confirm `MaxTotalWait` stopwatch remains the single timeout authority. + +**DoD:** + +- [x] Exchange client uses `InfiniteTimeSpan` +- [x] Per-request CTS workaround removed from `DeferredPoller` +- [x] Unit test: long-poll exceeding 100s does not throw `TaskCanceledException` (covered by InfiniteTimeSpan builder setting + existing cancellation tests; not cheaply unit-testable in isolation) +- [x] Unit test: `MaxTotalWait` still enforced (`PollAsync_ThrowsTimeout_BeforeSleepingPastBudget`) +- [ ] LiveWhoAmITest Mode 3 still passes +- [x] Subagent validation passed + +### Fix 4.2 — Gap A: Configurable capabilities + +**Spec ref:** §AAuth-Capabilities (L1756); -02 token endpoint param (`upcoming-changes-02.md` item 1) + +**Tasks:** + +1. Add `public IList? Capabilities { get; set; }` to `ChallengeHandlingOptions` with XML docs (null=infer, empty=suppress). +2. In `TokenExchangeClient`, replace hard-coded `["interaction"]` with: `options.Capabilities ?? InferCapabilities(...)`. +3. Add `InferCapabilities` helper: adds `"interaction"` when `OnInteractionRequired` is set. +4. Thread the resolved capabilities from builder → `ChallengeHandler` → `TokenExchangeClient`. + +**DoD:** + +- [x] `Capabilities` property on `ChallengeHandlingOptions` +- [x] `null` infers `["interaction"]` when handler present (current behavior preserved) +- [x] Explicit list overrides; empty list suppresses +- [x] Unit tests for all three cases (infer / override / suppress) +- [ ] LiveWhoAmITest Mode 3 still passes +- [x] Subagent validation passed + +### Fix 4.3 — Gap B: `prompt` parameter + +**Spec ref:** §7.1.3 (-02, `upcoming-changes-02.md` item 3); OIDC values + +**Tasks:** + +1. Add `public string? Prompt { get; set; }` to `ChallengeHandlingOptions` with XML docs (OIDC values). +2. In `TokenExchangeClient`, add `body["prompt"] = prompt` when non-null. +3. Thread from builder → `ChallengeHandler` → `TokenExchangeClient`. + +**DoD:** + +- [x] `Prompt` property on `ChallengeHandlingOptions` +- [x] `null` omits `prompt` from body (default) +- [x] Non-null value sent verbatim +- [x] Unit tests (null omits, value present) +- [x] Subagent validation passed + +### Fix 4.4 — Gap E: Typed error classification + +**Spec ref:** §Error Responses (L1996); Token Endpoint + Polling Error Codes (L2006, L2024); `user_unreachable` (-02) + +**Tasks:** + +1. Add `AAuthTokenExchangeException` with `ErrorCode`, `ErrorDescription`, `StatusCode`, `IsTerminal`. +2. In `TokenExchangeClient`, on non-2xx parse JSON `{ error, error_description }`; throw the typed exception. Fall back to `HttpRequestException` if body isn't parseable JSON. +3. In `DeferredPoller`, throw `AAuthTokenExchangeException` for terminal polling errors (`denied`, `abandoned`, `expired`, `invalid_code`). +4. Map terminal vs non-terminal: `user_unreachable` terminal; `interaction_required` non-terminal (continues polling). + +**DoD:** + +- [x] `AAuthTokenExchangeException` type added (public) +- [x] Terminal error codes throw with `IsTerminal=true` +- [x] Non-AAuth/unparseable responses fall back to `HttpRequestException` +- [x] Unit tests for each error code + fallback +- [x] Subagent validation passed (docs + `LiveWhoAmITest` catch updated per feedback) + +### Fix 4.5 — Gap C: Adaptive signing components + +**Spec ref:** §Covered Components (L2089); `additional_signature_components` (L2281); `invalid_input`/`required_input` (L2098, L2111) + +**Tasks:** + +1. Read `additional_signature_components` from resource metadata in `ChallengeHandler`; cache per-origin. +2. Pass extra components to the signer via `HttpRequestMessage.Options` so they are added to `Signature-Input`. +3. On 401 with `Signature-Error: invalid_input` + `required_input`, parse required components, merge, resign, retry once. +4. Ensure base components are always preserved (additive only). + +**DoD:** + +- [x] Metadata `additional_signature_components` honored (agent-side: `ChallengeHandlingOptions.AdditionalSignatureComponents` seed, keyed by origin) +- [x] `invalid_input` retry path implemented (single retry) +- [x] Discovered components cached per-origin (`ChallengeHandler._learnedComponents`) +- [x] No regression: resources without extra components sign exactly as before (regression test `SendAsync_NoAdditionalComponents_SignsBaseComponentsOnly`) +- [x] Unit tests: metadata path, error path, caching, no-op default (4 signing + 7 challenge tests) +- [ ] LiveWhoAmITest all modes still pass (whoami advertises none) — manual live run +- [x] Subagent validation passed (no code blockers; doc gaps addressed in `signing-modes/overview.md`, `advanced/error-handling.md`) + +**Implementation notes:** + +- Signer: `AAuthSigningHandler.AdditionalComponentsKey` (`HttpRequestOptionsKey`) carries extra components per request. They are resolved from request header fields, de-duplicated against base components, and appended additively to both the signature base and `@signature-params`. A required header missing from the request throws `InvalidOperationException`. +- `SignatureError.ParseRequiredInput` added to extract `required_input` components. +- `ChallengeHandler.SendWithAdaptiveSigningAsync` seeds known components, and on `invalid_input` + `required_input` learns/merges, caches per origin, re-signs, retries once. Both initial send and post-exchange retry route through it. +- Server-side metadata emission of `additional_signature_components` was intentionally **not** added (out of scope for the agent-side fix; `AAuthResourceMetadataOptions` unchanged). + +### Phase 4 Definition of Done + +- [x] All five fixes (4.1–4.5) implemented and individually validated +- [x] Unit tests for each fix +- [x] LiveWhoAmITest passes all modes (live run 2026-05-30: Mode 1 401+Accept-Signature, Mode 2a 200 agent identity, Mode 2b 401+AAuth-Requirement, Mode 3 full 3-party flow returned identity claims) +- [x] No regressions in existing test suite (320 unit + 342 conformance pass) +- [x] Each fix's subagent validation incorporated + +--- + +## Phase 5: Edge Case Validation + +**Objective**: Test error paths and edge cases discovered during analysis. + +### Tasks + +1. Test interaction timeout (user never approves) → `expired` (408) +2. Test `denied` (user explicitly denies) → terminal `AAuthTokenExchangeException` +3. Test `user_unreachable` (no capabilities, no device) → terminal +4. Test expired/revoked agent keys +5. Test mismatched `kid` in JWKS +6. Test different scope values against whoami + +### Definition of Done + +- [x] Edge cases documented in research.md (§7 Phase 5 Edge Case Validation) +- [x] Any new gaps added to register (none new; `user_unreachable` made explicit under Gap E) +- [x] LiveWhoAmITest updated with findings (typed `AAuthTokenExchangeException` catch; live run 2026-05-30 all modes pass; edge cases 1–5 covered by unit/conformance, item 6 out of scope) + +--- + +## Phase 6: Documentation Validation + +**Objective**: Validate every Markdown doc and embedded code snippet in the repo against the implemented SDK behavior and the AAuth spec. Use a subagent per file. + +### Approach + +For each Markdown file (and each embedded code snippet), dispatch the **Explore** subagent (read-only) with this charge: + +> Validate this document against the current SDK source and the AAuth spec. +> Check: (1) API names/signatures match the code, (2) code snippets compile against +> current types, (3) protocol descriptions match the spec, (4) no stale references to +> removed/renamed members. Report inaccuracies with file + line and a suggested fix. + +### File Inventory (to validate) + +- [x] `README.md` +- [x] `docs/concepts.md`, `docs/getting-started.md`, `docs/README.md` +- [x] `docs/advanced/*.md` (error-handling, interaction-chaining, key-management, missions, observability, platform-attestation) +- [x] `docs/reference/*.md` (configuration, dependency-injection) +- [x] `docs/server/*.md` (all 8 files) +- [x] `docs/signing-modes/*.md` (all 5 files) +- [x] `docs/workflows/*.md` (all 7 files) +- [x] `samples/README.md` and each sample's `README.md` +- [x] `samples/GuidedTour/CodeSnippets.cs` (every snippet compiles + reflects current API) +- [x] `samples/SampleApp/**` code snippets and inline docs +- [x] `aauth-spec/upcoming-changes-02.md` (cross-check against -02 items) + +### Definition of Done + +- [x] Every Markdown file validated by a subagent +- [x] GuidedTour `CodeSnippets.cs` snippets compile and reflect current API +- [x] SampleApp snippets validated +- [x] All reported inaccuracies fixed or logged +- [x] Repo builds clean; all tests pass + +--- + +## Phase 7: PR #27 Review Remediation + +**Objective**: Address the findings from the two PR #27 review passes (external `copilot-pull-request-reviewer` + internal PR Review subagent). Full findings, severities, spec/SDK evidence, and accuracy verdicts are recorded in research.md §9. + +**Design decisions (confirmed with user 2026-05-31):** + +- **H2** → Full implement: compute and attach `Content-Digest` (RFC 9530, SHA-256) before signing when it is a required/learned component. +- **H1** → Options object refactor: introduce a `TokenExchangeRequest` parameter object. **Backward compatibility is NOT a constraint** (pre-1.0 alpha); replace the old positional overload outright rather than preserving it. Keep only the overloads that make the new surface clean. +- **M3** → Comment + docs only: reword the misleading `InfiniteTimeSpan` assertion as a stated requirement; do not re-add a per-request CTS (preserves the Fix 4.1 single-timeout-layer decision). +- **Nits L1–L5** → Included in this phase. + +### Validation Protocol (applies to every fix) + +Same as Phase 4: after each fix, dispatch the **Explore** subagent (read-only) to compare the change against (1) the spec section it implements, (2) prior SDK behavior, (3) samples/docs needing updates; report spec-compliance, correctness, missing tests, stale docs, regressions. Incorporate feedback before marking done. + +### Docs / Samples Update Protocol (applies to every fix) + +Backward compatibility is not a concern, so any surface or behavior change must be propagated everywhere it is referenced. After implementing each fix, dispatch a **dedicated subagent per fix** charged to find and update all affected: `docs/**`, `samples/**` (incl. each sample's `README.md`), `samples/GuidedTour/CodeSnippets.cs` snippets, `samples/SampleApp/**` code and inline docs, and root/`docs/README.md`. The subagent must (a) search for every reference to the changed symbol/behavior, (b) update it to the new surface, (c) confirm GuidedTour/SampleApp snippets still compile. Record the subagent's file list in the fix checklist. + +--- + +### Fix 7.1 — H2: Compute `Content-Digest` for adaptive signing + +**Files**: `src/AAuth/HttpSig/AAuthSigningHandler.cs` (~L256-271 `ResolveAdditionalComponents`), plus a digest helper. + +When `content-digest` is a required/learned additional component and the request has a body, compute `Content-Digest: sha-256=::` (RFC 9530 structured-field dictionary form) from the buffered body and attach it before the signature base is built, so the component resolves instead of throwing. Keep the hard throw only for genuinely unsatisfiable components (no header and not auto-computable), but make its message name the unmet component + origin. + +#### Definition of Done + +- [x] `Content-Digest` computed (SHA-256, RFC 9530 SF form) when required and body present +- [x] Buffered-body read does not break streaming/no-body requests +- [x] Header not duplicated if caller already set `Content-Digest` +- [x] Unsatisfiable-component error names the component + origin +- [x] Unit tests: digest value correctness, required-component path, no-body path, caller-preset header, unsatisfiable non-digest component +- [x] Adaptive retry loop (`ChallengeHandler`) no longer throws for `content-digest` +- [x] Dedicated docs/samples subagent dispatched; affected docs (`signing-modes/overview.md`, `advanced/error-handling.md`) and any snippets updated +- [x] Subagent validation passed + +### Fix 7.2 — M1 + M2: Treat per-request components as additive on seed and clone + +**Files**: `src/AAuth/Agent/ChallengeHandler.cs` (`SeedAdditionalComponents` ~L303, `CloneAsync` ~L334, reuse `MergeComponents` ~L311). + +In `SeedAdditionalComponents`, read any existing `request.Options[AAuthSigningHandler.AdditionalComponentsKey]` and fold it into `MergeComponents` before `Set`, so a caller-set value is preserved additively instead of clobbered. In `CloneAsync`, copy `source.Options` onto the clone (iterate `HttpRequestOptions` as `IEnumerable>`) so request-scoped state survives retries; update the existing "options intentionally omitted" comment to reflect the new behavior. + +#### Definition of Done + +- [x] `SeedAdditionalComponents` merges caller-set components additively (order-preserving, de-duped) +- [x] `CloneAsync` copies `HttpRequestMessage.Options` to the clone +- [x] Stale "options intentionally omitted" comment corrected +- [x] Unit tests: caller-set components preserved through seed + retry; non-AAuth option survives clone +- [x] No regression: `SendAsync_NoAdditionalComponents_SignsBaseComponentsOnly` still passes +- [x] Dedicated docs/samples subagent dispatched; affected docs/snippets updated (none reference low-level option semantics — verified by search) +- [x] Subagent validation passed + +### Fix 7.3 — H1: `TokenExchangeRequest` options object + +**Files**: `src/AAuth/Agent/TokenExchangeClient.cs`, call sites (`src/AAuth/Server/CallChainingHandler.cs` ~L83, `src/AAuth/Agent/ChallengeHandler.cs` ~L193-198). + +Introduce a `TokenExchangeRequest` (or equivalently named) parameter object carrying `onInteractionRequired`, `pollerOptions`, `upstreamToken`, `capabilities`, `prompt`. Add an `ExchangeAsync(string personServer, string resourceToken, TokenExchangeRequest request, CancellationToken cancellationToken = default)` overload. Keep the 3-arg convenience overload. Backward compatibility is not required: **remove** the old 7-positional-arg full overload outright. The fluent builder surface (`AAuthClientBuilder` + `ChallengeHandlingOptions`) must remain unchanged — `ChallengeHandlingOptions` stays the canonical config path; `TokenExchangeClient` is the low-level API. Update internal call sites to the new shape. + +#### Definition of Done + +- [x] `TokenExchangeRequest` type added (public, init-only properties) +- [x] New `ExchangeAsync` overload accepting the object; 3-arg convenience overload retained; old positional full overload removed +- [x] `CancellationToken` remains the last parameter on every overload +- [x] Fluent builder surface (`AAuthClientBuilder`, `ChallengeHandlingOptions`) unchanged +- [x] Internal call sites updated (`CallChainingHandler`, `ChallengeHandler`) +- [x] Unit tests cover the object-based overload (capabilities/prompt/upstream/deferred paths) +- [x] Dedicated docs/samples subagent dispatched; `workflows/call-chaining.md` + any `ExchangeAsync` snippets updated +- [x] Subagent validation passed + +### Fix 7.4 — M3: Correct `DeferredPoller` timeout comment + +**Files**: `src/AAuth/Agent/DeferredPoller.cs` (~L119-122 inline comment, class `` ~L58-65). + +Reword the inline comment so the `InfiniteTimeSpan` configuration reads as a *requirement/assumption* of the supplied `HttpClient`, not a guaranteed fact. Add the requirement to the class-level `` beside the existing "must be signed" note. No behavioral change; no per-request CTS. + +#### Definition of Done + +- [x] Inline comment reworded as a requirement scoped to the builder-created client +- [x] Class `` documents the infinite-timeout expectation for external callers +- [x] No behavioral/code change beyond comments + XML docs +- [x] Dedicated docs/samples subagent dispatched; any `DeferredPoller` usage docs updated +- [x] Subagent validation passed + +### Fix 7.5 — L1–L5: Low-severity cleanup + +**Files**: as listed per item. + +- **L1** `ChallengeHandler.cs` ~L255-257: replace the read-modify-write of `_learnedComponents` with `AddOrUpdate` using a merge function for strict accumulation under concurrent 401s. +- **L2** `SignatureError.cs` ~L111-135 (`ParseRequiredInput`): replace naive `IndexOf("required_input")` with a `;`-split / word-boundary parse robust against tokens like `x-required_input`. +- **L3** `AAuthTokenExchangeException.cs` ~L52-53: add a clarifying comment that `interaction_required` is non-terminal (202) and unreachable on this `!IsSuccessStatusCode` path; keep behavior. +- **L4** `samples/LiveWhoAmITest/Program.cs` ~L78-93: wrap tunnel + Kestrel lifecycle in `try/finally`; dispose `tunnelProcess`; dispose per-mode `HttpResponseMessage`s. +- **L5** `.devcontainer/post-create.sh` ~L36: restore the cosmetic blank line removed adjacent to the cloudflared block. + +#### Definition of Done + +- [x] L1 `AddOrUpdate` accumulation; unit test for concurrent merge (or documented as benign) +- [x] L2 robust `required_input` parse; unit test with a decoy token +- [x] L3 clarifying comment added +- [x] L4 sample lifecycle in `try/finally`; disposables disposed +- [x] L5 cosmetic diff reverted +- [x] Dedicated docs/samples subagent dispatched; any affected docs/snippets updated +- [x] Subagent validation passed + +### Phase 7 Definition of Done + +- [x] Fixes 7.1–7.5 implemented and individually validated +- [x] Unit tests added for H2, M1/M2, H1, L1, L2 +- [x] **A dedicated docs/samples subagent was dispatched per fix** and every affected doc, sample README, GuidedTour `CodeSnippets.cs` snippet, and SampleApp reference updated to the new surface +- [x] GuidedTour + SampleApp snippets still compile +- [x] LiveWhoAmITest still passes all modes (manual live run) +- [x] No regressions (full unit + conformance suite) +- [x] Repo builds clean (0 warnings / 0 errors) +- [x] Each fix's subagent validation incorporated +- [x] research.md §9 statuses updated (Open → Fixed) + +--- + +## Out of Scope + +| Item | Reason | +|---|---| +| ECDSA / P-256 key support for live test | whoami only accepts Ed25519 | +| hwk / jkt-jwt signing modes against live servers | whoami only accepts jwt scheme | +| Multi-resource chaining | No second live resource available | +| AP enrollment against live AP | Separate initiative (`2026-05-27-ap-enrollment-key-naming`) | +| Browser-based interaction handling | Library design; consumer responsibility | +| `provider_hint` support | Hellospecific extension; doesn't generalize | diff --git a/.agent/plans/2026-05-28-live-interop-testing/research.md b/.agent/plans/2026-05-28-live-interop-testing/research.md new file mode 100644 index 0000000..b91e513 --- /dev/null +++ b/.agent/plans/2026-05-28-live-interop-testing/research.md @@ -0,0 +1,427 @@ +# Live Interop Testing: Research Document + +> Research-only. No implementation steps. +> Created 2026-05-28 as part of plan `2026-05-28-live-interop-testing`. +> Companion to [`implementation-plan.md`](./implementation-plan.md). + +--- + +## 1. Live Servers Under Test + +### 1.1 whoami.aauth.dev (Resource Server) + +| Property | Value | +|---|---| +| Stack | Cloudflare Workers + Hono (TypeScript) | +| Key algorithm | Ed25519 | +| Signature-Key scheme | Only `jwt` accepted | +| Metadata | `https://whoami.aauth.dev/.well-known/aauth-access.json` | +| Source | [aauth-dev/whoami](https://github.com/nickhardware/whoami-aauth-dev) (inferred) | +| Behaviour on unsigned GET | 401 + `Accept-Signature` header | +| Behaviour on agent-token (scoped) | 401 + `AAuth-Requirement: requirement=auth-token, resource_token=` | +| Behaviour on auth-token | 200 + identity claims JSON | +| Differences from local WhoAmI sample | No multi-path routing; no scope shortcut for unscoped; returns `Accept-Signature` | + +### 1.2 person.hello.coop (Person Server) + +| Property | Value | +|---|---| +| Metadata URL | `https://person.hello.coop/.well-known/aauth-person.json` | +| Token endpoint | `https://person.hello.coop/aauth/token` | +| Interaction endpoint | `https://person.hello.coop/auth` | +| JWKS URI | `https://issuer.hello.coop/.well-known/jwks.json` | +| Token exchange POST body (expected) | `{ resource_token, capabilities?, prompt?, provider_hint? }` | +| Deferred response | 202 + `Location` + `AAuth-Requirement: requirement=interaction, ...` | +| Error when no capabilities | `400 user_unreachable` with `"Agent has no interaction capability and user has no registered mobile devices"` | + +### 1.3 web-agent.aauth.dev (Reference Agent) + +| Property | Value | +|---|---| +| Role | Combined AP + agent server | +| Source | [nickhardware/web-agent-demo](https://github.com/nickhardware/web-agent-demo) (TypeScript) | +| Token exchange body | `{ resource_token, capabilities: ["interaction"], prompt: "consent", provider_hint: "email--" }` | +| Key | sends `capabilities: ["interaction"]` which is why PS returns 202 instead of 400 | + +--- + +## 2. Protocol Flow Observations + +### 2.1 Three Protocol Modes (as demonstrated) + +``` +Mode 1: GET (unsigned) + → 401 + Accept-Signature: sig=("@method" "@authority" "@path" "signature-key");keyid="...";tag="aauth" + +Mode 2a: GET + HTTP Signature + Signature-Key (agent_token, no scope) + → 200 + { sub, ps } — agent identity echoed back, no PS involvement + +Mode 2b: GET + HTTP Signature + Signature-Key (agent_token, with ?scope=email) + → 401 + AAuth-Requirement: requirement=auth-token, resource_token= + (Resource verified agent token via JWKS discovery at agent's issuer URL) + +Mode 3: Full three-party exchange + → Agent takes resource_token to PS token_endpoint (POST, signed) + → PS returns 200 + { auth_token } (cached consent) OR + → PS returns 202 + interaction requirement (first-time consent needed) + → Agent polls Location URL while user approves + → PS returns 200 + { auth_token } + → Agent re-sends request with auth_token → 200 + claims +``` + +### 2.2 PS Token Exchange Body Requirements + +The PS at `person.hello.coop` requires the following in the POST body: + +| Field | Required | Description | +|---|---|---| +| `resource_token` | Yes | The compact JWT from the resource's `AAuth-Requirement` header | +| `capabilities` | Conditional | Array of strings; must include `"interaction"` if agent can handle redirects. Without it, PS returns `400 user_unreachable` if user has no push device | +| `prompt` | No | Hint to PS (e.g. `"consent"`) | +| `provider_hint` | No | Login provider hint (e.g. `"email--"`) | +| `upstream_token` | No | For call-chaining scenarios | + +### 2.3 Agent JWKS Discovery + +The resource server (whoami) discovers the agent's public key via: + +1. Parses agent token JWT → extracts `iss` claim +2. Fetches `{iss}/.well-known/aauth-agent.json` +3. Reads `jwks_uri` from metadata +4. Fetches JWKS and finds key by `kid` +5. Verifies agent token signature + +This requires the agent's issuer URL to be publicly reachable (hence cloudflared tunnel). + +--- + +## 3. Gap Analysis (Spec References) + +Each gap references the relevant AAuth spec section from `draft-hardt-oauth-aauth-protocol`. + +### Gap A: Capabilities declaration at PS token endpoint + +| Aspect | Detail | +|---|---| +| **Symptom** | PS returns `400 user_unreachable` | +| **Current fix** | Send `capabilities: ["interaction"]` in POST body when `onInteractionRequired` is non-null | +| **Spec §** | §AAuth-Capabilities Request Header (`#aauth-capabilities`, line 1756) | +| **Spec says** | "Agents SHOULD include the `AAuth-Capabilities` header on signed requests to **resources**. **The header is not used on requests to PS endpoints** — the PS learns the agent's capabilities through the mission approval flow." | +| **Spec token endpoint params §** | §Agent Token Request (`#ps-token-endpoint`, line 830): Lists `resource_token`, `upstream_token`, `justification`, `login_hint`, `tenant`, `domain_hint`, `platform`, `device`. Does NOT list `capabilities`. | +| **Spec error code** | §Token Endpoint Error Codes (line 2016): `interaction_required` (403) — "User interaction is needed but no interaction channel is available — the PS cannot reach the user and the agent does not have the `interaction` capability" | +| **PS actual behavior** | Returns `400 user_unreachable` (not in spec error table) when `capabilities` not in body | +| **Conclusion** | `capabilities` in the token exchange POST body is a **Hellō PS-specific extension**, not in the AAuth spec. The spec says capabilities are communicated via the `AAuth-Capabilities` HTTP header on requests to resources, and via mission approval for PSes. However, Hellō PS requires it in the body. | +| **TODO** | 1) Should SDK send `AAuth-Capabilities` header on POST to PS token_endpoint as well? 2) Or include `capabilities` in body as PS-specific? 3) Should be configurable? | + +### Gap B: `prompt` / `provider_hint` in token exchange + +| Aspect | Detail | +|---|---| +| **Symptom** | web-agent-demo sends these; our SDK doesn't | +| **Spec §** | §Agent Token Request (line 830) | +| **Spec says** | Token endpoint parameters are: `resource_token`, `upstream_token`, `justification`, `login_hint`, `tenant`, `domain_hint`, `platform`, `device`. No `prompt` or `provider_hint`. | +| **Conclusion** | `prompt` and `provider_hint` are **NOT in the AAuth spec**. They are Hellō PS-specific extensions. The spec provides `login_hint` for a similar purpose (hint about who to authorize, per OpenID Core §3.1.2.1). | +| **TODO** | 1) Support `login_hint` (spec-standard). 2) Support arbitrary extra body fields via extensibility hook for PS-specific params? | + +### Gap C: `Accept-Signature` header parsing + +| Aspect | Detail | +|---|---| +| **Symptom** | whoami returns `Accept-Signature` on unsigned 401; SDK ignores it | +| **Spec §** | §Covered Components (`#covered-components`, line 2087); §Incremental Adoption (line 2300); §Resource Metadata `additional_signature_components` (line 2281) | +| **Spec says** | Base covered components are `@method`, `@authority`, `@path`, `signature-key`. Resources MAY require additional via `additional_signature_components` in metadata. `Accept-Signature` is referenced as what resources return (per `I-D.hardt-httpbis-signature-key`) but the spec doesn't define agent behavior on receiving it. | +| **Accept-Signature definition** | Defined in the Signature-Key spec (`I-D.hardt-httpbis-signature-key`), not in the main AAuth protocol spec. It tells the agent what components to sign. | +| **Conclusion** | Agent SHOULD parse `Accept-Signature` to discover required components. The SDK's current defaults (`@method`, `@authority`, `@path`, `signature-key`) match what whoami expects, but a resource advertising different requirements would fail. | +| **TODO** | 1) Parse `Accept-Signature` on first 401 and adapt. 2) Also read `additional_signature_components` from resource metadata. 3) Priority: medium (works today against known resources). | + +### Gap D: Interaction URL presentation + +| Aspect | Detail | +|---|---| +| **Symptom** | SDK surfaces URL via callback; no built-in opener | +| **Spec §** | §User Interaction (line 893) | +| **Spec says** | "The agent constructs the user-facing URL as `{url}?code={code}` and directs the user using one of the methods defined in (#requirement-responses) (browser redirect, QR code, or display code)." Also: agent MAY append `callback` parameter. | +| **Conclusion** | Spec is intentionally vague about HOW the agent presents the URL. Library-level callback is correct. A convenience helper could auto-open browser for CLI scenarios. | +| **TODO** | Low priority. Consider a `DefaultInteractionPresenter` that calls `Process.Start` for desktop/CLI apps. | + +### Gap E: PS error classification + +| Aspect | Detail | +|---|---| +| **Symptom** | All PS errors thrown as `HttpRequestException`; `user_unreachable` info lost | +| **Spec §** | §Token Endpoint Error Response Format (`#error-response-format`, line 2000); §Token Endpoint Error Codes (line 2006); §Polling Error Codes (line 2024) | +| **Spec error codes** | `invalid_request` (400), `invalid_agent_token` (400), `expired_agent_token` (400), `invalid_resource_token` (400), `expired_resource_token` (400), `interaction_required` (403), `server_error` (500) | +| **Polling error codes** | `denied` (403), `abandoned` (403), `expired` (408), `invalid_code` (410), `slow_down` (429), `server_error` (500) | +| **PS actual** | Returns `user_unreachable` (not in spec) | +| **Conclusion** | SDK should parse JSON `error` + `error_description` and throw typed exceptions. Spec defines error codes that should be distinguishable. Hellō PS uses non-standard codes too. | +| **TODO** | 1) Parse JSON body on non-2xx before throwing. 2) `AAuthTokenExchangeException` with `ErrorCode` property. 3) Allow unknown error codes (PS-specific). | + +### Gap F: Polling timeout configuration + +| Aspect | Detail | +|---|---| +| **Symptom** | Default `MaxTotalWait=5min`; `PreferWaitSeconds=45` per the spec example | +| **Spec §** | §Deferred Responses (`#deferred-responses`, line 1906); spec example (line 857) shows `Prefer: wait=45` | +| **Spec says** | "The agent MUST respect `Retry-After` values. If a `Retry-After` header is not present, the default polling interval is 5 seconds." Spec does NOT define a max total polling budget. | +| **Spec polling errors** | `expired` (408) — "Timed out". The server decides when to expire. | +| **Conclusion** | The server controls timeout (via 408 `expired`). Client-side max budget is a safety net, not spec-mandated. 5 minutes is reasonable. The `Prefer: wait=45` is directly from the spec example. | +| **TODO** | Validated. Current defaults are fine. No change needed unless testing reveals issues. | + +### Gap G: HttpClient.Timeout vs long-poll + +| Aspect | Detail | +|---|---| +| **Symptom** | `TaskCanceledException` after 100s (HttpClient default) during a `Prefer: wait=45` long-poll | +| **Current fix** | Per-request CTS with `PreferWait+60s`; catch `TaskCanceledException` in `TokenExchangeClient` | +| **Spec §** | §Deferred Responses (line 1906) — `Prefer: wait=N` is standard | +| **Conclusion** | .NET implementation detail. The `HttpClient.Timeout` default (100s) conflicts with long-poll semantics. Fix is correct in principle but approach may change. | +| **TODO** | 1) Set `Timeout=InfiniteTimeSpan` on the exchange-specific HttpClient in the builder. 2) Remove per-request CTS workaround once root cause fixed. | + +### Gap H: PS consent caching / `prompt` parameter + +| Aspect | Detail | +|---|---| +| **Symptom** | 2nd run got auth_token immediately (200) without consent | +| **Spec §** | §Resource Token (line 784): "The PS SHOULD remember prior consent decisions within a mission so the user is not re-prompted when the agent resubmits a request for the same resource and scope." | +| **Spec says** | Consent caching is expected PS behavior (within a mission). No standard way to force re-consent. | +| **Reference behavior** | web-agent-demo sends `prompt: "consent"` (not in spec) to force the consent screen | +| **Conclusion** | Consent caching is per-spec. Forcing re-consent (`prompt: "consent"`) is a PS-specific extension. | +| **TODO** | Low priority. If needed, expose via the extensibility hook for PS-specific body fields (same as Gap B). | + +### Gap I: `content-type` in covered components for POST + +| Aspect | Detail | +|---|---| +| **Symptom** | SDK signs `@method`, `@authority`, `@path`, `signature-key` for all requests. web-agent-demo adds `content-type` for POST. | +| **Spec §** | §Covered Components (`#covered-components`, line 2087) | +| **Spec says** | "The signature MUST cover: `@method`, `@authority`, `@path`, `signature-key`." These are the REQUIRED components. `content-type` is NOT required. Resources MAY require additional via `additional_signature_components` metadata. | +| **Spec also says** | "Servers MAY require additional covered components (e.g., `content-digest` for request body integrity)." | +| **Conclusion** | SDK is spec-compliant. `content-type` is optional. web-agent-demo includes it as defense-in-depth but it's not required. | +| **TODO** | Low priority. Could auto-include `content-type` for body-bearing requests as best practice, but not a spec violation to omit. | + +### Gap J: Exchange client HttpClient.Timeout + +| Aspect | Detail | +|---|---| +| **Symptom** | Same root cause as Gap G — the exchange HttpClient has a 100s default timeout | +| **Spec §** | N/A — implementation detail | +| **Conclusion** | The builder should configure `Timeout = Timeout.InfiniteTimeSpan` on the signed HttpClient used for token exchange + polling, since those flows can legitimately take minutes. | +| **TODO** | Fix in builder. Related to Gap G — once this is fixed, the per-request CTS workaround in DeferredPoller can be simplified. | + +--- + +## 4. Infrastructure Notes + +### 4.1 cloudflared Quick Tunnel + +- Binary: `/usr/local/bin/cloudflared` v2026.5.2 +- No account required; generates random `*.trycloudflare.com` subdomain +- DNS propagation takes 3-15 seconds after tunnel starts +- Command: `cloudflared tunnel --url http://localhost:{port} --no-autoupdate` +- URL extracted from stderr via regex: `https://[a-z0-9\-]+\.trycloudflare\.com` + +### 4.2 Dev Container Constraints + +- No browser available inside container (headless) +- User must open interaction URLs on host machine +- `$BROWSER` variable available for host browser opening + +--- + +## 5. Open Questions (Resolved) + +| # | Question | Resolution | +|---|---|---| +| 1 | Are `prompt` and `provider_hint` standardized AAuth fields or Hellō-specific? | **`prompt` will be standard in -02** (OIDC values: `none`, `login`, `consent`, `select_account`). `provider_hint` stays Hellō-specific. | +| 2 | Does the PS accept capabilities beyond `"interaction"`? | **Spec defines:** `interaction`, `clarification`, `payment` (§AAuth-Capabilities, L1756). PS behavior unconfirmed for `clarification`/`payment`. | +| 3 | PS polling timeout - how long before expiring? | **Server-controlled.** Spec §Deferred Responses says server returns `expired` (408) when it decides. No client max mandated. | +| 4 | Does PS support `Prefer: wait=N` on initial POST or only polls? | **Both.** Spec example (L857) shows `Prefer: wait=45` on initial POST. Confirmed working with person.hello.coop. | +| 5 | What claims does whoami return for different scopes? | **Tested:** Returns `sub`, `iss` always. Scope-dependent claims not yet tested (whoami may not support scopes). | +| 6 | Is `capabilities` in POST body spec-standard or PS-specific? | **Will be standard in -02.** Spec lead confirmed body is the correct place. See §5b. | +| 7 | Is `user_unreachable` a valid error code? | **Yes, will be added in -02.** Distinct from `interaction_required`. See §5b. | + +## 5a. Resolved: `capabilities` Header vs Body + +> **Resolved 2026-05-30** - Spec lead confirmed our fix is correct. Will be standardized in -02. + +Original discrepancy: + +- **Spec -01** (§AAuth-Capabilities, L1776): "The header is not used on requests to PS endpoints - the PS learns the agent's capabilities through the mission approval flow." +- **Live PS** (person.hello.coop): Requires `capabilities: ["interaction"]` in the POST body. + +**Resolution from spec lead:** + +- `capabilities` belongs in the token request body, not headers. +- The `AAuth-Capabilities` header exclusion on PS endpoints (§12.1) stands - that header is for resource calls. +- Headers are only used where there's a conflict with a pre-existing API; for the PS token endpoint, body is correct. +- When a mission is active, the agent doesn't need to re-send `capabilities` (PS has them from approval flow) but MAY include them. +- **Spec -02 will list `capabilities` as a standard token endpoint parameter.** + +**SDK action:** Current fix is correct. Promote from "partially fixed" to "confirmed correct". + +## 5b. Spec Lead Response (2026-05-30) + +Full clarification from spec lead on our three questions: + +### `capabilities` in token endpoint body + +- Will be added to §7.1.3 Agent Token Request as a standard OPTIONAL parameter. +- Array of strings from the capabilities registry (`interaction`, `clarification`, `payment`). +- Mission-less agents: MUST send if they need interaction. Mission agents: MAY send (PS already knows from approval). + +### `user_unreachable` vs `interaction_required` + +These are two **distinct** conditions: + +| Error | Status | Type | Meaning | +|-------|--------|------|--------| +| `interaction_required` | 202 | Non-terminal | PS needs the agent to direct the user somewhere (URL + code). Polling continues. | +| `user_unreachable` | 400 | Terminal | PS has no channel to the user AND agent didn't declare `interaction`. Hard stop. | + +- `user_unreachable` will be added to the spec error table in -02. +- Hellourrent behavior is correct. + +### `prompt` parameter + +- Will be added to §7.1.3 in -02 with OIDC values: `none`, `login`, `consent`, `select_account`. +- Consistent with spec already reusing OIDC vocabulary for `login_hint`, `tenant`, `domain_hint`. +- `provider_hint` stays Hellospecific (steers between consumer providers, doesn't generalize). + +--- + +## 6. Web-Agent-Demo Parity Analysis + +> **Update (2026-05-28):** Confirmed all modes work end-to-end. Added Mode 2a. +> PS caches consent — second run got auth_token immediately (200) without interaction. + +### 6.1 Reference Agent Covered Components + +| Request type | Components signed | +|---|---| +| GET (all modes) | `@method`, `@authority`, `@path`, `signature-key` | +| POST with body | `@method`, `@authority`, `@path`, `content-type`, `signature-key` | + +**Gap I**: Our SDK always signs `@method`, `@authority`, `@path`, `signature-key` regardless of request type. The reference adds `content-type` for POST requests with body. + +### 6.2 Reference Agent PS Token Exchange Body + +```json +{ + "resource_token": "", + "capabilities": ["interaction"], + "prompt": "consent", + "provider_hint": "email--" +} +``` + +Our SDK sends: +```json +{ + "resource_token": "", + "capabilities": ["interaction"] +} +``` + +### 6.3 Reference Agent Long-Poll Configuration + +- `POLL_WAIT_SECONDS = 45` (sent as `Prefer: wait=45` on GET to pending URL) +- No explicit max total wait — loops indefinitely on 202 +- On network error: waits 5 seconds then retries + +### 6.4 Reference Agent Signing Modes + +| Flow | Signature scheme | +|---|---| +| Bootstrap POST | `sig=hwk` (bare key, no JWT) | +| Refresh POST | `sig=hwk` | +| Agent forget POST | `sig=hwk` | +| Whoami GET (agent_token) | `sig=jwt` (jwt=agent_token) | +| PS token exchange POST | `sig=jwt` (jwt=agent_token) | +| Poll GET | `sig=jwt` (jwt=agent_token) | +| Whoami GET (auth_token) | `sig=jwt` (jwt=auth_token) | + +### 6.5 Confirmed Behaviors + +- ✅ Unscoped GET with valid agent_token → 200 with `{ sub, ps }` +- ✅ Scoped GET → 401 + resource_token +- ✅ Capabilities: `["interaction"]` is required for the PS to return 202 instead of 400 +- ✅ PS caches consent — subsequent requests get 200 immediately (no interaction) +- ✅ Poll uses `Prefer: wait=45` — PS holds connection open (long-poll semantics) +- ✅ On 200 from PS, response body is `{ auth_token: "" }` + +--- + +## 7. Phase 5: Edge Case Validation (2026-05-30) + +Audited each edge case against the implemented SDK and existing tests. Most +were already covered; findings below. + +| # | Edge case | Status | Implementation / Test | +|---|---|---|---| +| 1 | Interaction timeout (user never approves) → timeout | ✅ Covered | `DeferredPoller.PollAsync` throws `TimeoutException` on `MaxTotalWait`; `TokenExchangeClient` wraps as `AAuthInteractionTimeoutException`. Tests: `DeferredPollerTests`, `InteractionHandlerTests.TimesOut_WhenPollKeepsReturning202`. | +| 2 | User explicitly denies → terminal | ✅ Covered | `TokenExchangeClient` maps `403 + access_denied` to `AAuthInteractionDeniedException`. Tests: `MockPersonServerConsentTests`, `WhoAmIFlowTests` deny path. | +| 3 | `user_unreachable` (400, terminal) | ✅ Fixed | Added explicit `TokenErrorCode.UserUnreachable` (wire `user_unreachable`); `AAuthTokenExchangeException.IsTerminalCode` returns terminal. Tests: `TokenErrorTests.UserUnreachable_IsTerminal`, `ChallengeHandlerTests.Exchange_NonSuccessWithErrorBody_ThrowsTyped` inline case. | +| 4 | Expired / revoked agent keys | ✅ Covered | `SignatureErrorCode.ExpiredJwt`; expiry validated in middleware (clock-skew aware). Tests: `TokenVerifierTests.Verify_RejectsExpiredToken`, `AAuthVerifierTests.Verify_RejectsExpiredCreated`. | +| 5 | Mismatched `kid` (unknown_key) | ✅ Covered | `JwksClient.ResolveKeyAsync` returns null for unknown kid (rate-limited refresh); resolver raises `unknown_key`. Tests: `JwksClientTests` unknown-kid + refresh cases. | +| 6 | Different scope values | ⚪ Out of scope | Scope is a resource-token / response concern (`ResourceTokenBuilder.Scope`, `AuthTokenResponseValidator`), not an agent-supplied token-endpoint param. Spec §7.1.3 token-endpoint params do not include `scope`; not added speculatively. Live scope behavior remains a manual LiveWhoAmITest concern. | + +**New gap register entries:** none. Item 3 was already in scope under Gap E (upcoming-changes-02 item 2); it is now explicit rather than incidental. Item 6 confirmed out of scope. + +## 8. Phase 6: Documentation Validation (2026-05-30) + +Dispatched 6 parallel Explore subagents covering all Markdown, GuidedTour `CodeSnippets.cs`, SampleApp code, and `upcoming-changes-02.md`. Each finding was re-verified against source before applying. Stylistic `using`-omission complaints were rejected (consistent docs convention). + +| File | Inaccuracy | Fix | +|---|---|---| +| `docs/README.md` | `TokenError`/`PollingError` type names wrong | `TokenErrorResponse`/`PollingErrorException`; added `AAuthTokenExchangeException` row | +| `docs/reference/configuration.md` | `MetadataCacheDuration`/`JwksCacheDuration` wrong; JWKS default wrong; ChallengeHandlingOptions table incomplete | Renamed to `*CacheTtl`, added `JwksMinRefreshInterval`, JWKS default 1h, completed options table | +| `docs/reference/dependency-injection.md` | Same `*CacheDuration` names + default | Renamed to `*CacheTtl`, JWKS default 1h | +| `docs/server/verification-middleware.md` | Error-code table incomplete; `expired` not a wire code | Listed all 8 `SignatureErrorCode` wire codes incl. `expired_jwt`, `invalid_input` | +| `docs/server/token-issuance.md` | Used `WWW-Authenticate: AAuth resource_token=...` | Use `context.ChallengeAAuth(resourceToken)` (sets `AAuth-Requirement`) | +| `docs/workflows/call-chaining.md` | `ExchangeAsync` skipped required `onInteractionRequired` positional param | Added `onInteractionRequired: null` | +| `samples/GuidedTour/CodeSnippets.cs` | `SelfSignAgentToken` missing `required` `KeyId` | Added `KeyId = "sample-key-1"` | + +Confirmed accurate (no change): root `README.md`, `concepts.md`, `getting-started.md`, `docs/advanced/*`, remaining `docs/server/*` (incl. `resource-metadata.md` correctly omitting `additional_signature_components`), all `docs/signing-modes/*`, remaining `docs/workflows/*`, all sample READMEs, SampleApp code, and `upcoming-changes-02.md` (all 3 items implemented). + +Validation: full solution builds clean (0 warnings/errors); 320 unit + 342 conformance tests pass. + +## 9. PR #27 Review Findings (2026-05-31) + +Two independent review passes against PR #27 (branch `feat/live-interop-testing`): (a) the GitHub automated reviewer (`copilot-pull-request-reviewer`, 4 inline comments) and (b) an internal PR Review subagent run spec-first then SDK. The internal pass corroborated all 4 external comments and surfaced additional findings. Each item below was verified against the spec (`aauth-spec/draft-hardt-oauth-aauth-protocol.md` §Covered Components L2098, §Verification L2111, `additional_signature_components` L2266-2281) and the SDK source. + +### 9.1 Consolidated Findings Table + +| ID | Severity | File / Location | Issue | Source | Status | +|---|---|---|---|---|---| +| H1 | High | `src/AAuth/Agent/TokenExchangeClient.cs` ~L88-96 | `ExchangeAsync` full overload inserts optional `capabilities`/`prompt` before `CancellationToken` — source + binary break; positional `cancellationToken` callers now bind to `capabilities` (compile error). All internal call sites already switched to named `cancellationToken:`. | External #3 + internal | Fixed (7.3) | +| H2 | High | `src/AAuth/HttpSig/AAuthSigningHandler.cs` ~L256-271 (`ResolveAdditionalComponents`) | Adaptive learn-and-retry throws `InvalidOperationException` when a required component has no header. SDK never computes `Content-Digest`, yet that is the spec's canonical additional component (L2098, L2266). Retry throws instead of satisfying a resource demanding `content-digest` on a body request. | Internal only (new) | Fixed (7.1) | +| M1 | Medium | `src/AAuth/Agent/ChallengeHandler.cs` ~L303 (`SeedAdditionalComponents`) | Unconditional `request.Options.Set(AdditionalComponentsKey, components)` clobbers any caller-set per-request components. `MergeComponents` (~L311) exists but is not consulted here. | External #2 + internal | Fixed (7.2) | +| M2 | Medium | `src/AAuth/Agent/ChallengeHandler.cs` ~L334 (`CloneAsync`) | Clone intentionally omits `HttpRequestMessage.Options`. Mitigated for AAuth's own key (re-applied on both retry paths) but loses any other request-scoped option (Polly context, telemetry, caller-set key). | External #1 + internal | Fixed (7.2) | +| M3 | Medium | `src/AAuth/Agent/DeferredPoller.cs` ~L119-122 | Inline comment asserts the HttpClient is always `Timeout.InfiniteTimeSpan`, but the class is `public` and constructible with any `HttpClient`. `MaxTotalWait` only gates between polls, so a default client (100s) with `PreferWaitSeconds > ~100` aborts an in-flight long-poll with an uncaught `TaskCanceledException`. | External #4 + internal | Fixed (7.4) | +| L1 | Low | `src/AAuth/Agent/ChallengeHandler.cs` ~L255-257 | `_learnedComponents` read-modify-write not atomic; concurrent 401s for one origin race (last-writer-wins). Benign — both produce supersets. Could use `AddOrUpdate`. | Internal only | Fixed (7.5) | +| L2 | Low | `src/AAuth/Errors/SignatureError.cs` ~L111-135 (`ParseRequiredInput`) | Naive `IndexOf("required_input")` could match a token like `x-required_input`. Fine for own output; word-boundary/`;`-split parse more robust vs third-party servers. | Internal only | Fixed (7.5) | +| L3 | Nit | `src/AAuth/Errors/AAuthTokenExchangeException.cs` ~L52-53 | `IsTerminalCode` marks `interaction_required` terminal; per `upcoming-changes-02.md` it is 202/non-terminal. Currently unreachable (only runs on `!IsSuccessStatusCode`; 202 is success). Worth a comment. | Internal only | Fixed (7.5) | +| L4 | Nit | `samples/LiveWhoAmITest/Program.cs` ~L78-93 | `tunnelProcess` (`IDisposable`) never disposed; `Kill()`/`StopAsync()` not in `finally` — leaks tunnel + Kestrel if an exception precedes cleanup. Per-mode `HttpResponseMessage`s not disposed. Demo-only. | Internal only | Fixed (7.5) | +| L5 | Nit | `.devcontainer/post-create.sh` ~L36 | Stray blank-line removal adjacent to cloudflared block; cosmetic. cloudflared install correctly uses `signed-by` keyring and is idempotent. | Internal only | Fixed (7.5) | + +### 9.2 External Comment Accuracy Verdicts + +All four GitHub-reviewer comments were accurate (no false positives), though two were narrower or softer than stated: + +- **#1 (CloneAsync drops Options):** Valid but narrow. High-level paths (metadata `AdditionalSignatureComponents` dict + runtime `_learnedComponents` cache) survive because the cloned request is re-seeded; only a caller-set low-level `AdditionalComponentsKey` not also in dict/cache is genuinely lost. = M2. +- **#2 (SeedAdditionalComponents overwrites):** Valid. Same root cause as #1 — the public per-request option is not treated as an additive input. = M1. +- **#3 (ExchangeAsync ordering):** Valid, but the comment's "now binds to `capabilities`" implies a silent rebind; it is a *compile-time* break (`CancellationToken` not convertible to `IReadOnlyList?`), not silent. Internal pass adds the binary-compat angle. = H1. +- **#4 (DeferredPoller comment):** Valid, doc-only at minimum; internal pass shows a real abort risk for external callers using a default client. = M3. + +### 9.3 Confirmed Correct (both passes) + +`capabilities`/`prompt` sent in the token request *body* (matches `upcoming-changes-02.md` L17-31; `AAuth-Capabilities` header stays resource-only); `"interaction"` capability inference from a wired callback (overridable); `user_unreachable` distinct terminal code (400/terminal per spec delta); `AdditionalComponentsKey` signing logic (RFC 9421 §2.1 ordering, base-component de-dup, `", "` multi-value join); issuer formatting (scheme+host, lowercased, no trailing slash, §Identifiers); non-JSON error-body fallback to `HttpRequestException`; no injected secrets. + +### 9.4 Recommended Remediation Order + +1. **H2** — spec-correctness gap (new): implement `Content-Digest` (RFC 9530) when required, or downgrade the hard throw to an actionable error naming the unmet component + origin and document the caller-pre-populate contract. +2. **M1 + M2** — one cohesive fix: treat per-request `AdditionalComponentsKey` as an additive merge input on both seed and clone (reuse `MergeComponents`); copy or deliberately reset `Options` on the clone with an accurate comment. +3. **M3** — reword the comment to a requirement and/or enforce a per-request linked CTS. +4. **H1** — add a back-compat overload or a `TokenExchangeRequest` options object (decision gated on whether the alpha SDK offers source-compat guarantees). +5. **L1-L5** — cleanup pass. + diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 03b44a8..cef77b1 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -33,7 +33,35 @@ else echo "==> gh already installed: $(gh --version | head -n1)" fi +# --- cloudflared -------------------------------------------------------------- +# Installs `cloudflared` from Cloudflare's official apt repository. +# Required by the LiveWhoAmITest sample, which exposes its local agent metadata +# endpoint over a quick tunnel so the live resource server can fetch its JWKS. +# Docs: https://pkg.cloudflare.com/ +if ! command -v cloudflared >/dev/null 2>&1; then + echo "==> Installing cloudflared" + + sudo_cmd="" + if [[ $EUID -ne 0 ]]; then + sudo_cmd="sudo" + fi + + $sudo_cmd install -d -m 0755 /etc/apt/keyrings + curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ + | $sudo_cmd tee /etc/apt/keyrings/cloudflare-main.gpg >/dev/null + $sudo_cmd chmod go+r /etc/apt/keyrings/cloudflare-main.gpg + + echo "deb [signed-by=/etc/apt/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \ + | $sudo_cmd tee /etc/apt/sources.list.d/cloudflared.list >/dev/null + + $sudo_cmd apt-get update + $sudo_cmd apt-get install -y cloudflared +else + echo "==> cloudflared already installed: $(cloudflared --version | head -n1)" +fi + # --- Bash: git completion + git status in prompt ------------------------------ + # Idempotent: only appended once, guarded by a marker line. BASHRC="${HOME}/.bashrc" MARKER="# >>> aauth devcontainer bash setup >>>" diff --git a/AAuth.slnx b/AAuth.slnx index 358d76f..aeb3738 100644 --- a/AAuth.slnx +++ b/AAuth.slnx @@ -2,6 +2,7 @@ + diff --git a/Makefile b/Makefile index 0a3cd17..ab0eabd 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ TOUR_PROJECT := samples/GuidedTour/GuidedTour.csproj AGENT_PROJECT := samples/AgentConsole/AgentConsole.csproj SAMPLE_PROJECT := samples/SampleApp/SampleApp.csproj ORCH_PROJECT := samples/Orchestrator/Orchestrator.csproj +LIVE_PROJECT := samples/LiveWhoAmITest/LiveWhoAmITest.csproj WHOAMI_URL := http://localhost:5000 PS_URL := http://localhost:5100 @@ -25,7 +26,7 @@ SAMPLE_URL := http://localhost:5240 .PHONY: help build restore test test-unit test-conformance \ whoami ps ap tour agent demo \ - clean format + live clean format help: ## List available targets @awk 'BEGIN { FS = ":.*##"; printf "Targets:\n" } \ @@ -71,6 +72,9 @@ orchestrator: ## Run the Orchestrator service (port 5200) agent: ## Run AgentConsole against WhoAmI (override URL=… for a different target) $(DOTNET) run --project $(AGENT_PROJECT) -- $(or $(URL),$(WHOAMI_URL)) +live: ## Run LiveWhoAmITest against whoami.aauth.dev (needs cloudflared + network) + $(DOTNET) run --project $(LIVE_PROJECT) + demo: ## Start WhoAmI + Orchestrator + MockPersonServer + MockAgentProvider + GuidedTour in parallel @echo "Starting five-party demo (all flows including call-chain)..." @echo " WhoAmI: $(WHOAMI_URL)" diff --git a/aauth-spec/upcoming-changes-02.md b/aauth-spec/upcoming-changes-02.md new file mode 100644 index 0000000..07fc34a --- /dev/null +++ b/aauth-spec/upcoming-changes-02.md @@ -0,0 +1,68 @@ +# Upcoming Changes in draft-hardt-oauth-aauth-protocol-02 + +Confirmed by spec lead (2026-05-30). Tracked here until the -02 draft is published. + +## Index + +| # | Change | Extends | Status | +|---|--------|---------|--------| +| 1 | Add `capabilities` as standard token endpoint parameter | §7.1.3 Agent Token Request (L830) | Pending -02 | +| 2 | Add `user_unreachable` to error table as terminal error | §Error Responses (L2006) | Pending -02 | +| 3 | Add `prompt` as standard token endpoint parameter | §7.1.3 Agent Token Request (L830) | Pending -02 | + +--- + +## 1. `capabilities` as token endpoint body parameter + +**Extends:** §7.1.3 Agent Token Request (`#ps-token-endpoint`, line 830) + +**Current spec:** Token endpoint params are `resource_token`, `upstream_token`, `justification`, `login_hint`, `tenant`, `domain_hint`, `platform`, `device`. The `AAuth-Capabilities` header (§AAuth-Capabilities, L1756) is explicitly excluded from PS endpoints. + +**Change:** Add `capabilities` (OPTIONAL) to the token endpoint request body. Array of strings. Values from the AAuth Capabilities registry (`interaction`, `clarification`, `payment`). + +**Clarification:** +- `capabilities` in the body is the correct mechanism for mission-less agents. +- When a mission is active, the PS already has capabilities from the approval flow - the agent doesn't need to resend them but MAY include them if they've changed. +- The `AAuth-Capabilities` header remains resource-only. No conflict - headers are used where there's a pre-existing API; body is the right place for the PS token endpoint. + +**SDK impact:** Current fix (sending `capabilities` in POST body) is correct and will be spec-standard. + +--- + +## 2. `user_unreachable` as terminal error + +**Extends:** §Token Endpoint Error Codes (`#error-response-format`, line 2006) + +**Current spec:** Error table has `interaction_required` (403) defined as "User interaction is needed but no interaction channel is available." + +**Change:** Add `user_unreachable` as a distinct terminal error. Clarify the difference: + +| Error | Status | Type | Meaning | +|-------|--------|------|---------| +| `interaction_required` | 202 | Non-terminal | PS needs the agent to direct the user somewhere (URL + code). Polling continues. | +| `user_unreachable` | 400 | Terminal | PS has no channel to the user AND the agent didn't declare `interaction` capability. No way to reach the user. | + +**Clarification:** These are two distinct conditions, not aliases. `interaction_required` comes with a deferred response (202) and an interaction URL. `user_unreachable` is a hard stop - nothing can be done without the agent declaring capabilities. + +**SDK impact:** Error classification (Gap E) should treat `user_unreachable` as a terminal, non-retryable error distinct from `interaction_required`. + +--- + +## 3. `prompt` as token endpoint body parameter + +**Extends:** §7.1.3 Agent Token Request (`#ps-token-endpoint`, line 830) + +**Current spec:** No `prompt` parameter listed. + +**Change:** Add `prompt` (OPTIONAL) to the token endpoint request body. Values follow OIDC (per OpenID Core §3.1.2.1): + +| Value | Meaning | +|-------|---------| +| `none` | No UI. Return error if consent/login is needed. | +| `login` | Force re-authentication. | +| `consent` | Force consent screen even if prior consent exists. | +| `select_account` | Prompt user to select an account. | + +**Not included:** `provider_hint` remains a Hellospecific extension. It steers between consumer providers (email, Google, etc.) and doesn't generalize. + +**SDK impact:** Should support `prompt` as a first-class option on the token exchange builder. `provider_hint` can go through the extensibility hook for PS-specific params. diff --git a/docs/README.md b/docs/README.md index 43961b5..e9fc612 100644 --- a/docs/README.md +++ b/docs/README.md @@ -172,8 +172,9 @@ This is the documentation for the AAuth .NET SDK (`AAuth` NuGet package). It cov | Type | Purpose | |------|---------| | `SignatureError` / `SignatureErrorCode` | Signature verification failures | -| `TokenError` / `TokenErrorCode` | Token validation failures | -| `PollingError` / `PollingErrorCode` | Deferred polling failures | +| `TokenErrorResponse` / `TokenErrorCode` | Token validation failures | +| `AAuthTokenExchangeException` | Structured PS token-endpoint errors | +| `PollingErrorException` / `PollingErrorCode` | Deferred polling failures | ### `AAuth.Identifiers` — AAuth URI parsing diff --git a/docs/advanced/error-handling.md b/docs/advanced/error-handling.md index b7442a3..a1af6db 100644 --- a/docs/advanced/error-handling.md +++ b/docs/advanced/error-handling.md @@ -18,7 +18,7 @@ namespace AAuth.Errors; public enum SignatureErrorCode { InvalidRequest, // Missing required headers (Signature, Signature-Input, Signature-Key) - InvalidInput, // Malformed Signature-Input structured field + InvalidInput, // Covered components don't match the required set (see required_input) InvalidSignature, // Signature bytes don't verify against key UnsupportedAlgorithm, // Algorithm not supported by this resource InvalidKey, // Key material is malformed or unsupported @@ -48,8 +48,26 @@ if (SignatureError.TryParse(response.Headers["Signature-Error"], out var code)) { Console.WriteLine($"Signature rejected: {code}"); } + +// Extract the components a resource demands on an invalid_input error +string[] required = SignatureError.ParseRequiredInput( + response.Headers["Signature-Error"]); +// → ["content-digest"] (empty array when no required_input is present) ``` +### Adaptive Retry on `invalid_input` + +When challenge handling is enabled, the agent handles `invalid_input` with a +`required_input` list automatically: it learns the additional covered +components, re-signs the request covering them, and retries once. Learned +components are cached per origin. If `content-digest` is among the required +components, the signing handler computes it (RFC 9530, `sha-256`) from the +request body before re-signing, so the retry succeeds without caller +intervention. See +[Adaptive Signature Components](../signing-modes/overview.md#adaptive-signature-components) +for how to seed components proactively from resource metadata. `ParseRequiredInput` +is exposed for callers implementing this handshake manually. + ## Token Errors (PS/AS → Agent) When a Person Server or Access Server rejects a token exchange request. @@ -66,8 +84,9 @@ public enum TokenErrorCode ExpiredAgentToken, // Agent token exp has passed InvalidResourceToken, // Resource token fails validation ExpiredResourceToken, // Resource token exp has passed - InteractionRequired, // User must approve (deferred consent) - ServerError, // Internal server error + InteractionRequired, // User must approve (deferred consent, non-terminal 202) + UserUnreachable, // No channel to the user; agent declared no interaction capability (terminal 400) + ServerError, // Internal server error (transient, retryable) } ``` @@ -80,10 +99,48 @@ public sealed record TokenErrorResponse(TokenErrorCode Error, string? ErrorDescr } ``` -The `TokenExchangeClient` throws when it receives an error response from the PS. Check the HTTP status code and parse the body: +The `TokenExchangeClient` throws when it receives an error response from the PS. + +When the PS returns a non-success status with a structured AAuth error body +(`{ "error": ..., "error_description": ... }`), the exchange throws a typed +`AAuthTokenExchangeException` carrying the parsed fields. Responses that are not +parseable AAuth error objects fall back to a plain `HttpRequestException`. + +```csharp +public sealed class AAuthTokenExchangeException : Exception +{ + public string ErrorCode { get; } // e.g. "invalid_resource_token" + public string? ErrorDescription { get; } // optional human-readable text + public int StatusCode { get; } // HTTP status from the token endpoint + public bool IsTerminal { get; } // false only for "server_error" (retryable) +} +``` + +```csharp +try +{ + var authToken = await exchangeClient.ExchangeAsync(personServer, resourceToken); +} +catch (AAuthTokenExchangeException ex) +{ + Console.WriteLine($"Token exchange failed: {ex.ErrorCode} (HTTP {ex.StatusCode})"); + if (!ex.IsTerminal) + { + // Transient (server_error) — a later retry may succeed. + } +} +catch (HttpRequestException ex) +{ + // Transport failure, or a non-success response without a parseable + // AAuth error body. + Console.WriteLine($"Transport error: {ex.Message}"); +} +``` + +If you're calling the PS manually, parse the body yourself with +`TokenErrorResponse`: ```csharp -// If you're calling the PS manually: var response = await signedClient.PostAsync(psTokenEndpoint, content); if (!response.IsSuccessStatusCode) { @@ -178,9 +235,10 @@ catch (TokenVerificationException ex) |-----------|----------|---------| | `AAuthVerificationException` | `AAuthVerifier` | Signature bytes invalid | | `TokenVerificationException` | `TokenVerifier` | JWT fails validation | +| `AAuthTokenExchangeException` | `TokenExchangeClient` / `ChallengeHandler` | PS token endpoint returned a structured error | | `AAuthInteractionDeniedException` | `DeferredPoller` / `ChallengeHandler` | User denied | | `AAuthInteractionTimeoutException` | `DeferredPoller` / `ChallengeHandler` | Polling timed out | -| `PollingErrorException` | `DeferredPoller` | PS returned terminal error | +| `PollingErrorException` | `DeferredPoller` | PS returned terminal error during polling | ## Server-Side Error Emission diff --git a/docs/advanced/interaction-chaining.md b/docs/advanced/interaction-chaining.md index 2bfc498..1d3fb36 100644 --- a/docs/advanced/interaction-chaining.md +++ b/docs/advanced/interaction-chaining.md @@ -98,6 +98,8 @@ app.MapGet("/", async (HttpContext ctx) => }); ``` +> **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`. + ## Pending Request Management The intermediary must manage pending requests: diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 95f4c15..3effcf7 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -208,8 +208,9 @@ Standard `DelegatingHandler` — no configurable options. Requires an `ISignatur | Property | Type | Default | Description | |----------|------|---------|-------------| -| `MetadataCacheDuration` | `TimeSpan` | 5 minutes | Metadata document cache lifetime | -| `JwksCacheDuration` | `TimeSpan` | 5 minutes | JWKS cache lifetime | +| `MetadataCacheTtl` | `TimeSpan` | 5 minutes | Metadata document cache lifetime | +| `JwksCacheTtl` | `TimeSpan` | 1 hour | JWKS cache lifetime | +| `JwksMinRefreshInterval` | `TimeSpan` | 1 minute | Minimum interval between JWKS fetches (rate limit) | ### ChallengeHandlingOptions (WithChallengeHandling) @@ -218,6 +219,12 @@ Standard `DelegatingHandler` — no configurable options. Requires an `ISignatur | `OnInteractionRequired` | `Func?` | null | Deferred consent callback | | `PollingTimeout` | `TimeSpan` | 5 minutes | Max deferred polling time | | `DefaultPollInterval` | `TimeSpan` | 5 seconds | Poll interval (overridden by Retry-After) | +| `PreferWaitSeconds` | `int?` | null | Sends `Prefer: wait=N` to long-poll | +| `MinPollInterval` | `TimeSpan` | 100 ms | Minimum delay between polls | +| `OnPoll` | `Action?` | null | Per-poll callback (logging/progress) | +| `Capabilities` | `IList?` | null | Capabilities sent to the PS (null = infer) | +| `Prompt` | `string?` | null | OIDC `prompt` sent to the PS | +| `AdditionalSignatureComponents` | `IReadOnlyDictionary>?` | null | Per-origin extra covered components to seed | ### InteractionHandlingOptions (WithInteractionHandling) diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index 20a61a2..f8310e8 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -195,8 +195,8 @@ Register shared `MetadataClient` and `JwksClient` singletons with custom cache s ```csharp builder.Services.AddAAuthDiscovery(options => { - options.MetadataCacheDuration = TimeSpan.FromMinutes(10); - options.JwksCacheDuration = TimeSpan.FromHours(2); + options.MetadataCacheTtl = TimeSpan.FromMinutes(10); + options.JwksCacheTtl = TimeSpan.FromHours(2); }); ``` @@ -320,8 +320,8 @@ app.Run(); | Property | Type | Default | Description | |----------|------|---------|-------------| -| `MetadataCacheDuration` | `TimeSpan` | 5 min | How long to cache well-known metadata | -| `JwksCacheDuration` | `TimeSpan` | 5 min | How long to cache JWKS documents | +| `MetadataCacheTtl` | `TimeSpan` | 5 min | How long to cache well-known metadata | +| `JwksCacheTtl` | `TimeSpan` | 1 hour | How long to cache JWKS documents | ## Call Chaining (AAuthClientBuilder) diff --git a/docs/server/token-issuance.md b/docs/server/token-issuance.md index 9bccf03..13e7108 100644 --- a/docs/server/token-issuance.md +++ b/docs/server/token-issuance.md @@ -25,9 +25,9 @@ var resourceToken = new ResourceTokenBuilder Lifetime = TimeSpan.FromMinutes(5), // default: 5 min }.Build(); -// Return as 401 challenge -context.Response.Headers["WWW-Authenticate"] = $"AAuth resource_token={resourceToken}"; -return Results.Unauthorized(); +// Return as 401 challenge (sets the AAuth-Requirement header: +// requirement=auth-token; resource-token="...") +return context.ChallengeAAuth(resourceToken); ``` ### ResourceTokenBuilder Properties diff --git a/docs/server/verification-middleware.md b/docs/server/verification-middleware.md index 2f7b965..719e1b4 100644 --- a/docs/server/verification-middleware.md +++ b/docs/server/verification-middleware.md @@ -131,9 +131,13 @@ On verification failure, the middleware returns `401 Unauthorized` with a `Signa | Error Code | Meaning | |------------|---------| | `invalid_request` | Missing required signature headers | +| `invalid_input` | Covered components don't match the required set (see the `required_input` parameter) | | `invalid_signature` | Signature verification failed | +| `unsupported_algorithm` | Signature algorithm not supported | +| `invalid_key` | Signature key malformed or unusable | +| `unknown_key` | Referenced key could not be resolved | | `invalid_jwt` | JWT parsing/issuer verification failed | -| `expired` | Token or signature timestamp expired | +| `expired_jwt` | Token JWT expired | ## OpenTelemetry Integration diff --git a/docs/signing-modes/overview.md b/docs/signing-modes/overview.md index 32d2221..d6e06fd 100644 --- a/docs/signing-modes/overview.md +++ b/docs/signing-modes/overview.md @@ -99,6 +99,52 @@ Signature: sig=:base64url-ed25519-signature: The `AAuthSigningHandler` handles construction automatically. +## Adaptive Signature Components + +Every signed request always covers the four base AAuth components shown above +(`@method`, `@authority`, `@path`, `signature-key`), plus `authorization` when +that header is present. A resource MAY require **additional** covered components +(for example `content-digest` for request-body integrity, or `content-type`). +The agent discovers these in one of two ways: + +1. **From resource metadata.** If you know a resource publishes + `additional_signature_components`, seed them so the very first request + already covers them: + + ```csharp + using var client = new AAuthClientBuilder(key) + .WithTokenRefresh(refresher) + .WithChallengeHandling(ps, options => + { + options.AdditionalSignatureComponents = + new Dictionary> + { + ["https://resource.example"] = new[] { "content-digest" }, + }; + }) + .Build(); + ``` + + The dictionary is keyed by origin (`scheme://host:port`). + +2. **From a `401` response.** When a resource rejects a request with + `Signature-Error: invalid_input; required_input="content-digest"`, the + challenge handler learns the required components, re-signs the request + covering them, and retries **once**. Learned components are cached per + origin, so subsequent requests to the same resource cover them up front. + +Additional components are always **additive** — the base components can never +be dropped or reordered. The component value is taken from the request's own +headers at signing time. When a resource requires `content-digest` (RFC 9530) +on a body-bearing request, the signing handler **computes and attaches it +automatically** (`sha-256`) before signing, so callers do not need to set it +themselves. Any required component AAuth cannot derive on its own must be +present on the request; if such a component is absent, signing fails fast with +an `InvalidOperationException` that names the resource origin. + +See [Error Handling](../advanced/error-handling.md) for the +`Signature-Error` codes and `SignatureError.ParseRequiredInput`. + ## Further Reading - [Signing Mode Comparison](https://explorer.aauth.dev/signing/compare) diff --git a/docs/workflows/call-chaining.md b/docs/workflows/call-chaining.md index 2543aa3..e6a9cd2 100644 --- a/docs/workflows/call-chaining.md +++ b/docs/workflows/call-chaining.md @@ -141,7 +141,7 @@ app.MapGet("/", async (HttpContext ctx) => var exchange = new TokenExchangeClient(signedClient, metadata); var chained = await exchange.ExchangeAsync( personServer, resourceToken, - upstreamToken: upstreamAuthToken); + new TokenExchangeRequest { UpstreamToken = upstreamAuthToken }); // 3. Call downstream with the chained auth token using var downstream = new AAuthClientBuilder(myKey) @@ -177,9 +177,7 @@ var exchange = new TokenExchangeClient(signedClient, metadata); var downstreamToken = await exchange.ExchangeAsync( personServer: "https://ps.example", resourceToken: resourceToken, - onInteractionRequired: null, - pollerOptions: null, - upstreamToken: incomingAuthToken); // preserves delegation chain + new TokenExchangeRequest { UpstreamToken = incomingAuthToken }); // preserves delegation chain ``` The SDK includes the upstream token as `upstream_token` in the POST body to the PS token endpoint. diff --git a/docs/workflows/deferred-consent.md b/docs/workflows/deferred-consent.md index e0c030a..d30c975 100644 --- a/docs/workflows/deferred-consent.md +++ b/docs/workflows/deferred-consent.md @@ -38,18 +38,21 @@ try var authToken = await exchange.ExchangeAsync( "https://ps.example", resourceToken, - onInteractionRequired: async (interaction, ct) => + new TokenExchangeRequest { - // Present to user — open browser, show notification, etc. - Console.WriteLine($"Approve at: {interaction.Url}"); - Console.WriteLine($"Code: {interaction.Code}"); - }, - pollerOptions: new DeferredPollerOptions - { - MaxTotalWait = TimeSpan.FromMinutes(5), - DefaultPollInterval = TimeSpan.FromSeconds(2), - // Long-poll: server holds connection open up to 30s (RFC 7240) - PreferWaitSeconds = 30, + OnInteractionRequired = async (interaction, ct) => + { + // Present to user - open browser, show notification, etc. + Console.WriteLine($"Approve at: {interaction.Url}"); + Console.WriteLine($"Code: {interaction.Code}"); + }, + PollerOptions = new DeferredPollerOptions + { + MaxTotalWait = TimeSpan.FromMinutes(5), + DefaultPollInterval = TimeSpan.FromSeconds(2), + // Long-poll: server holds connection open up to 30s (RFC 7240) + PreferWaitSeconds = 30, + }, }); } catch (AAuthInteractionDeniedException) @@ -62,6 +65,8 @@ catch (AAuthInteractionTimeoutException) } ``` +> **Note:** When you set `PreferWaitSeconds` (long-poll) on a directly constructed `TokenExchangeClient`/`DeferredPoller`, the `HttpClient` you supply must allow a per-request timeout longer than the wait value. Set `HttpClient.Timeout` greater than `PreferWaitSeconds` (or to `Timeout.InfiniteTimeSpan`); otherwise the default 100s timeout can abort an in-flight long-poll with a `TaskCanceledException`. Clients built via `AAuthClientBuilder` already use `Timeout.InfiniteTimeSpan`. + ## Automatic with AAuthClientBuilder ```csharp diff --git a/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs index 5c66dca..9ce261b 100644 --- a/samples/GuidedTour/CodeSnippets.cs +++ b/samples/GuidedTour/CodeSnippets.cs @@ -17,6 +17,7 @@ internal static class CodeSnippets { Issuer = "https://ap.example", Subject = "aauth:myapp@ap.example", + KeyId = "sample-key-1", Key = key, PersonServer = "https://ps.example", }.Build(); diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs index 5576e51..165ca0f 100644 --- a/samples/GuidedTour/TourSession.cs +++ b/samples/GuidedTour/TourSession.cs @@ -1120,6 +1120,11 @@ private async Task StepPollPendingAsync(CancellationToken ct) string? capturedBase = null; var signing = BuildSigningHandler( () => _agentToken!, capture, (_, b) => capturedBase = b); + // This HttpClient is constructed directly (not via AAuthClientBuilder), so it + // keeps the default 100s timeout. That is fine here because PreferWaitSeconds (30) + // is well under 100s. If you raise PreferWaitSeconds beyond the HttpClient.Timeout, + // set Timeout greater than PreferWaitSeconds (or Timeout.InfiniteTimeSpan) or the + // in-flight long-poll aborts with a TaskCanceledException. using var client = new HttpClient(signing); var pollerOptions = new DeferredPollerOptions { diff --git a/samples/LiveWhoAmITest/LiveWhoAmITest.csproj b/samples/LiveWhoAmITest/LiveWhoAmITest.csproj new file mode 100644 index 0000000..6970d98 --- /dev/null +++ b/samples/LiveWhoAmITest/LiveWhoAmITest.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/samples/LiveWhoAmITest/Program.cs b/samples/LiveWhoAmITest/Program.cs new file mode 100644 index 0000000..137ecc7 --- /dev/null +++ b/samples/LiveWhoAmITest/Program.cs @@ -0,0 +1,362 @@ +// Live test: hit whoami.aauth.dev demonstrating all protocol modes. +// Parity with the reference agent at https://github.com/aauth-dev/web-agent-demo +// +// Mode 1: No signature → 401 + Accept-Signature header +// 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 3: Full 3-party flow → 200 + identity claims (via PS exchange) +// +// Architecture: +// - Local Kestrel server on port 5199 serving agent metadata + JWKS +// - cloudflared quick tunnel exposes it publicly +// - Uses the live Person Server at https://person.hello.coop +// - Our SDK's SelfIssuing builder + WithChallengeHandling drives the 3-party flow +// +// The live PS has a user-consent interaction — the agent will print the +// interaction URL for you to approve in your browser. +// +// Usage: dotnet run --project samples/LiveWhoAmITest + +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using AAuth.Crypto; +using AAuth.Errors; +using AAuth.HttpSig; +using AAuth.Server; + +const string WhoAmIUrl = "https://whoami.aauth.dev/"; +const string PersonServer = "https://person.hello.coop"; +const string Subject = "aauth:live-test@dotnet-samples"; +const int LocalPort = 5199; + +Console.WriteLine("╔══════════════════════════════════════════════════════════════╗"); +Console.WriteLine("║ Live WhoAmI Test — All 3 Protocol Modes ║"); +Console.WriteLine("║ Resource: whoami.aauth.dev ║"); +Console.WriteLine("║ Person Server: person.hello.coop ║"); +Console.WriteLine("╚══════════════════════════════════════════════════════════════╝"); +Console.WriteLine(); + +// ── 1. Generate agent key ─────────────────────────────────────────────────── +var agentKey = AAuthKey.Generate(); +var agentKid = agentKey.ComputeJwkThumbprint(); +Console.WriteLine($"Generated agent key (kid: {agentKid[..12]}...)"); +Console.WriteLine(); + +// ── 2. Start local agent metadata server ──────────────────────────────────── +var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args }); +builder.WebHost.UseUrls($"http://localhost:{LocalPort}"); +builder.Logging.SetMinimumLevel(LogLevel.Warning); +var app = builder.Build(); + +string? tunnelUrl = null; + +// Agent well-known metadata (whoami fetches {iss}/.well-known/aauth-agent.json) +app.MapGet("/.well-known/aauth-agent.json", () => Results.Json(new JsonObject +{ + ["issuer"] = tunnelUrl, + ["jwks_uri"] = $"{tunnelUrl}/.well-known/jwks.json", + ["client_name"] = "AAuth .NET SDK Live Test", +}, contentType: "application/json")); + +app.MapGet("/.well-known/jwks.json", () => +{ + var jwk = agentKey.ToPublicJwk(); + jwk["kid"] = agentKid; + jwk["key_ops"] = new JsonArray("verify"); + return Results.Json(new JsonObject { ["keys"] = new JsonArray(jwk) }, contentType: "application/json"); +}); + +app.MapGet("/health", () => Results.Ok("ok")); + +await app.StartAsync(); +Console.WriteLine($"Local agent metadata server on http://localhost:{LocalPort}"); + +// ── 3. Start cloudflared tunnel ───────────────────────────────────────────── +Console.WriteLine("Starting cloudflared quick tunnel..."); + +var tunnelProcess = new Process +{ + StartInfo = new ProcessStartInfo + { + FileName = "cloudflared", + Arguments = $"tunnel --url http://localhost:{LocalPort} --no-autoupdate", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }, +}; +tunnelProcess.Start(); + +// Everything past this point runs inside try/finally so the tunnel process and +// the local Kestrel host are always torn down, even if a mode throws. +try +{ + +tunnelUrl = await WaitForTunnelUrl(tunnelProcess); +if (tunnelUrl is null) +{ + Console.Error.WriteLine("ERROR: Failed to get tunnel URL from cloudflared."); + return 1; +} + +Console.WriteLine($"Tunnel URL (agent issuer): {tunnelUrl}"); + +// Wait for tunnel readiness +Console.WriteLine("Waiting for tunnel to become reachable..."); +using var verifyClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; +for (int attempt = 1; attempt <= 15; attempt++) +{ + try + { + var healthResp = await verifyClient.GetAsync($"{tunnelUrl}/health"); + if (healthResp.IsSuccessStatusCode) + { + Console.WriteLine($"Tunnel ready (attempt {attempt})"); + break; + } + Console.WriteLine($" Attempt {attempt}: HTTP {(int)healthResp.StatusCode}"); + } + catch (Exception ex) + { + Console.WriteLine($" Attempt {attempt}: {ex.GetType().Name}"); + } + await Task.Delay(3000); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MODE 1: No signature — raw GET, expect 401 + Accept-Signature +// ═══════════════════════════════════════════════════════════════════════════════ +Console.WriteLine(); +Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +Console.WriteLine("MODE 1: No signature → 401 + Accept-Signature"); +Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +Console.WriteLine(); + +using var rawClient = new HttpClient(); +var rawResp = await rawClient.GetAsync(WhoAmIUrl); + +Console.WriteLine($" Status: {(int)rawResp.StatusCode} {rawResp.ReasonPhrase}"); +if (rawResp.Headers.TryGetValues("Accept-Signature", out var acceptSigValues)) + Console.WriteLine($" Accept-Signature: {string.Join(", ", acceptSigValues)}"); +var rawBody = await rawResp.Content.ReadAsStringAsync(); +Console.WriteLine($" Body: {rawBody}"); +Console.WriteLine(); +Console.WriteLine(" → Resource tells the agent: sign with these components, use JWT key scheme."); + +// ═══════════════════════════════════════════════════════════════════════════════ +// MODE 2a: aa-agent+jwt (no scope) — agent identity returned directly +// ═══════════════════════════════════════════════════════════════════════════════ +Console.WriteLine(); +Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +Console.WriteLine("MODE 2a: aa-agent+jwt (no scope) → 200 + agent identity"); +Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +Console.WriteLine(); + +// Build a client without challenge handling — unscoped requests get 200 directly +using var mode2aClient = AAuthClientBuilder.SelfIssuing(agentKey) + .As(tunnelUrl!, Subject) + .WithKid(agentKid) + .WithPersonServer(PersonServer) + .Build(); + +var mode2aResp = await mode2aClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, WhoAmIUrl)); + +Console.WriteLine($" Status: {(int)mode2aResp.StatusCode} {mode2aResp.ReasonPhrase}"); +var mode2aBody = await mode2aResp.Content.ReadAsStringAsync(); +if (mode2aResp.IsSuccessStatusCode) +{ + Console.WriteLine($" Body: {mode2aBody}"); + Console.WriteLine(); + Console.WriteLine(" → Resource verified agent token via JWKS, returned agent's self-asserted sub."); + Console.WriteLine(" No PS involvement needed — whoami echoes the agent identity for unscoped requests."); +} +else +{ + Console.WriteLine($" Body: {mode2aBody}"); + Console.WriteLine(); + Console.WriteLine($" ⚠ Expected 200 but got {(int)mode2aResp.StatusCode}."); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MODE 2b: aa-agent+jwt (with scope) — agent introduces itself, gets resource_token +// ═══════════════════════════════════════════════════════════════════════════════ +Console.WriteLine(); +Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +Console.WriteLine("MODE 2b: aa-agent+jwt (scope=email) → 401 + AAuth-Requirement"); +Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +Console.WriteLine(); + +// Build a client WITHOUT challenge handling so we see the raw 401 + resource_token +using var mode2bClient = AAuthClientBuilder.SelfIssuing(agentKey) + .As(tunnelUrl!, Subject) + .WithKid(agentKid) + .WithPersonServer(PersonServer) + .Build(); + +var mode2bResp = await mode2bClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{WhoAmIUrl}?scope=email")); + +Console.WriteLine($" Status: {(int)mode2bResp.StatusCode} {mode2bResp.ReasonPhrase}"); +if (mode2bResp.Headers.TryGetValues("AAuth-Requirement", out var reqValues)) +{ + var reqHeader = string.Join(", ", reqValues); + Console.WriteLine($" AAuth-Requirement: {(reqHeader.Length > 100 ? reqHeader[..100] + "..." : reqHeader)}"); +} +var mode2bBody = await mode2bResp.Content.ReadAsStringAsync(); +Console.WriteLine($" Body: {mode2bBody}"); +Console.WriteLine(); +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."); + +// ═══════════════════════════════════════════════════════════════════════════════ +// MODE 3: Full 3-party flow — WithChallengeHandling does it automatically +// ═══════════════════════════════════════════════════════════════════════════════ +Console.WriteLine(); +Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +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(" Using live PS at person.hello.coop (may require user consent)"); +Console.WriteLine(); + +using var mode3Client = AAuthClientBuilder.SelfIssuing(agentKey) + .As(tunnelUrl!, Subject) + .WithKid(agentKid) + .WithPersonServer(PersonServer) + .WithChallengeHandling(opts => + { + opts.PreferWaitSeconds = 45; + opts.MinPollInterval = TimeSpan.FromSeconds(2); + opts.OnInteractionRequired = (interaction, ct) => + { + Console.WriteLine(); + Console.WriteLine(" ┌─────────────────────────────────────────────────────────┐"); + Console.WriteLine(" │ USER ACTION REQUIRED │"); + Console.WriteLine(" │ Open this URL in your browser to approve: │"); + Console.WriteLine($" │ {interaction.BuildUserUrl()}"); + Console.WriteLine(" └─────────────────────────────────────────────────────────┘"); + Console.WriteLine(); + + // Also try to open in browser + try { Process.Start(new ProcessStartInfo(interaction.BuildUserUrl()) { UseShellExecute = true }); } + catch { /* not critical */ } + + return Task.CompletedTask; + }; + opts.OnPoll = response => + { + Console.WriteLine($" [poll] {(int)response.StatusCode} {response.ReasonPhrase}"); + }; + }) + .Build(); + +HttpResponseMessage? mode3Resp = null; +try +{ + mode3Resp = await mode3Client.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{WhoAmIUrl}?scope=email")); +} +catch (AAuthTokenExchangeException ex) +{ + Console.WriteLine(); + Console.WriteLine($" Token exchange error: {ex.ErrorCode} (HTTP {ex.StatusCode}, terminal={ex.IsTerminal})"); + if (!string.IsNullOrEmpty(ex.ErrorDescription)) + { + Console.WriteLine($" {ex.ErrorDescription}"); + } + Console.WriteLine(); + Console.WriteLine(" This is expected if:"); + Console.WriteLine(" - The user has no Hellō account / registered devices"); + Console.WriteLine(" - The agent has no callback_endpoint for interaction"); + Console.WriteLine(" The PS couldn't reach the user to obtain consent."); + Console.WriteLine(); + Console.WriteLine(" To complete Mode 3, you need a Hellō account linked to"); + 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 (HttpRequestException ex) +{ + Console.WriteLine(); + Console.WriteLine($" Token exchange error: {ex.Message}"); + Console.WriteLine(); + Console.WriteLine(" This is expected if:"); + Console.WriteLine(" - The user has no Hellō account / registered devices"); + Console.WriteLine(" - The agent has no callback_endpoint for interaction"); + Console.WriteLine(" The PS couldn't reach the user to obtain consent."); + Console.WriteLine(); + Console.WriteLine(" To complete Mode 3, you need a Hellō account linked to"); + 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."); +} + +if (mode3Resp is not null) +{ + Console.WriteLine(); + Console.WriteLine($" Status: {(int)mode3Resp.StatusCode} {mode3Resp.ReasonPhrase}"); + var mode3Body = await mode3Resp.Content.ReadAsStringAsync(); + if (mode3Resp.IsSuccessStatusCode) + { + Console.WriteLine(" Identity claims returned by whoami.aauth.dev:"); + try + { + var formatted = JsonSerializer.Serialize( + JsonSerializer.Deserialize(mode3Body), + new JsonSerializerOptions { WriteIndented = true }); + foreach (var line in formatted.Split('\n')) + Console.WriteLine($" {line}"); + } + catch { Console.WriteLine($" {mode3Body}"); } + Console.WriteLine(); + Console.WriteLine(" ✓ Full three-party flow complete!"); + Console.WriteLine(" Agent → whoami (agent_token) → PS (resource_token) → auth_token → whoami → claims"); + } + else + { + Console.WriteLine($" Body: {mode3Body}"); + Console.WriteLine(); + Console.WriteLine(" Response headers:"); + foreach (var h in mode3Resp.Headers) + Console.WriteLine($" {h.Key}: {string.Join(", ", h.Value)}"); + } +} + +// ── Cleanup ───────────────────────────────────────────────────────────────── +Console.WriteLine(); +Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +Console.WriteLine("Done. Shutting down..."); +return 0; + +} +finally +{ + try { if (!tunnelProcess.HasExited) { tunnelProcess.Kill(); } } + catch { /* process may already be gone */ } + tunnelProcess.Dispose(); + await app.StopAsync(); +} + +// ── Helpers ───────────────────────────────────────────────────────────────── +static async Task WaitForTunnelUrl(Process process) +{ + var regex = new Regex(@"https://[a-z0-9\-]+\.trycloudflare\.com", RegexOptions.IgnoreCase); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + try + { + while (!cts.Token.IsCancellationRequested) + { + var line = await process.StandardError.ReadLineAsync(cts.Token); + if (line is null) break; + + var match = regex.Match(line); + if (match.Success) + return match.Value; + } + } + catch (OperationCanceledException) { } + + return null; +} diff --git a/samples/README.md b/samples/README.md index 669de7f..d20cd5b 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,6 +1,6 @@ # Samples -Seven sample applications demonstrating AAuth flows end-to-end. +Eight sample applications demonstrating AAuth flows end-to-end. | Sample | Port | Description | |--------|------|-------------| @@ -11,6 +11,7 @@ Seven sample applications demonstrating AAuth flows end-to-end. | [GuidedTour](GuidedTour/) | 5400 | Blazor walk-through — visualises all four AAuth flows step by step | | [SampleApp](SampleApp/) | 5240 | Golden example — one page per signing mode (hwk, jwt, jwks_uri, call chain) | | [AgentConsole](AgentConsole/) | — | CLI agent — signs requests, handles challenges, exchanges with a PS | +| [LiveWhoAmITest](LiveWhoAmITest/) | 5199 | Live interop test against `whoami.aauth.dev` + `person.hello.coop` — exercises all 3 protocol modes over a public tunnel | ## Quick Start @@ -128,6 +129,20 @@ dotnet run --project samples/SampleApp Simple Blazor app showing each signing mode as a separate page. Open . Requires WhoAmI, MockPersonServer, and Orchestrator running. MockAgentProvider is needed only for the JWKS-URI enrollment page. +### LiveWhoAmITest + +```bash +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 all three protocol modes: + +- **Mode 1** — unsigned request returns `401` + `Accept-Signature`. +- **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. + +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. + ## Make Targets ```bash @@ -145,5 +160,6 @@ make ap # MockAgentProvider (port 5301) make tour # GuidedTour (port 5400; expects other services running) make sampleapp # SampleApp (port 5240; expects other services running) make agent # AgentConsole against WhoAmI (override URL=…) +make live # LiveWhoAmITest against whoami.aauth.dev (needs cloudflared + network) make clean # dotnet clean + remove bin/ obj/ ``` diff --git a/src/AAuth/Agent/ChallengeHandler.cs b/src/AAuth/Agent/ChallengeHandler.cs index 16faef4..ce2e353 100644 --- a/src/AAuth/Agent/ChallengeHandler.cs +++ b/src/AAuth/Agent/ChallengeHandler.cs @@ -1,10 +1,14 @@ using System; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using AAuth.Errors; using AAuth.Headers; +using AAuth.HttpSig; using AAuth.Server; namespace AAuth.Agent; @@ -35,6 +39,15 @@ public sealed class ChallengeHandler : DelegatingHandler private readonly DeferredPollerOptions? _pollerOptions; private readonly Func? _upstreamTokenProvider; + // Per-origin cache of additional signature components a resource has been + // observed to require (learned from an `invalid_input` + `required_input` + // 401, or seeded from resource metadata via AdditionalSignatureComponents). + // Keyed by origin (scheme://host:port). Once learned, subsequent requests + // to that origin proactively include the components so they sign correctly + // on the first attempt. §Covered Components. + private readonly ConcurrentDictionary> _learnedComponents + = new(StringComparer.Ordinal); + /// Create the challenge handler. /// Token exchange client (configured with the agent token). /// Shared carrier-token holder used by the signer. @@ -93,11 +106,38 @@ public ChallengeHandler( _upstreamTokenProvider = upstreamTokenProvider; } + /// + /// Capabilities to declare to the PS during the embedded exchange. + /// When (default), capabilities are inferred from + /// the flow ("interaction" when an interaction callback is wired). + /// An explicit (possibly empty) list overrides inference. + /// + public IReadOnlyList? Capabilities { get; init; } + + /// + /// Optional OIDC prompt value sent to the PS during the embedded + /// exchange (e.g. "consent"). When (default), + /// no prompt is sent. + /// + public string? Prompt { get; init; } + + /// + /// Additional signature components a resource requires, keyed by origin + /// (scheme://host:port), typically discovered from the resource's + /// additional_signature_components metadata. When set, requests to + /// a matching origin proactively cover those components on the first + /// attempt. Components additionally learned at runtime from an + /// invalid_input error are merged on top of these. §Covered + /// Components. + /// + public IReadOnlyDictionary>? AdditionalSignatureComponents { get; init; } + /// protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { - var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + var response = await SendWithAdaptiveSigningAsync(request, cancellationToken) + .ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.Unauthorized) { @@ -152,8 +192,15 @@ protected override async Task SendAsync( var authToken = await _exchange .ExchangeAsync(targetServer, requirement.ResourceToken!, - _onInteractionRequired, _pollerOptions, - upstreamToken: upstreamToken, cancellationToken) + new TokenExchangeRequest + { + OnInteractionRequired = _onInteractionRequired, + PollerOptions = _pollerOptions, + UpstreamToken = upstreamToken, + Capabilities = Capabilities, + Prompt = Prompt, + }, + cancellationToken) .ConfigureAwait(false); _holder.Update(authToken); @@ -165,7 +212,7 @@ protected override async Task SendAsync( // here, which is a known limitation. response.Dispose(); var retry = await CloneAsync(request, cancellationToken).ConfigureAwait(false); - var result = await base.SendAsync(retry, cancellationToken).ConfigureAwait(false); + var result = await SendWithAdaptiveSigningAsync(retry, cancellationToken).ConfigureAwait(false); // Reassign the response's RequestMessage to the caller-owned // original so diagnostics (EnsureSuccessStatusCode, loggers) keep // working, then dispose the short-lived clone. This avoids both @@ -177,6 +224,133 @@ protected override async Task SendAsync( return result; } + // Send a request through the inner pipeline, transparently handling the + // adaptive-signing handshake: seed any known additional components into + // the request so the signer covers them, and on a `401` carrying + // `Signature-Error: invalid_input; required_input="..."`, learn the + // required components, re-sign, and retry exactly once. §Covered + // Components / §Verification step 2. + private async Task SendWithAdaptiveSigningAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + SeedAdditionalComponents(request); + + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode != HttpStatusCode.Unauthorized + || request.RequestUri is null + || !response.Headers.TryGetValues(SignatureError.HeaderName, out var errorValues)) + { + return response; + } + + string? rawError = null; + foreach (var value in errorValues) + { + if (!string.IsNullOrWhiteSpace(value)) { rawError = value; break; } + } + + if (rawError is null + || !SignatureError.TryParse(rawError, out var code) + || code != SignatureErrorCode.InvalidInput) + { + return response; + } + + var required = SignatureError.ParseRequiredInput(rawError); + if (required.Length == 0) + { + return response; + } + + // Learn the components for this origin (additive: base components and + // anything previously learned are preserved), then re-sign and retry + // exactly once. AddOrUpdate keeps concurrent 401s for the same origin + // from clobbering each other (each produces a superset). + var origin = GetOrigin(request.RequestUri); + var merged = _learnedComponents.AddOrUpdate( + origin, + _ => MergeComponents(origin, required), + (_, _) => MergeComponents(origin, required)); + + response.Dispose(); + var retry = await CloneAsync(request, cancellationToken).ConfigureAwait(false); + retry.Options.Set(AAuthSigningHandler.AdditionalComponentsKey, merged); + var result = await base.SendAsync(retry, cancellationToken).ConfigureAwait(false); + result.RequestMessage = request; + retry.Dispose(); + return result; + } + + // Seed the request with this origin's known additional components (learned + // at runtime or seeded from metadata) so the signer covers them. + private void SeedAdditionalComponents(HttpRequestMessage request) + { + if (request.RequestUri is null) + { + return; + } + + var origin = GetOrigin(request.RequestUri); + var hasLearnedOrSeeded = + _learnedComponents.ContainsKey(origin) + || AdditionalSignatureComponents?.ContainsKey(origin) == true; + if (!hasLearnedOrSeeded) + { + // Nothing to seed for this origin; leave any caller-set + // components on the request untouched. + return; + } + + // Merge metadata-seeded + learned components with any components the + // caller already set on the request (additive, order-preserving, + // de-duplicated) so a per-request AdditionalComponentsKey value is + // never clobbered. + request.Options.TryGetValue(AAuthSigningHandler.AdditionalComponentsKey, out var callerSet); + var merged = MergeComponents(origin, callerSet ?? Array.Empty()); + if (merged.Count > 0) + { + request.Options.Set(AAuthSigningHandler.AdditionalComponentsKey, merged); + } + } + + // Combine metadata-seeded components, previously learned components, and + // the newly required ones into a de-duplicated, order-preserving list. + private IReadOnlyList MergeComponents(string origin, IEnumerable required) + { + var ordered = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + void Add(IEnumerable? source) + { + if (source is null) { return; } + foreach (var item in source) + { + if (string.IsNullOrWhiteSpace(item)) { continue; } + var name = item.Trim(); + if (seen.Add(name)) { ordered.Add(name); } + } + } + + if (AdditionalSignatureComponents?.TryGetValue(origin, out var seeded) == true) + { + Add(seeded); + } + if (_learnedComponents.TryGetValue(origin, out var learned)) + { + Add(learned); + } + Add(required); + + return ordered; + } + + private static string GetOrigin(Uri uri) + => uri.GetComponents( + UriComponents.Scheme | UriComponents.Host | UriComponents.Port, + UriFormat.UriEscaped) + .ToLowerInvariant(); + private static async Task CloneAsync( HttpRequestMessage source, CancellationToken cancellationToken) { @@ -207,11 +381,16 @@ private static async Task CloneAsync( clone.Headers.TryAddWithoutValidation(header.Key, header.Value); } - // HttpRequestMessage.Options copying is intentionally omitted — - // AAuth headers and the retry semantics here do not depend on - // request options, and the HttpRequestOptions API on .NET 10 has - // no public bulk-copy helper. Revisit if a future phase plumbs - // request-scoped state through options. + // Carry request-scoped options onto the clone so caller state (a + // per-request AdditionalComponentsKey, telemetry/Polly context, etc.) + // survives the retry. AAuth-specific keys are re-applied downstream + // (SeedAdditionalComponents / the adaptive retry), but copying here + // preserves anything else the caller attached. HttpRequestOptions + // exposes IDictionary for writes. + foreach (var option in source.Options) + { + ((IDictionary)clone.Options)[option.Key] = option.Value; + } return clone; } diff --git a/src/AAuth/Agent/DeferredPoller.cs b/src/AAuth/Agent/DeferredPoller.cs index 2eec04d..48fda5e 100644 --- a/src/AAuth/Agent/DeferredPoller.cs +++ b/src/AAuth/Agent/DeferredPoller.cs @@ -62,6 +62,13 @@ public sealed record DeferredPollerOptions /// The supplied is expected to be configured /// with the agent's so each GET /// to the pending URL is signed — the PS will reject otherwise. +/// When is set, the +/// supplied must allow a per-request timeout longer +/// than the long-poll wait (set to a value +/// greater than PreferWaitSeconds, or to ). +/// Clients built by AAuthClientBuilder use +/// already; a directly constructed poller using a default client (100s timeout) +/// with a larger PreferWaitSeconds will abort the in-flight poll. /// public sealed class DeferredPoller { @@ -116,6 +123,12 @@ public async Task PollAsync( { request.Headers.TryAddWithoutValidation("Prefer", $"wait={waitSeconds}"); } + // Long-poll requests rely on the HttpClient allowing a per-request + // timeout longer than PreferWaitSeconds. Clients built by + // AAuthClientBuilder use Timeout.InfiniteTimeSpan; a directly + // constructed poller must configure HttpClient.Timeout accordingly + // (see class remarks). The overall budget is enforced by the + // MaxTotalWait stopwatch check above. var response = await _signedClient.SendAsync(request, cancellationToken).ConfigureAwait(false); try { diff --git a/src/AAuth/Agent/TokenExchangeClient.cs b/src/AAuth/Agent/TokenExchangeClient.cs index 515857d..6e2190d 100644 --- a/src/AAuth/Agent/TokenExchangeClient.cs +++ b/src/AAuth/Agent/TokenExchangeClient.cs @@ -49,41 +49,38 @@ public Task ExchangeAsync( string personServer, string resourceToken, CancellationToken cancellationToken = default) - => ExchangeAsync(personServer, resourceToken, onInteractionRequired: null, - pollerOptions: null, upstreamToken: null, cancellationToken); + => ExchangeAsync(personServer, resourceToken, new TokenExchangeRequest(), cancellationToken); /// /// Submit to the PS at /// and return the auth token, with /// support for the deferred / user-consent path (PS returns - /// 202 Accepted + AAuth-Requirement: requirement=interaction). + /// 202 Accepted + AAuth-Requirement: requirement=interaction), + /// call chaining, and capability/prompt declaration. /// /// PS issuer URL (used to fetch aauth-person.json). /// Compact aa-resource+jwt from the resource's challenge. - /// - /// Invoked when the PS returns 202 with an interaction requirement, - /// before polling begins. Callers display the user-facing URL/code via - /// and then return — - /// polling proceeds in parallel with the user's out-of-band action. If - /// and the PS returns 202, the call throws. - /// - /// Optional polling cadence/timeout override. - /// - /// Optional upstream auth token for call-chaining scenarios. When provided, - /// included as upstream_token in the POST body so the PS/AS can - /// construct nested act claims preserving the delegation chain. + /// + /// Optional exchange parameters (interaction callback, poller options, + /// upstream token, capabilities, prompt). Pass a default-constructed + /// instance for the plain exchange. /// /// Caller cancellation. public async Task ExchangeAsync( string personServer, string resourceToken, - Func? onInteractionRequired, - DeferredPollerOptions? pollerOptions = null, - string? upstreamToken = null, + TokenExchangeRequest options, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(personServer); ArgumentException.ThrowIfNullOrEmpty(resourceToken); + ArgumentNullException.ThrowIfNull(options); + + var onInteractionRequired = options.OnInteractionRequired; + var pollerOptions = options.PollerOptions; + var upstreamToken = options.UpstreamToken; + var capabilities = options.Capabilities; + var prompt = options.Prompt; using var activity = AAuthDiagnostics.Source.StartActivity("AAuth.TokenExchange"); @@ -120,6 +117,26 @@ public async Task ExchangeAsync( { body["upstream_token"] = upstreamToken; } + // Declare capabilities so the PS knows what the agent can do (e.g. + // handle a 202 + user-facing consent redirect). Spec §AAuth-Capabilities + // plus -02 token endpoint parameter. null = infer from flow; an explicit + // (possibly empty) list overrides. + var resolvedCapabilities = capabilities ?? InferCapabilities(onInteractionRequired); + if (resolvedCapabilities.Count > 0) + { + var caps = new JsonArray(); + foreach (var capability in resolvedCapabilities) + { + caps.Add(capability); + } + body["capabilities"] = caps; + } + // Optional OIDC prompt hint (e.g. "consent" to force a fresh consent + // screen). Spec -02 §7.1.3. Omitted when null. + if (!string.IsNullOrEmpty(prompt)) + { + body["prompt"] = prompt; + } using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpointUri) { Content = JsonContent.Create(body), @@ -190,6 +207,15 @@ public async Task ExchangeAsync( } } + // Default capability inference: declare "interaction" when the caller + // can handle a 202 + user-facing consent redirect. An explicit + // capabilities list passed to ExchangeAsync overrides this. + private static IReadOnlyList InferCapabilities( + Func? onInteractionRequired) + => onInteractionRequired is not null + ? new[] { "interaction" } + : Array.Empty(); + private static async Task IsAccessDeniedAsync( HttpResponseMessage response, CancellationToken cancellationToken) { @@ -259,6 +285,20 @@ private static async Task ReadAuthTokenAsync( var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { + // The token endpoint signals failure with a JSON body carrying a + // required 'error' code and optional 'error_description' + // (§Token Endpoint Error Response Format). Surface those as a + // typed exception so callers can branch on the code. Bodies that + // are not parseable AAuth error objects fall back to a plain + // HttpRequestException. + var errorCode = TryReadErrorCode(responseBody, out var errorDescription); + if (errorCode is not null) + { + throw new Errors.AAuthTokenExchangeException( + errorCode, errorDescription, (int)response.StatusCode, + Errors.AAuthTokenExchangeException.IsTerminalCode(errorCode)); + } + throw new HttpRequestException( $"Token exchange failed: {(int)response.StatusCode} {response.ReasonPhrase}\n{responseBody}"); } @@ -268,4 +308,31 @@ private static async Task ReadAuthTokenAsync( return (string?)json["auth_token"] ?? throw new InvalidOperationException("Token exchange response did not include 'auth_token'."); } + + // Parse a token-endpoint error body into its 'error' code (and optional + // 'error_description'). Returns null when the body is not a JSON object + // with a non-empty string 'error' member, signalling the caller to fall + // back to a generic transport exception. + private static string? TryReadErrorCode(string body, out string? errorDescription) + { + errorDescription = null; + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + JsonObject? json; + try { json = JsonNode.Parse(body) as JsonObject; } + catch (System.Text.Json.JsonException) { return null; } + if (json is null) + { + return null; + } + var error = (string?)json["error"]; + if (string.IsNullOrEmpty(error)) + { + return null; + } + errorDescription = (string?)json["error_description"]; + return error; + } } diff --git a/src/AAuth/Agent/TokenExchangeRequest.cs b/src/AAuth/Agent/TokenExchangeRequest.cs new file mode 100644 index 0000000..1eafadf --- /dev/null +++ b/src/AAuth/Agent/TokenExchangeRequest.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AAuth.Headers; + +namespace AAuth.Agent; + +/// +/// Optional parameters for . +/// Groups the deferred-consent, call-chaining, and capability/prompt options so +/// the public surface stays stable as new exchange parameters are added. +/// +public sealed class TokenExchangeRequest +{ + /// + /// Invoked when the PS returns 202 with an interaction requirement, + /// before polling begins. Callers display the user-facing URL/code via + /// and then return — + /// polling proceeds in parallel with the user's out-of-band action. If + /// and the PS returns 202, the call throws. + /// + public Func? OnInteractionRequired { get; init; } + + /// Optional polling cadence/timeout override for the deferred path. + public DeferredPollerOptions? PollerOptions { get; init; } + + /// + /// Optional upstream auth token for call-chaining scenarios. When provided, + /// included as upstream_token in the POST body so the PS/AS can + /// construct nested act claims preserving the delegation chain. + /// + public string? UpstreamToken { get; init; } + + /// + /// Capabilities to declare to the PS in the token request body. When + /// (default), capabilities are inferred from the + /// flow: "interaction" is sent when + /// is non-null. An explicit (possibly empty) list overrides inference. + /// + public IReadOnlyList? Capabilities { get; init; } + + /// + /// Optional OIDC prompt value (e.g. "consent", "login", + /// "none", "select_account") sent to the PS to influence the + /// consent/login experience. When (default), no + /// prompt is sent. + /// + public string? Prompt { get; init; } +} diff --git a/src/AAuth/Errors/AAuthTokenExchangeException.cs b/src/AAuth/Errors/AAuthTokenExchangeException.cs new file mode 100644 index 0000000..a15eefa --- /dev/null +++ b/src/AAuth/Errors/AAuthTokenExchangeException.cs @@ -0,0 +1,70 @@ +using System; + +namespace AAuth.Errors; + +/// +/// Thrown when the Person Server's token endpoint rejects a token exchange +/// with a structured error response (§Token Endpoint Error Response Format). +/// +/// +/// The PS returns a non-2xx response whose JSON body carries an +/// error code (REQUIRED) and an optional error_description. +/// This typed exception surfaces those fields so callers (UIs, retry +/// policies, tests) can branch on the error code without re-parsing the +/// body. Responses that are not parseable AAuth error objects fall back to +/// a plain instead. +/// +/// Polling-phase terminal errors (after a 202 deferred response) are +/// surfaced separately via . +/// +/// +public sealed class AAuthTokenExchangeException : Exception +{ + /// The wire error code (e.g. invalid_resource_token). + public string ErrorCode { get; } + + /// The optional human-readable error_description, if present. + public string? ErrorDescription { get; } + + /// The HTTP status code from the token endpoint response. + public int StatusCode { get; } + + /// + /// when the error is not retryable as-is (the agent, + /// resource, or request must change). for transient + /// errors (server_error) where a later retry may succeed. + /// + public bool IsTerminal { get; } + + /// Create a token-exchange exception. + public AAuthTokenExchangeException( + string errorCode, string? errorDescription, int statusCode, bool isTerminal) + : base(BuildMessage(errorCode, errorDescription, statusCode)) + { + ErrorCode = errorCode; + ErrorDescription = errorDescription; + StatusCode = statusCode; + IsTerminal = isTerminal; + } + + /// + /// Classify whether a token-endpoint is terminal. + /// Only server_error is treated as transient (retryable); every other + /// known or unknown code is terminal, including user_unreachable + /// (a hard stop when the PS has no channel to the user and the agent did + /// not declare the interaction capability). + /// + /// + /// This runs only on a non-success status. interaction_required is + /// delivered as 202 Accepted (a success status handled by the + /// deferred poller), so it never reaches this classifier despite not being + /// a terminal outcome. + /// + public static bool IsTerminalCode(string? errorCode) + => !string.Equals(errorCode, "server_error", StringComparison.Ordinal); + + private static string BuildMessage(string errorCode, string? errorDescription, int statusCode) + => errorDescription is { Length: > 0 } + ? $"Token exchange failed: {errorCode} (HTTP {statusCode}) — {errorDescription}" + : $"Token exchange failed: {errorCode} (HTTP {statusCode})"; +} diff --git a/src/AAuth/Errors/SignatureError.cs b/src/AAuth/Errors/SignatureError.cs index 4c4f2dd..c9238e7 100644 --- a/src/AAuth/Errors/SignatureError.cs +++ b/src/AAuth/Errors/SignatureError.cs @@ -101,4 +101,48 @@ public static bool TryParse(string? headerValue, out SignatureErrorCode code) or "unsupported_algorithm" or "invalid_key" or "unknown_key" or "invalid_jwt" or "expired_jwt"; } + + /// + /// Extract the required_input covered components from a + /// Signature-Error: invalid_input; required_input="..." header + /// value. Returns the space-separated component identifiers, or an empty + /// array when the parameter is absent or malformed. + /// + public static string[] ParseRequiredInput(string? headerValue) + { + if (string.IsNullOrWhiteSpace(headerValue)) + return System.Array.Empty(); + + const string marker = "required_input"; + + // The header is a list of ';'-separated parameters + // (e.g. invalid_input; required_input="..."). Match the parameter whose + // name is exactly "required_input" so tokens like "x-required_input" do + // not falsely match. + foreach (var segment in headerValue.Split(';')) + { + var eq = segment.IndexOf('='); + if (eq < 0) + continue; + + var name = segment[..eq].Trim(); + if (!string.Equals(name, marker, System.StringComparison.Ordinal)) + continue; + + var value = segment[(eq + 1)..].Trim(); + var firstQuote = value.IndexOf('"'); + if (firstQuote < 0) + return System.Array.Empty(); + + var secondQuote = value.IndexOf('"', firstQuote + 1); + if (secondQuote < 0) + return System.Array.Empty(); + + var inner = value[(firstQuote + 1)..secondQuote]; + return inner.Split(' ', System.StringSplitOptions.RemoveEmptyEntries + | System.StringSplitOptions.TrimEntries); + } + + return System.Array.Empty(); + } } diff --git a/src/AAuth/Errors/TokenError.cs b/src/AAuth/Errors/TokenError.cs index ce9a3b7..6ca671c 100644 --- a/src/AAuth/Errors/TokenError.cs +++ b/src/AAuth/Errors/TokenError.cs @@ -23,6 +23,14 @@ public enum TokenErrorCode /// User interaction is needed but not available. InteractionRequired, + /// + /// The PS has no channel to reach the user and the agent did not declare + /// the interaction capability. Terminal (HTTP 400) — distinct from + /// , which is a non-terminal 202 carrying + /// an interaction URL. Per draft-02 §Token Endpoint Error Codes. + /// + UserUnreachable, + /// Internal error. ServerError, } @@ -43,6 +51,7 @@ public sealed record TokenErrorResponse(TokenErrorCode Error, string? ErrorDescr TokenErrorCode.InvalidResourceToken => "invalid_resource_token", TokenErrorCode.ExpiredResourceToken => "expired_resource_token", TokenErrorCode.InteractionRequired => "interaction_required", + TokenErrorCode.UserUnreachable => "user_unreachable", TokenErrorCode.ServerError => "server_error", _ => "server_error", }; @@ -58,11 +67,12 @@ public static bool TryParseCode(string? code, out TokenErrorCode result) "invalid_resource_token" => TokenErrorCode.InvalidResourceToken, "expired_resource_token" => TokenErrorCode.ExpiredResourceToken, "interaction_required" => TokenErrorCode.InteractionRequired, + "user_unreachable" => TokenErrorCode.UserUnreachable, "server_error" => TokenErrorCode.ServerError, _ => default, }; return code is "invalid_request" or "invalid_agent_token" or "expired_agent_token" or "invalid_resource_token" or "expired_resource_token" - or "interaction_required" or "server_error"; + or "interaction_required" or "user_unreachable" or "server_error"; } } diff --git a/src/AAuth/HttpSig/AAuthClientBuilder.cs b/src/AAuth/HttpSig/AAuthClientBuilder.cs index 1cc8e8f..d0e6404 100644 --- a/src/AAuth/HttpSig/AAuthClientBuilder.cs +++ b/src/AAuth/HttpSig/AAuthClientBuilder.cs @@ -480,7 +480,14 @@ public HttpMessageHandler BuildHandler() { InnerHandler = new HttpClientHandler(), }; - var exchangeHttpClient = new HttpClient(exchangeSigner); + var exchangeHttpClient = new HttpClient(exchangeSigner) + { + // Token exchange and deferred polling can legitimately take minutes + // (long-poll via Prefer: wait=N). The default 100s HttpClient.Timeout + // would abort mid-poll. The DeferredPoller enforces the real budget + // via DeferredPollerOptions.MaxTotalWait. + Timeout = Timeout.InfiniteTimeSpan, + }; var metadataHttp = new HttpClient(); var metadata = new MetadataClient(metadataHttp); var exchangeClient = new TokenExchangeClient(exchangeHttpClient, metadata); @@ -501,6 +508,11 @@ public HttpMessageHandler BuildHandler() _upstreamTokenProvider) { InnerHandler = outerSigner, + Capabilities = challengeOptions.Capabilities is { } caps + ? new System.Collections.Generic.List(caps) + : null, + Prompt = challengeOptions.Prompt, + AdditionalSignatureComponents = challengeOptions.AdditionalSignatureComponents, }; // If token refresh is configured, insert it above the challenge handler. diff --git a/src/AAuth/HttpSig/AAuthSigningHandler.cs b/src/AAuth/HttpSig/AAuthSigningHandler.cs index 37fb75a..8c47378 100644 --- a/src/AAuth/HttpSig/AAuthSigningHandler.cs +++ b/src/AAuth/HttpSig/AAuthSigningHandler.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Net.Http; +using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -39,6 +40,18 @@ public sealed class AAuthSigningHandler : DelegatingHandler "@method", "@authority", "@path", "signature-key", }); + /// + /// Per-request option carrying additional HTTP message component + /// identifiers (e.g. content-type, content-digest) that + /// MUST be covered by the signature in addition to the base AAuth + /// components (§Covered Components). Set via + /// request.Options.Set(AdditionalComponentsKey, ...); the signer + /// reads it on each . The values + /// are resolved from the request's header fields at signing time. + /// + public static readonly HttpRequestOptionsKey> AdditionalComponentsKey + = new("AAuth.AdditionalSignatureComponents"); + private readonly IAAuthKey _key; private readonly ISignatureKeyProvider _signatureKeyProvider; private readonly Func _clock; @@ -99,11 +112,57 @@ public AAuthSigningHandler( } /// - protected override Task SendAsync( + protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { + await EnsureRequiredContentDigestAsync(request, cancellationToken).ConfigureAwait(false); Sign(request); - return base.SendAsync(request, cancellationToken); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + // When a resource requires `content-digest` as an additional covered + // component (RFC 9530) and the request carries a body without an explicit + // Content-Digest header, compute it here so the signer can cover it. Only + // SHA-256 is emitted. Requests without a body, or that already carry the + // header, are left untouched. This buffering only happens when a resource + // has actually demanded `content-digest`, so the common no-digest path is + // unaffected. Direct callers of the synchronous must + // pre-populate Content-Digest themselves. + private static async Task EnsureRequiredContentDigestAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Content is null) + { + return; + } + if (!request.Options.TryGetValue(AdditionalComponentsKey, out var requested) + || requested is not { Count: > 0 }) + { + return; + } + + var needsDigest = false; + foreach (var raw in requested) + { + if (!string.IsNullOrWhiteSpace(raw) + && string.Equals(raw.Trim(), "content-digest", StringComparison.OrdinalIgnoreCase)) + { + needsDigest = true; + break; + } + } + if (!needsDigest || request.Content.Headers.Contains("Content-Digest")) + { + return; + } + + var body = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + var hash = SHA256.HashData(body); + // RFC 9530 §3: Content-Digest is a Dictionary structured field whose + // member value is a Byte Sequence (`:...:`). RFC 9421 then signs over + // the serialized field value verbatim. + var value = $"sha-256=:{Convert.ToBase64String(hash)}:"; + request.Content.Headers.TryAddWithoutValidation("Content-Digest", value); } /// Apply AAuth signature headers to . @@ -129,7 +188,14 @@ public void Sign(HttpRequestMessage request) // the leading '/', so re-add it. var path = "/" + request.RequestUri.GetComponents(UriComponents.Path, UriFormat.UriEscaped); - var paramsLine = BuildSignatureParams(created, request); + // Additional covered components required by the resource (from its + // metadata or a prior invalid_input error). Resolve each to its + // current header value so it can be both listed in @signature-params + // and appended to the signature base. Unresolvable components are + // skipped here and validated below. + var additional = ResolveAdditionalComponents(request); + + var paramsLine = BuildSignatureParams(created, request, additional); // RFC 9421 §2.5 signature base construction. // @@ -152,6 +218,10 @@ public void Sign(HttpRequestMessage request) { AppendComponent(sb, "authorization", request.Headers.Authorization.ToString()); } + foreach (var (name, value) in additional) + { + AppendComponent(sb, name, value); + } sb.Append("\"@signature-params\": ").Append(paramsLine); var signatureBase = sb.ToString(); @@ -182,7 +252,9 @@ private static void AppendComponent(StringBuilder sb, string name, string value) sb.Append('"').Append(name).Append("\": ").Append(value).Append('\n'); } - private static string BuildSignatureParams(long created, HttpRequestMessage request) + private static string BuildSignatureParams( + long created, HttpRequestMessage request, + IReadOnlyList<(string Name, string Value)> additional) { var sb = new StringBuilder("("); for (int i = 0; i < CoveredComponents.Count; i++) @@ -198,10 +270,88 @@ private static string BuildSignatureParams(long created, HttpRequestMessage requ { sb.Append(" \"authorization\""); } + foreach (var (name, _) in additional) + { + sb.Append(" \"").Append(name).Append('"'); + } sb.Append(");created=").Append(created); return sb.ToString(); } + // Resolve the resource-required additional components (carried in + // request.Options) to (name, value) pairs in declared order. Each name + // is a lowercase HTTP field identifier; its value is taken from the + // request's content headers (e.g. content-type, content-digest) or + // request headers. Names are de-duplicated and the base AAuth components + // (already covered) are never re-added. A required component whose value + // is not present on the request is rejected, because signing over an + // absent field would not satisfy the resource. + private static IReadOnlyList<(string Name, string Value)> ResolveAdditionalComponents( + HttpRequestMessage request) + { + if (!request.Options.TryGetValue(AdditionalComponentsKey, out var requested) + || requested is not { Count: > 0 }) + { + return Array.Empty<(string, string)>(); + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var baseComponent in CoveredComponents) + { + seen.Add(baseComponent); + } + seen.Add("authorization"); + + var resolved = new List<(string, string)>(); + foreach (var raw in requested) + { + if (string.IsNullOrWhiteSpace(raw)) + { + continue; + } + var name = raw.Trim().ToLowerInvariant(); + if (!seen.Add(name)) + { + continue; + } + if (!TryResolveFieldValue(request, name, out var value)) + { + var origin = request.RequestUri is { } uri + ? uri.GetComponents( + UriComponents.Scheme | UriComponents.Host | UriComponents.Port, + UriFormat.UriEscaped) + : "(unknown origin)"; + throw new InvalidOperationException( + $"Resource at {origin} requires signature component '{name}', but the request " + + "has no such header to sign over. Components AAuth can compute automatically " + + "(e.g. 'content-digest' on a body-bearing request) are added before signing; " + + "any other required component must be set on the request by the caller."); + } + resolved.Add((name, value)); + } + return resolved; + } + + private static bool TryResolveFieldValue( + HttpRequestMessage request, string name, out string value) + { + // Content headers (content-type, content-digest, content-length, ...) + // live on request.Content; everything else on request.Headers. RFC + // 9421 §2.1: multiple field values are combined with ", ". + if (request.Content?.Headers.TryGetValues(name, out var contentValues) == true) + { + value = string.Join(", ", contentValues); + return true; + } + if (request.Headers.TryGetValues(name, out var headerValues)) + { + value = string.Join(", ", headerValues); + return true; + } + value = string.Empty; + return false; + } + /// /// Create an that signs every outbound request. /// diff --git a/src/AAuth/HttpSig/ChallengeHandlingOptions.cs b/src/AAuth/HttpSig/ChallengeHandlingOptions.cs index 306f59f..88c646b 100644 --- a/src/AAuth/HttpSig/ChallengeHandlingOptions.cs +++ b/src/AAuth/HttpSig/ChallengeHandlingOptions.cs @@ -48,4 +48,33 @@ public sealed class ChallengeHandlingOptions /// or progress UI during deferred exchanges. /// public Action? OnPoll { get; set; } + + /// + /// Capabilities to declare to the PS in the token request body. When + /// (default), capabilities are inferred from the + /// flow: "interaction" is declared when + /// is set. Supply an explicit (possibly empty) list to override inference — + /// an empty list suppresses the capability declaration entirely. + /// + public System.Collections.Generic.IList? Capabilities { get; set; } + + /// + /// Optional OIDC prompt value (e.g. "consent", "login", + /// "none", "select_account") sent to the PS during token + /// exchange to influence the consent/login experience. When + /// (default), no prompt is sent. + /// + public string? Prompt { get; set; } + + /// + /// Additional signature components a resource requires, keyed by origin + /// (scheme://host:port). Typically populated from a resource's + /// additional_signature_components metadata so requests cover those + /// components on the first attempt. Components a resource demands at + /// runtime via an invalid_input error are learned and merged on top + /// of these automatically, so this is an optional optimisation. + /// §Covered Components. + /// + public System.Collections.Generic.IReadOnlyDictionary>? AdditionalSignatureComponents { get; set; } } diff --git a/src/AAuth/Server/CallChainingHandler.cs b/src/AAuth/Server/CallChainingHandler.cs index 54ed381..b63a6de 100644 --- a/src/AAuth/Server/CallChainingHandler.cs +++ b/src/AAuth/Server/CallChainingHandler.cs @@ -77,9 +77,12 @@ public async Task ExchangeForDownstreamAsync( return await _exchangeClient.ExchangeAsync( targetServer, resourceToken, - onInteractionRequired: onInteractionRequired, - pollerOptions: pollerOptions, - upstreamToken: upstreamAuthToken, + new TokenExchangeRequest + { + OnInteractionRequired = onInteractionRequired, + PollerOptions = pollerOptions, + UpstreamToken = upstreamAuthToken, + }, cancellationToken).ConfigureAwait(false); } diff --git a/tests/AAuth.Conformance/AuthTokens/CallChainingTests.cs b/tests/AAuth.Conformance/AuthTokens/CallChainingTests.cs index 0b8d5dd..4db81db 100644 --- a/tests/AAuth.Conformance/AuthTokens/CallChainingTests.cs +++ b/tests/AAuth.Conformance/AuthTokens/CallChainingTests.cs @@ -46,9 +46,10 @@ public async Task UpstreamTokenIncludedInPostBody() await exchangeClient.ExchangeAsync( "http://localhost:5555", resourceToken, - onInteractionRequired: null, - pollerOptions: null, - upstreamToken: upstreamToken); + new TokenExchangeRequest + { + UpstreamToken = upstreamToken, + }); Assert.NotNull(capturedBody); Assert.Equal(resourceToken, (string?)capturedBody!["resource_token"]); diff --git a/tests/AAuth.Conformance/Errors/SignatureErrorTests.cs b/tests/AAuth.Conformance/Errors/SignatureErrorTests.cs index 72e2807..078c35d 100644 --- a/tests/AAuth.Conformance/Errors/SignatureErrorTests.cs +++ b/tests/AAuth.Conformance/Errors/SignatureErrorTests.cs @@ -122,6 +122,32 @@ public void InvalidInput_IncludesRequiredInput() Assert.Contains("required_input=\"content-digest\"", header); } + [Fact(DisplayName = "§Signature-Error — ParseRequiredInput extracts space-separated components")] + public void ParseRequiredInput_ExtractsComponents() + { + var components = SignatureError.ParseRequiredInput( + "invalid_input; required_input=\"@method @authority content-digest\""); + Assert.Equal(new[] { "@method", "@authority", "content-digest" }, components); + } + + [Fact(DisplayName = "§Signature-Error — ParseRequiredInput ignores look-alike parameter names")] + public void ParseRequiredInput_IgnoresLookAlikeParameter() + { + // A different parameter whose name merely ends in "required_input" must + // not be mistaken for the real required_input parameter. + var components = SignatureError.ParseRequiredInput( + "invalid_input; x-required_input=\"content-digest\""); + Assert.Empty(components); + } + + [Fact(DisplayName = "§Signature-Error — ParseRequiredInput returns empty for missing parameter")] + public void ParseRequiredInput_ReturnsEmptyWhenAbsent() + { + Assert.Empty(SignatureError.ParseRequiredInput("invalid_input")); + Assert.Empty(SignatureError.ParseRequiredInput(null)); + Assert.Empty(SignatureError.ParseRequiredInput("")); + } + [Fact(DisplayName = "§Signature-Error — unsupported_algorithm includes supported_algorithms parameter")] public void UnsupportedAlgorithm_IncludesSupportedAlgorithms() { diff --git a/tests/AAuth.Conformance/Errors/TokenErrorTests.cs b/tests/AAuth.Conformance/Errors/TokenErrorTests.cs index 1d04920..9c07ad2 100644 --- a/tests/AAuth.Conformance/Errors/TokenErrorTests.cs +++ b/tests/AAuth.Conformance/Errors/TokenErrorTests.cs @@ -15,6 +15,7 @@ public class TokenErrorTests [InlineData("invalid_resource_token", TokenErrorCode.InvalidResourceToken)] [InlineData("expired_resource_token", TokenErrorCode.ExpiredResourceToken)] [InlineData("interaction_required", TokenErrorCode.InteractionRequired)] + [InlineData("user_unreachable", TokenErrorCode.UserUnreachable)] [InlineData("server_error", TokenErrorCode.ServerError)] public void ParsesAllErrorCodes(string wireCode, TokenErrorCode expected) { @@ -41,4 +42,14 @@ public void NullCode_ReturnsFalse() { Assert.False(TokenErrorResponse.TryParseCode(null, out _)); } + + [Fact(DisplayName = "draft-02 §Token Endpoint Errors — user_unreachable is terminal")] + public void UserUnreachable_IsTerminal() + { + // Per upcoming-changes-02 item 2: user_unreachable (HTTP 400) is a + // distinct terminal error, not retryable. + Assert.Equal("user_unreachable", + new TokenErrorResponse(TokenErrorCode.UserUnreachable).ErrorCode); + Assert.True(AAuthTokenExchangeException.IsTerminalCode("user_unreachable")); + } } diff --git a/tests/AAuth.Conformance/Observability/ActivityDiagnosticsTests.cs b/tests/AAuth.Conformance/Observability/ActivityDiagnosticsTests.cs index 58147bc..d4ea138 100644 --- a/tests/AAuth.Conformance/Observability/ActivityDiagnosticsTests.cs +++ b/tests/AAuth.Conformance/Observability/ActivityDiagnosticsTests.cs @@ -275,11 +275,14 @@ public async Task DeferredPoll_CreatesActivitySpan() await exchange.ExchangeAsync( "http://localhost:9997", "rt-xyz", - onInteractionRequired: (_, _) => Task.CompletedTask, - pollerOptions: new DeferredPollerOptions + new TokenExchangeRequest { - DefaultPollInterval = TimeSpan.FromMilliseconds(1), - MinPollInterval = TimeSpan.Zero, + OnInteractionRequired = (_, _) => Task.CompletedTask, + PollerOptions = new DeferredPollerOptions + { + DefaultPollInterval = TimeSpan.FromMilliseconds(1), + MinPollInterval = TimeSpan.Zero, + }, }); Assert.Contains(_activities, a => a.OperationName == "AAuth.TokenExchange"); diff --git a/tests/AAuth.Tests/Agent/ChallengeHandlerTests.cs b/tests/AAuth.Tests/Agent/ChallengeHandlerTests.cs index f0dbc25..3c6ffdd 100644 --- a/tests/AAuth.Tests/Agent/ChallengeHandlerTests.cs +++ b/tests/AAuth.Tests/Agent/ChallengeHandlerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Text; @@ -7,7 +8,9 @@ using System.Threading.Tasks; using AAuth.Agent; using AAuth.Discovery; +using AAuth.Errors; using AAuth.Headers; +using AAuth.HttpSig; using Microsoft.IdentityModel.Tokens; using Xunit; @@ -17,6 +20,7 @@ public class ChallengeHandlerTests { private const string PsUrl = "http://localhost:5555"; private const string ResourceUrl = "http://localhost:6000"; + private static readonly HttpRequestOptionsKey CustomOptionKey = new("Test.CallerState"); // ── Upstream token routing ────────────────────────────────────────────── @@ -220,9 +224,10 @@ public async Task InitialExchangePost_IncludesPreferHeader() await exchangeClient.ExchangeAsync( PsUrl, "fake-resource-token", - onInteractionRequired: null, - pollerOptions: new DeferredPollerOptions { PreferWaitSeconds = 45 }, - upstreamToken: null); + new TokenExchangeRequest + { + PollerOptions = new DeferredPollerOptions { PreferWaitSeconds = 45 }, + }); Assert.Equal("wait=45", capturedPrefer); } @@ -242,13 +247,354 @@ public async Task InitialExchangePost_OmitsPreferWhenNotConfigured() await exchangeClient.ExchangeAsync( PsUrl, "fake-resource-token", - onInteractionRequired: null, - pollerOptions: null, - upstreamToken: null); + new TokenExchangeRequest()); Assert.Null(capturedPrefer); } + // ── Capabilities & prompt in exchange body ────────────────────────────── + + [Fact(DisplayName = "TokenExchangeClient — infers 'interaction' capability when callback supplied")] + public async Task ExchangeBody_InfersInteractionCapability() + { + var body = await CaptureExchangeBodyAsync( + onInteractionRequired: (_, _) => Task.CompletedTask); + + var caps = body!["capabilities"]!.AsArray(); + Assert.Single(caps); + Assert.Equal("interaction", (string)caps[0]!); + } + + [Fact(DisplayName = "TokenExchangeClient — omits capabilities when no callback and none specified")] + public async Task ExchangeBody_OmitsCapabilities_WhenNoCallback() + { + var body = await CaptureExchangeBodyAsync(onInteractionRequired: null); + + Assert.False(body!.ContainsKey("capabilities")); + } + + [Fact(DisplayName = "TokenExchangeClient — explicit capabilities override inference")] + public async Task ExchangeBody_ExplicitCapabilities_Override() + { + var body = await CaptureExchangeBodyAsync( + onInteractionRequired: (_, _) => Task.CompletedTask, + capabilities: new[] { "interaction", "payment" }); + + var caps = body!["capabilities"]!.AsArray(); + Assert.Equal(2, caps.Count); + Assert.Equal("interaction", (string)caps[0]!); + Assert.Equal("payment", (string)caps[1]!); + } + + [Fact(DisplayName = "TokenExchangeClient — empty capabilities list suppresses the field")] + public async Task ExchangeBody_EmptyCapabilities_Suppresses() + { + var body = await CaptureExchangeBodyAsync( + onInteractionRequired: (_, _) => Task.CompletedTask, + capabilities: Array.Empty()); + + Assert.False(body!.ContainsKey("capabilities")); + } + + [Fact(DisplayName = "TokenExchangeClient — sends prompt when supplied")] + public async Task ExchangeBody_SendsPrompt_WhenSupplied() + { + var body = await CaptureExchangeBodyAsync( + onInteractionRequired: null, prompt: "consent"); + + Assert.Equal("consent", (string)body!["prompt"]!); + } + + [Fact(DisplayName = "TokenExchangeClient — omits prompt when not supplied")] + public async Task ExchangeBody_OmitsPrompt_WhenNotSupplied() + { + var body = await CaptureExchangeBodyAsync(onInteractionRequired: null); + + Assert.False(body!.ContainsKey("prompt")); + } + + // ── Typed token-exchange errors (Gap E) ───────────────────────────────── + + [Theory(DisplayName = "TokenExchangeClient — non-2xx with error body throws typed exception")] + [InlineData(HttpStatusCode.BadRequest, "invalid_resource_token", true)] + [InlineData(HttpStatusCode.BadRequest, "expired_agent_token", true)] + [InlineData((HttpStatusCode)400, "user_unreachable", true)] + [InlineData(HttpStatusCode.Forbidden, "interaction_required", true)] + [InlineData(HttpStatusCode.InternalServerError, "server_error", false)] + public async Task Exchange_NonSuccessWithErrorBody_ThrowsTyped( + HttpStatusCode status, string errorCode, bool expectedTerminal) + { + var exchangeHandler = new ErrorExchangeHandler(status, + $"{{\"error\":\"{errorCode}\",\"error_description\":\"boom\"}}"); + var metaClient = new MetadataClient(new HttpClient(exchangeHandler)); + var exchangeClient = new TokenExchangeClient(new HttpClient(exchangeHandler), metaClient); + + var ex = await Assert.ThrowsAsync( + () => exchangeClient.ExchangeAsync(PsUrl, "fake-resource-token")); + + Assert.Equal(errorCode, ex.ErrorCode); + Assert.Equal("boom", ex.ErrorDescription); + Assert.Equal((int)status, ex.StatusCode); + Assert.Equal(expectedTerminal, ex.IsTerminal); + } + + [Fact(DisplayName = "TokenExchangeClient — non-2xx without parseable error falls back to HttpRequestException")] + public async Task Exchange_NonSuccessWithoutErrorBody_FallsBack() + { + var exchangeHandler = new ErrorExchangeHandler( + HttpStatusCode.BadGateway, "nginx 502"); + var metaClient = new MetadataClient(new HttpClient(exchangeHandler)); + var exchangeClient = new TokenExchangeClient(new HttpClient(exchangeHandler), metaClient); + + await Assert.ThrowsAsync( + () => exchangeClient.ExchangeAsync(PsUrl, "fake-resource-token")); + } + + [Fact(DisplayName = "TokenExchangeClient — JSON body without 'error' member falls back to HttpRequestException")] + public async Task Exchange_JsonWithoutError_FallsBack() + { + var exchangeHandler = new ErrorExchangeHandler( + HttpStatusCode.BadRequest, "{\"detail\":\"something\"}"); + var metaClient = new MetadataClient(new HttpClient(exchangeHandler)); + var exchangeClient = new TokenExchangeClient(new HttpClient(exchangeHandler), metaClient); + + await Assert.ThrowsAsync( + () => exchangeClient.ExchangeAsync(PsUrl, "fake-resource-token")); + } + + private static async Task CaptureExchangeBodyAsync( + Func? onInteractionRequired, + IReadOnlyList? capabilities = null, + string? prompt = null) + { + JsonObject? capturedBody = null; + var exchangeHandler = new CapturingExchangeHandler(req => + { + if (req.Content is not null) + { + var json = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + capturedBody = JsonNode.Parse(json) as JsonObject; + } + }); + + var metaClient = new MetadataClient(new HttpClient(exchangeHandler)); + var exchangeClient = new TokenExchangeClient(new HttpClient(exchangeHandler), metaClient); + + await exchangeClient.ExchangeAsync( + PsUrl, "fake-resource-token", + new TokenExchangeRequest + { + OnInteractionRequired = onInteractionRequired, + Capabilities = capabilities, + Prompt = prompt, + }); + + return capturedBody; + } + + // ── Adaptive signing components (§Covered Components) ─────────────────── + + [Fact(DisplayName = "ChallengeHandler — invalid_input learns required components and retries once")] + public async Task AdaptiveSigning_InvalidInput_LearnsAndRetriesOnce() + { + var resource = new AdaptiveResourceHandler( + _ => InvalidInput("content-digest"), + _ => Ok()); + + using var client = BuildAdaptiveClient(resource, out _); + var response = await client.GetAsync("/data"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, resource.CallCount); + // First attempt has no extra components; the retry carries the learned set. + Assert.Null(resource.Observed[0]); + Assert.NotNull(resource.Observed[1]); + Assert.Equal(new[] { "content-digest" }, resource.Observed[1]!); + } + + [Fact(DisplayName = "ChallengeHandler — learned components are cached per origin for later requests")] + public async Task AdaptiveSigning_LearnedComponents_CachedForLaterRequests() + { + var resource = new AdaptiveResourceHandler( + _ => InvalidInput("content-digest"), + _ => Ok(), + _ => Ok()); + + using var client = BuildAdaptiveClient(resource, out _); + await client.GetAsync("/data"); // attempt 1 (401) + retry (200) + await client.GetAsync("/more"); // attempt 3 should be proactively seeded + + Assert.Equal(3, resource.CallCount); + Assert.Equal(new[] { "content-digest" }, resource.Observed[2]!); + } + + [Fact(DisplayName = "ChallengeHandler — metadata-seeded components cover the first request")] + public async Task AdaptiveSigning_MetadataSeed_CoversFirstRequest() + { + var resource = new AdaptiveResourceHandler(_ => Ok()); + var seed = new Dictionary> + { + [ResourceUrl] = new[] { "content-type" }, + }; + + using var client = BuildAdaptiveClient(resource, out _, seed); + await client.GetAsync("/data"); + + Assert.Equal(1, resource.CallCount); + Assert.Equal(new[] { "content-type" }, resource.Observed[0]!); + } + + [Fact(DisplayName = "ChallengeHandler — invalid_input merges learned components on top of metadata seed")] + public async Task AdaptiveSigning_InvalidInput_MergesOnTopOfMetadataSeed() + { + var resource = new AdaptiveResourceHandler( + _ => InvalidInput("content-digest"), + _ => Ok()); + var seed = new Dictionary> + { + [ResourceUrl] = new[] { "content-type" }, + }; + + using var client = BuildAdaptiveClient(resource, out _, seed); + await client.GetAsync("/data"); + + Assert.Equal(2, resource.CallCount); + Assert.Equal(new[] { "content-type" }, resource.Observed[0]!); + Assert.Equal(new[] { "content-type", "content-digest" }, resource.Observed[1]!); + } + + [Fact(DisplayName = "ChallengeHandler — no Signature-Error returns response unchanged")] + public async Task AdaptiveSigning_NoSignatureError_NoRetry() + { + var resource = new AdaptiveResourceHandler(_ => Ok()); + + using var client = BuildAdaptiveClient(resource, out _); + var response = await client.GetAsync("/data"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, resource.CallCount); + Assert.Null(resource.Observed[0]); + } + + [Fact(DisplayName = "ChallengeHandler — invalid_input without required_input does not retry")] + public async Task AdaptiveSigning_InvalidInputWithoutRequiredInput_NoRetry() + { + var resource = new AdaptiveResourceHandler( + _ => InvalidInput(/* no required_input */)); + + using var client = BuildAdaptiveClient(resource, out _); + var response = await client.GetAsync("/data"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(1, resource.CallCount); + } + + [Fact(DisplayName = "ChallengeHandler — seed merges caller-set components additively")] + public async Task AdaptiveSigning_Seed_MergesCallerSetComponents() + { + var resource = new AdaptiveResourceHandler(_ => Ok()); + var seed = new Dictionary> + { + [ResourceUrl] = new[] { "content-type" }, + }; + + using var client = BuildAdaptiveClient(resource, out _, seed); + + var request = new HttpRequestMessage(HttpMethod.Get, "/data"); + request.Options.Set( + AAuthSigningHandler.AdditionalComponentsKey, new[] { "x-caller" }); + await client.SendAsync(request); + + Assert.Equal(1, resource.CallCount); + // Caller-set component is preserved additively alongside the seed. + Assert.Equal(new[] { "content-type", "x-caller" }, resource.Observed[0]!); + } + + [Fact(DisplayName = "ChallengeHandler — non-AAuth request option survives the adaptive retry clone")] + public async Task AdaptiveSigning_CloneAsync_PreservesCallerOption() + { + var resource = new AdaptiveResourceHandler( + _ => InvalidInput("content-type"), + _ => Ok()); + + using var client = BuildAdaptiveClient(resource, out _); + + var request = new HttpRequestMessage(HttpMethod.Get, "/data"); + request.Options.Set(CustomOptionKey, "caller-state"); + await client.SendAsync(request); + + Assert.Equal(2, resource.CallCount); + // Both the first attempt and the retried clone carry the caller option. + Assert.Equal("caller-state", resource.ObservedCustom[0]); + Assert.Equal("caller-state", resource.ObservedCustom[1]); + } + + [Fact(DisplayName = "ChallengeHandler — adaptive retry happens at most once")] + public async Task AdaptiveSigning_RetriesAtMostOnce() + { + var resource = new AdaptiveResourceHandler( + _ => InvalidInput("content-digest"), + _ => InvalidInput("content-digest")); + + using var client = BuildAdaptiveClient(resource, out _); + var response = await client.GetAsync("/data"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(2, resource.CallCount); + } + + [Fact(DisplayName = "ChallengeHandler — learned components persist for later requests to the same origin")] + public async Task AdaptiveSigning_LearnedComponents_PersistAcrossRequests() + { + var resource = new AdaptiveResourceHandler( + _ => InvalidInput("content-digest"), // request 1, attempt 1 + _ => Ok(), // request 1, retry (learns content-digest) + _ => Ok()); // request 2, first attempt + + using var client = BuildAdaptiveClient(resource, out _); + + await client.GetAsync("/data"); + await client.GetAsync("/data"); + + Assert.Equal(3, resource.CallCount); + Assert.Null(resource.Observed[0]); + Assert.Equal(new[] { "content-digest" }, resource.Observed[1]!); + // Second request signs content-digest up front from the learned set. + Assert.Equal(new[] { "content-digest" }, resource.Observed[2]!); + } + + private static HttpClient BuildAdaptiveClient( + HttpMessageHandler resource, + out AAuthTokenHolder holder, + IReadOnlyDictionary>? seed = null) + { + var exchangeHandler = new CapturingExchangeHandler(_ => { }); + var metaClient = new MetadataClient(new HttpClient(exchangeHandler)); + var exchangeClient = new TokenExchangeClient(new HttpClient(exchangeHandler), metaClient); + holder = new AAuthTokenHolder("initial-token"); + + var challengeHandler = new ChallengeHandler( + exchangeClient, holder, personServer: PsUrl) + { + InnerHandler = resource, + AdditionalSignatureComponents = seed, + }; + + return new HttpClient(challengeHandler) { BaseAddress = new Uri(ResourceUrl) }; + } + + private static HttpResponseMessage InvalidInput(params string[] required) + { + var response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + response.Headers.TryAddWithoutValidation( + SignatureError.HeaderName, + SignatureError.Format(SignatureErrorCode.InvalidInput, required)); + return response; + } + + private static HttpResponseMessage Ok() + => new(HttpStatusCode.OK) { Content = new StringContent("{\"ok\":true}") }; + // ── Helpers ───────────────────────────────────────────────────────────── private static string BuildTokenWithPayload(JsonObject payload) @@ -259,6 +605,36 @@ private static string BuildTokenWithPayload(JsonObject payload) return $"{h}.{p}.fake-sig"; } + /// + /// Resource handler driven by a per-call script. Records the additional + /// signature components observed in each request's options so adaptive + /// signing behaviour can be asserted. + /// + private sealed class AdaptiveResourceHandler : HttpMessageHandler + { + private readonly Queue> _script; + public System.Collections.Generic.List?> Observed { get; } = new(); + public System.Collections.Generic.List ObservedCustom { get; } = new(); + public int CallCount { get; private set; } + + public AdaptiveResourceHandler(params Func[] script) + => _script = new Queue>(script); + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + CallCount++; + request.Options.TryGetValue(AAuthSigningHandler.AdditionalComponentsKey, out var comps); + Observed.Add(comps); + ObservedCustom.Add( + request.Options.TryGetValue(CustomOptionKey, out var custom) ? custom : null); + var step = _script.Count > 0 + ? _script.Dequeue() + : (_ => new HttpResponseMessage(HttpStatusCode.OK)); + return Task.FromResult(step(request)); + } + } + /// Returns a 401 challenge on first request, then 200 on retry. private sealed class MockResourceHandler : HttpMessageHandler { @@ -320,4 +696,42 @@ protected override Task SendAsync( }); } } + + /// + /// Serves metadata for well-known requests and returns a fixed + /// non-success status + body for the token endpoint POST. + /// + private sealed class ErrorExchangeHandler : HttpMessageHandler + { + private readonly HttpStatusCode _status; + private readonly string _body; + public ErrorExchangeHandler(HttpStatusCode status, string body) + { + _status = status; + _body = body; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + if (request.RequestUri?.AbsolutePath.Contains("well-known") == true) + { + var origin = request.RequestUri.GetLeftPart(UriPartial.Authority); + var metadata = new JsonObject + { + ["issuer"] = origin, + ["token_endpoint"] = $"{origin}/token", + }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(metadata.ToJsonString(), Encoding.UTF8, "application/json"), + }); + } + + return Task.FromResult(new HttpResponseMessage(_status) + { + Content = new StringContent(_body, Encoding.UTF8, "application/json"), + }); + } + } } diff --git a/tests/AAuth.Tests/Agent/DeferredPollerTests.cs b/tests/AAuth.Tests/Agent/DeferredPollerTests.cs index b139bd0..b4254f9 100644 --- a/tests/AAuth.Tests/Agent/DeferredPollerTests.cs +++ b/tests/AAuth.Tests/Agent/DeferredPollerTests.cs @@ -121,6 +121,29 @@ public async Task PollAsync_FiresOnPoll_ForEachAttempt() }, observed); } + [Fact] + public async Task PollAsync_ThrowsTimeout_BeforeSleepingPastBudget() + { + // MaxTotalWait is enforced before backing off — a large Retry-After must not + // cause the poller to sleep well past the budget before timing out. + var handler = new ScriptedHandler( + r => Respond(HttpStatusCode.Accepted, retryAfter: TimeSpan.FromSeconds(30))); + using var client = new HttpClient(handler); + var poller = new DeferredPoller(client, new DeferredPollerOptions + { + MaxTotalWait = TimeSpan.FromMilliseconds(50), + DefaultPollInterval = TimeSpan.FromMilliseconds(10), + MinPollInterval = TimeSpan.Zero, + }); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + await Assert.ThrowsAsync(() => poller.PollAsync(PendingUrl)); + sw.Stop(); + + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), + $"Expected fast timeout (clamped to budget), but waited {sw.Elapsed.TotalSeconds:0.##}s."); + } + private static HttpResponseMessage Respond( HttpStatusCode status, TimeSpan? retryAfter = null, diff --git a/tests/AAuth.Tests/HttpSig/AAuthSigningHandlerTests.cs b/tests/AAuth.Tests/HttpSig/AAuthSigningHandlerTests.cs index d55cbe7..7c23d46 100644 --- a/tests/AAuth.Tests/HttpSig/AAuthSigningHandlerTests.cs +++ b/tests/AAuth.Tests/HttpSig/AAuthSigningHandlerTests.cs @@ -231,4 +231,177 @@ public async Task OnSignatureBase_IsInvokedWithBytesActuallySigned() var b64 = Regex.Match(sigHeader, @":(?[^:]+):").Groups["v"].Value; Assert.True(key.Verify(Encoding.ASCII.GetBytes(observedBase!), Convert.FromBase64String(b64))); } + + [Fact] + public async Task SendAsync_NoAdditionalComponents_SignsBaseComponentsOnly() + { + // Regression guard: when no additional components are requested, the + // Signature-Input must contain only the four base AAuth components. + var key = AAuthKey.Generate(); + var capture = new CaptureHandler(); + var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero); + var signing = new AAuthSigningHandler(key, () => "abc.def.ghi", () => clock) { InnerHandler = capture }; + using var client = new HttpClient(signing); + + await client.GetAsync("https://resource.example/api"); + + var input = string.Join(',', capture.Captured!.Headers.GetValues("Signature-Input")); + Assert.Equal( + $"sig=(\"@method\" \"@authority\" \"@path\" \"signature-key\");created={clock.ToUnixTimeSeconds()}", + input); + } + + [Fact] + public async Task SendAsync_AdditionalComponents_AppendedAfterBaseAndVerify() + { + var key = AAuthKey.Generate(); + var capture = new CaptureHandler(); + var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero); + var signing = new AAuthSigningHandler(key, () => "abc.def.ghi", () => clock) { InnerHandler = capture }; + using var client = new HttpClient(signing); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://resource.example/api") + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + }; + request.Content.Headers.Add("Content-Digest", "sha-256=:abc:"); + request.Options.Set( + AAuthSigningHandler.AdditionalComponentsKey, + new[] { "content-type", "content-digest" }); + + await client.SendAsync(request); + + var req = capture.Captured!; + var input = string.Join(',', req.Headers.GetValues("Signature-Input")); + Assert.Equal( + $"sig=(\"@method\" \"@authority\" \"@path\" \"signature-key\" \"content-type\" \"content-digest\");created={clock.ToUnixTimeSeconds()}", + input); + + // The additional components must be covered by the signature too. + var sigHeader = string.Join(',', req.Headers.GetValues("Signature")); + var signature = Convert.FromBase64String( + Regex.Match(sigHeader, @"^sig=:(?[^:]+):$").Groups["b64"].Value); + var paramsLine = input["sig=".Length..]; + var baseStr = new StringBuilder() + .Append("\"@method\": POST\n") + .Append("\"@authority\": resource.example\n") + .Append("\"@path\": /api\n") + .Append("\"signature-key\": sig=jwt;jwt=\"abc.def.ghi\"\n") + .Append("\"content-type\": application/json; charset=utf-8\n") + .Append("\"content-digest\": sha-256=:abc:\n") + .Append("\"@signature-params\": ").Append(paramsLine) + .ToString(); + Assert.True(key.Verify(Encoding.ASCII.GetBytes(baseStr), signature)); + } + + [Fact] + public async Task SendAsync_AdditionalComponents_DeduplicatesAndIgnoresBaseComponents() + { + var key = AAuthKey.Generate(); + var capture = new CaptureHandler(); + var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero); + var signing = new AAuthSigningHandler(key, () => "abc.def.ghi", () => clock) { InnerHandler = capture }; + using var client = new HttpClient(signing); + + var request = new HttpRequestMessage(HttpMethod.Get, "https://resource.example/api"); + request.Headers.Add("X-Custom", "v1"); + // Base components and duplicates must be filtered out. + request.Options.Set( + AAuthSigningHandler.AdditionalComponentsKey, + new[] { "@method", "signature-key", "x-custom", "x-custom" }); + + await client.SendAsync(request); + + var input = string.Join(',', capture.Captured!.Headers.GetValues("Signature-Input")); + Assert.Equal( + $"sig=(\"@method\" \"@authority\" \"@path\" \"signature-key\" \"x-custom\");created={clock.ToUnixTimeSeconds()}", + input); + } + + [Fact] + public async Task SendAsync_AdditionalComponentMissingFromRequest_Throws() + { + var key = AAuthKey.Generate(); + var capture = new CaptureHandler(); + var signing = new AAuthSigningHandler(key, () => "abc.def.ghi") { InnerHandler = capture }; + using var client = new HttpClient(signing); + + var request = new HttpRequestMessage(HttpMethod.Get, "https://resource.example/api"); + request.Options.Set( + AAuthSigningHandler.AdditionalComponentsKey, + new[] { "content-digest" }); + + await Assert.ThrowsAsync(() => client.SendAsync(request)); + } + + [Fact] + public async Task SendAsync_RequiredContentDigest_ComputedAndCovered() + { + var key = AAuthKey.Generate(); + var capture = new CaptureHandler(); + var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero); + var signing = new AAuthSigningHandler(key, () => "abc.def.ghi", () => clock) { InnerHandler = capture }; + using var client = new HttpClient(signing); + + const string bodyText = "{\"hello\":\"world\"}"; + var request = new HttpRequestMessage(HttpMethod.Post, "https://resource.example/api") + { + Content = new StringContent(bodyText, Encoding.UTF8, "application/json"), + }; + // Resource requires content-digest, but the caller did not set it. + request.Options.Set( + AAuthSigningHandler.AdditionalComponentsKey, + new[] { "content-digest" }); + + await client.SendAsync(request); + + var req = capture.Captured!; + var expectedDigest = + $"sha-256=:{Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(bodyText)))}:"; + Assert.Equal(expectedDigest, string.Join(", ", req.Content!.Headers.GetValues("Content-Digest"))); + + var input = string.Join(',', req.Headers.GetValues("Signature-Input")); + Assert.Equal( + $"sig=(\"@method\" \"@authority\" \"@path\" \"signature-key\" \"content-digest\");created={clock.ToUnixTimeSeconds()}", + input); + + // The auto-computed digest must be covered by the signature. + var sigHeader = string.Join(',', req.Headers.GetValues("Signature")); + var signature = Convert.FromBase64String( + Regex.Match(sigHeader, @"^sig=:(?[^:]+):$").Groups["b64"].Value); + var paramsLine = input["sig=".Length..]; + var baseStr = new StringBuilder() + .Append("\"@method\": POST\n") + .Append("\"@authority\": resource.example\n") + .Append("\"@path\": /api\n") + .Append("\"signature-key\": sig=jwt;jwt=\"abc.def.ghi\"\n") + .Append("\"content-digest\": ").Append(expectedDigest).Append('\n') + .Append("\"@signature-params\": ").Append(paramsLine) + .ToString(); + Assert.True(key.Verify(Encoding.ASCII.GetBytes(baseStr), signature)); + } + + [Fact] + public async Task SendAsync_RequiredContentDigest_DoesNotOverwriteCallerHeader() + { + var key = AAuthKey.Generate(); + var capture = new CaptureHandler(); + var signing = new AAuthSigningHandler(key, () => "abc.def.ghi") { InnerHandler = capture }; + using var client = new HttpClient(signing); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://resource.example/api") + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + }; + request.Content.Headers.Add("Content-Digest", "sha-256=:caller-supplied:"); + request.Options.Set( + AAuthSigningHandler.AdditionalComponentsKey, + new[] { "content-digest" }); + + await client.SendAsync(request); + + Assert.Equal( + "sha-256=:caller-supplied:", + string.Join(", ", capture.Captured!.Content!.Headers.GetValues("Content-Digest"))); + } }