diff --git a/.agent/plans/2026-05-27-ap-enrollment-key-naming/implementation-plan.md b/.agent/plans/2026-05-27-ap-enrollment-key-naming/implementation-plan.md new file mode 100644 index 0000000..658f06f --- /dev/null +++ b/.agent/plans/2026-05-27-ap-enrollment-key-naming/implementation-plan.md @@ -0,0 +1,359 @@ +--- +title: "AP Enrollment Key Naming — Implementation Plan" +description: Rename EnrolledKeyId to LocalKeyHandle, default to JWK thumbprint, and clarify docs/samples around AP↔agent identifier separation. +ms.date: 2026-05-27 +--- + +## Goals + +1. Stop conflating three distinct identifiers (JWK thumbprint, AP-internal JWT `kid`, local keystore handle). +2. Default the local keystore handle to the durable key's JWK thumbprint (spec-endorsed; § Identifier Strategies in `draft-hardt-aauth-bootstrap.md`). +3. Make XML docs, samples, and prose state clearly that the AP and agent **never share a keystore**, and that the AP identifies the agent at refresh time by signature/thumbprint, not by any string the agent sends. + +Breaking renames are OK (alpha). + +## Phase 1 — SDK changes + +`src/AAuth/Agent/AgentProviderClient.cs` +* Rename `EnrollResult.EnrolledKeyId` → `LocalKeyHandle`. XML doc: "Agent-local handle for the durable private key in `IKeyStore`. Persist this in your config so the agent can re-load the key after restart. The AP does not see this value — at refresh time the AP identifies the agent from the HTTP signature (JWK thumbprint), not from this string." +* Add `EnrollResult.AgentTokenKid` (nullable `string`). XML doc: "AP-internal opaque identifier the AP returned in the enrollment response (typically the JWT `kid` of the issued agent token). Diagnostic only — the agent never sends this back." +* In `EnrolAsync`: drop the `$"{agentId}:{Guid.NewGuid():N}"` fabrication. Compute `localHandle = key.ComputeJwkThumbprint()`. Store the key under `localHandle`. Capture the AP's optional `body["key_id"]` into `AgentTokenKid`. +* Collapse the two `RefreshAsync` overloads into one: `RefreshAsync(string refreshEndpoint, string localKeyHandle, CancellationToken ct)`. Remove the misleading `currentAgentToken` parameter (unused — confirmed by reading `RefreshCoreAsync`). + +`src/AAuth/Agent/AgentProviderTokenRefresher.cs` +* Rename ctor / builder / field parameters `enrolledKeyId` → `localKeyHandle`. +* Update XML docs to use the new "AP and agent never share a keystore" language. + +### Definition of Done + +- [x] `EnrollResult.EnrolledKeyId` renamed to `LocalKeyHandle` +- [x] `EnrollResult.AgentTokenKid` added (nullable) +- [x] `EnrolAsync` uses `key.ComputeJwkThumbprint()` as local handle +- [x] Two `RefreshAsync` overloads collapsed to one (removed `currentAgentToken`) +- [x] `AgentProviderTokenRefresher` parameters renamed +- [x] XML docs updated with keystore-separation language +- [x] `dotnet build` passes + +## Phase 2 — Tests + +`tests/AAuth.Tests/Agent/AgentProviderTokenRefresherTests.cs` +* Rename `Constructor_ThrowsOnEmptyEnrolledKeyId` → `Constructor_ThrowsOnEmptyLocalKeyHandle`. +* Update any other affected assertions/identifiers. + +(Sweep for any other test files touching `EnrolledKeyId`.) + +### Definition of Done + +- [x] Test method renamed +- [x] `dotnet test` passes + +## Phase 3 — Samples + +| File | Change | +|------|--------| +| `samples/AgentConsole/Program.cs` | `result.EnrolledKeyId` → `result.LocalKeyHandle` | +| `samples/SampleApp/EnrollmentService.cs` | property + field rename; update comments | +| `samples/SampleApp/Components/Pages/JwksUri.razor` | property usage + UI label | +| `samples/SampleApp/Components/Pages/Jwt.razor` | property usage | +| `samples/GuidedTour/CodeSnippets.cs` | property usage + comments | +| `samples/Orchestrator/Program.cs` | commented snippet only | + +Leave `samples/MockAgentProvider/appsettings.json` (`AgentProvider:KeyId = ap-key-1`) alone — that is the AP's **own** signing-key id, a different concept. + +### Definition of Done + +- [x] All samples compile +- [x] `result.EnrolledKeyId` references replaced with `result.LocalKeyHandle` +- [x] AgentConsole persists/restores `AgentTokenKid` in enrollment cache +- [x] AgentConsole variable renamed from `keyId` to `localKeyHandle` (done in Phase 5b) +- [x] `JwksUri.razor` uses `AgentTokenKid` for `UseJwksUri` kid param — throws if null per spec (done in Phase 5a) + +## Phase 4 — Docs + +Primary rewrite: +* `docs/workflows/bootstrap-enrollment.md` + * Code snippets: `enrol.EnrolledKeyId` → `enrol.LocalKeyHandle`; `AAuth:KeyId` → `AAuth:LocalKeyHandle`. + * "What Bootstrap Produces" — split the `key_id` bullet into "A local key handle (agent-chosen, defaults to the durable key's JWK thumbprint)" and "An optional AP-internal JWT `kid` carried inside the agent token (opaque to receivers)". + * "Token Refresh" → "AP-Enrolled Agents" — call out that the AP identifies the agent from the signature alone; the handle is purely a local `IKeyStore` lookup. + * "Key IDs: What Goes Where" — restructure into three rows for the three identifiers (JWK thumbprint / AP-internal JWT `kid` / local keystore handle) instead of two scenarios. + * Update the mermaid "AP-Enrolled: Key ID Flow" diagram to relabel "Agent Provider assigns key_id" → "Agent Provider records public key by JWK thumbprint" and the local-store node to "stores private key under local handle (defaults to thumbprint)". + +Touch-ups (rename string `EnrolledKeyId` and `AAuth:KeyId` → `AAuth:LocalKeyHandle`, with a one-line clarification where the doc mentions the AP): +* `docs/getting-started.md` +* `docs/reference/dependency-injection.md` +* `docs/signing-modes/agent-token-jwt.md` +* `docs/workflows/call-chaining.md` +* `docs/workflows/deferred-consent.md` +* `docs/workflows/federated-access.md` +* `docs/workflows/identity-based-access.md` +* `docs/workflows/ps-asserted-access.md` +* `docs/workflows/resource-managed-access.md` + +Smaller insertion: +* `docs/concepts.md` — add a one-line note in the Agent Provider row that AP and agent share only public keying material; the agent's private key never leaves the agent's `IKeyStore`. + +### Definition of Done + +- [x] `bootstrap-enrollment.md` rewritten with three-identifier table +- [x] All workflow docs updated with `AAuth:LocalKeyHandle` +- [x] `concepts.md` clarification added +- [x] `getting-started.md` updated +- [x] `dependency-injection.md` updated +- [x] `docs/signing-modes/agent-identity-jwks-uri.md` clarified (done in Phase 5f) +- [x] `docs/signing-modes/overview.md` clarified (done in Phase 5f) +- [x] `docs/advanced/key-management.md` clarified (done in Phase 5f) + +## Phase 5 — Review findings fix-up + +> **Added 2026-05-27** after PR #22 review. Addresses bugs and gaps identified during spec-alignment review and automated PR review comments. + +### 5a. Fix `jwks_uri` kid bug in samples + +The `UseJwksUri(url, kid)` call must pass the AP-published JWKS `kid` (from `AgentTokenKid`), not the local keystore handle. The local handle defaults to the JWK thumbprint, which doesn't match the AP's published `kid` in the JWKS — causing `unknown_key` errors at the resource. + +| File | Fix | +|------|-----| +| `samples/SampleApp/Components/Pages/JwksUri.razor` | Change `var keyId = _enrollment.LocalKeyHandle` → use `_enrollment.AgentTokenKid ?? _enrollment.LocalKeyHandle` as the kid param in `UseJwksUri` | +| `samples/GuidedTour/CodeSnippets.cs` (`SignedGetJwksUri`) | Change `result.LocalKeyHandle` → `result.AgentTokenKid ?? result.LocalKeyHandle` | + +### 5b. Complete variable rename in `AgentConsole/Program.cs` + +Rename the `string keyId` variable to `string localKeyHandle` (and all its usages). The current name reintroduces the identifier confusion this PR eliminates. + +### 5c. Complete snippet rename in `GuidedTour/CodeSnippets.cs` + +The `SignedGetJwt`, `TokenExchangeDirect`, and `FullAutomatic` string constants still reference `keyId`. Update to `localKeyHandle`: +* Line 73: `AgentProviderTokenRefresher.Create(refreshEndpoint, keyId)` → `localKeyHandle` +* Line 112: same pattern +* Line 214: `var key = await keyStore.LoadAsync(keyId)` → `localKeyHandle` +* Line 216: same pattern as line 73 + +### 5d. Fix `Jwt.razor` illustrative HTML code + +The `
` block (line ~38) shows `RefreshAsync(refreshEndpoint, keyId, ct)`. Update to `localKeyHandle`.
+
+### 5e. Clarify `AgentTokenKid` documentation
+
+> **Update (2026-05-27):** PR review identified that `AgentTokenKid` is documented as "diagnostic only" but is actually **required** for `jwks_uri` mode (it's the kid the AP publishes in its per-agent JWKS). Update the XML doc to:
+> "AP-published key identifier returned in the enrollment response. Required as the `kid` parameter for `UseJwksUri` when using `jwks_uri` signing mode. For other signing modes (hwk, jwt, jkt-jwt), this value is informational only."
+
+### 5f. Doc gaps — signing-modes and key-management clarifications
+
+| File | Change |
+|------|--------|
+| `docs/signing-modes/agent-identity-jwks-uri.md` | Add comment to AP-enrolled example: `// "my-key-1" is the AP-published kid (EnrollResult.AgentTokenKid), not the local key handle` |
+| `docs/signing-modes/overview.md` | Add comment to `jwks_uri` line: `// kid = AP-published JWKS kid (AgentTokenKid) or self-chosen kid for self-hosted` |
+| `docs/advanced/key-management.md` | Add a paragraph in the Overview or after `IKeyStore` interface noting that for AP-enrolled agents the `keyId` parameter is the `LocalKeyHandle` (JWK thumbprint by default) — not an AP-assigned identifier |
+
+### 5g. Sweep top-level `README.md`
+
+Check for any enrollment code snippets using `EnrolledKeyId` or `AAuth:KeyId`. Update if found.
+
+### Definition of Done
+
+- [x] `JwksUri.razor` uses `AgentTokenKid` for kid param (throws if null — no fallback per spec)
+- [x] `GuidedTour/CodeSnippets.cs` `SignedGetJwksUri` uses `AgentTokenKid` (throws if null)
+- [x] `GuidedTour/CodeSnippets.cs` remaining snippets use `localKeyHandle` variable
+- [x] `AgentConsole/Program.cs` variable renamed from `keyId` to `localKeyHandle`
+- [x] `Jwt.razor` illustrative HTML updated
+- [x] `EnrollResult.AgentTokenKid` XML doc updated (required for jwks_uri, not "diagnostic only")
+- [x] `docs/signing-modes/agent-identity-jwks-uri.md` comment added
+- [x] `docs/signing-modes/overview.md` comment added
+- [x] `docs/advanced/key-management.md` paragraph added
+- [x] `README.md` swept — no stale enrollment references
+- [x] `dotnet build` passes
+- [x] All four AgentConsole signing modes verified against mock servers (hwk ✓ jwks_uri ✓; jwt/jkt-jwt need PS flow)
+- [x] AgentConsole auto-routes to `/hwk` or `/jwks-uri` when target URL has no path
+
+## Phase 6 — SDK improvements (formerly out of scope)
+
+> **Added 2026-05-27.** These items were originally out of scope but pulled in to complete the identifier cleanup in one pass.
+
+### 6a. Rename `IKeyStore` parameter from `keyId` to `handle`
+
+The `IKeyStore` interface uses `string keyId` as the parameter name in `LoadAsync`, `StoreAsync`, and `DeleteAsync`. After the rename PR the semantic is clearly "a local handle/name" — rename the parameter to `handle` to complete the cleanup.
+
+| File | Change |
+|------|--------|
+| `src/AAuth/Crypto/IKeyStore.cs` | Rename parameter `keyId` → `handle` in `LoadAsync`, `StoreAsync`, `DeleteAsync` |
+| `src/AAuth/Crypto/FileKeyStore.cs` | Update parameter names to match interface |
+| `src/AAuth/Crypto/InMemoryKeyStore.cs` | Update parameter names to match interface |
+| `docs/advanced/key-management.md` | Update interface listing and examples |
+
+### 6b. Add `AAuthClientBuilder.From(EnrollResult)` convenience API
+
+Every sample after enrollment manually extracts fields and wires them together. Add a static factory that wires `LocalKeyHandle`, `AgentTokenKid`, `JwksUri`, and `Key` from an `EnrollResult`.
+
+```csharp
+// Proposed API
+public static AAuthClientBuilder From(EnrollResult result, IKeyStore keyStore)
+```
+
+The builder should pre-configure:
+* `Key` from `result.Key`
+* If `result.JwksUri` is set: `UseJwksUri(result.JwksUri, result.AgentTokenKid ?? result.LocalKeyHandle)`
+
+Callers still chain `.WithTokenRefresh(...)` and `.WithChallengeHandling(...)` as needed.
+
+### 6c. Two-key (`jkt-jwt`) refresh — SDK side
+
+The SDK currently only implements single-key (`hwk`) refresh. Add `jkt-jwt` refresh support:
+
+| File | Change |
+|------|--------|
+| `src/AAuth/Agent/NamingJwtBuilder.cs` | **New file.** Internal helper that creates a naming JWT signed by the durable key, embedding the ephemeral key's public half as `cnf.jwk`. Claims: `iss` (AP URL), `iat`, `exp` (+5 min), `jti`, `cnf.jwk`. Header: `alg=EdDSA`, `typ=naming+jwt`, `kid` = durable key thumbprint. |
+| `src/AAuth/Agent/AgentProviderClient.cs` | Add `RefreshTwoKeyAsync(refreshEndpoint, localKeyHandle, apIssuer, ct)` → `TwoKeyRefreshResult`. Generates ephemeral key, builds naming JWT, signs refresh POST with ephemeral key under `jkt-jwt` scheme, returns `{ AgentToken, EphemeralKey }`. |
+| `src/AAuth/Agent/AgentProviderClient.cs` | Add `TwoKeyRefreshResult` record: `AgentToken` (string) + `EphemeralKey` (AAuthKey). |
+| `src/AAuth/Agent/AgentProviderTokenRefresher.cs` | Add `RefreshMode` enum (`SingleKey` / `TwoKey`). Add `.WithRefreshMode(RefreshMode)` to `RefresherBuilder`. When `TwoKey`, call `RefreshTwoKeyAsync` and update the signing key in the handler. |
+
+### 6d. Two-key (`jkt-jwt`) refresh — MockAP side
+
+The MockAgentProvider's `/refresh` endpoint currently only accepts `scheme=hwk`. Extend it to also accept `scheme=jkt-jwt`:
+
+| File | Change |
+|------|--------|
+| `samples/MockAgentProvider/Program.cs` | In `/refresh` handler: if `parsedKey.Scheme == "jkt-jwt"`, parse the naming JWT, extract `cnf.jwk` (ephemeral key), verify naming JWT signature against enrolled durable key (looked up by durable key thumbprint from JWT `kid`), verify HTTP signature against ephemeral key, issue new agent token with `ConfirmationKey = ephemeralKey`. |
+
+### 6e. AgentConsole — wire two-key refresh
+
+| File | Change |
+|------|--------|
+| `samples/AgentConsole/Program.cs` | In `case "jkt-jwt"`: switch from single-key `RefreshAsync` to the new `RefreshTwoKeyAsync`-based flow, using the refresher builder with `.WithRefreshMode(RefreshMode.TwoKey)`. |
+
+### Definition of Done
+
+- [x] `IKeyStore` parameter renamed from `keyId` to `handle`
+- [x] `FileKeyStore` and `InMemoryKeyStore` parameter names updated
+- [x] `AAuthClientBuilder.From(EnrollResult)` implemented
+- [x] At least one sample updated to use `AAuthClientBuilder.From()`
+- [x] `NamingJwtBuilder` implemented (internal helper)
+- [x] `RefreshTwoKeyAsync` implemented in `AgentProviderClient`
+- [x] `TwoKeyRefreshResult` type added
+- [x] `AgentProviderTokenRefresher` supports `RefreshMode.TwoKey`
+- [x] MockAP `/refresh` accepts `jkt-jwt` scheme
+- [x] AgentConsole `jkt-jwt` mode uses two-key refresh
+- [x] `dotnet build` passes
+- [x] `dotnet test` passes
+- [x] AgentConsole `--signing-mode jkt-jwt` returns 200 against mock servers
+
+## Phase 7 — jkt-jwt resource endpoint and sample routing
+
+> **Added 2026-05-27.** Spec review revealed jkt-jwt is pseudonymous (2-party) like hwk, not three-party like jwt. All samples incorrectly routed jkt-jwt to the three-party `GET /` endpoint which requires issuer verification — failing with 401 because naming JWTs are not agent tokens. Additionally, GuidedTour was passing the agent token (not the naming JWT) to `UseJktJwt()`.
+
+### 7a. Dedicated `/jkt-jwt` endpoint on WhoAmI
+
+| File | Change |
+|------|--------|
+| `samples/WhoAmI/Program.cs` | Add `UseWhen` middleware branch for `/jkt-jwt` with `RequireIssuerVerification = false` (same pattern as `/hwk`) |
+| `samples/WhoAmI/Program.cs` | Add `app.MapGet("/jkt-jwt", ...)` endpoint returning `{ mode, scheme, jkt, note }` |
+| `samples/WhoAmI/Program.cs` | Exclude `/jkt-jwt` from three-party catch-all middleware (alongside `/hwk`) |
+
+### 7b. Fix sample routing — all samples target `/jkt-jwt`
+
+| File | Change |
+|------|--------|
+| `samples/AgentConsole/Program.cs` | Route jkt-jwt to `/jkt-jwt` in the target-URL path switch |
+| `samples/GuidedTour/TourSession.cs` | `EffectiveResourceUrl` for `SigningMode.JktJwt` → append `/jkt-jwt` |
+| `samples/SampleApp/Components/Pages/JktJwt.razor` | Send request to `resourceUrl + "/jkt-jwt"` |
+
+### 7c. Fix GuidedTour naming JWT construction
+
+| File | Change |
+|------|--------|
+| `samples/GuidedTour/TourSession.cs` | Add `_ephemeralKey` field; in `BuildSigningHandler` JktJwt case: generate ephemeral key, build naming JWT via `NamingJwtBuilder.Build(agentKey, ephemeralKey, apIssuer, durableThumbprint)`, use `AAuthClientBuilder(ephemeralKey).UseJktJwt(() => namingJwt)` |
+| `samples/GuidedTour/TourSession.cs` | Clear `_ephemeralKey` in `Reset()` |
+
+### 7d. SampleApp JktJwt page
+
+| File | Change |
+|------|--------|
+| `samples/SampleApp/Components/Pages/JktJwt.razor` | **New file.** Blazor page demonstrating 3-step jkt-jwt flow (Enrol → TwoKeyRefresh → SendRequest). Content separates "Setup (agent ↔ AP)" from "Resource access (2-party, no AP)". |
+| `samples/SampleApp/Components/Layout/NavMenu.razor` | Add jkt-jwt nav link |
+| `samples/SampleApp/Components/Layout/NavMenu.razor.css` | Add `.bi-arrow-repeat-nav-menu` CSS class |
+| `samples/SampleApp/Components/Pages/Home.razor` | Add jkt-jwt card (badge: 2-party, sig=jkt-jwt, two-key) |
+
+### 7e. Fix AgentConsole `--ps` validation
+
+| File | Change |
+|------|--------|
+| `samples/AgentConsole/Program.cs` | Remove jkt-jwt from PS-required check. Only `jwt` mode requires `--ps`. Update error messages to list jkt-jwt as a pseudonymous mode alongside hwk/jwks_uri. |
+
+### Definition of Done
+
+- [x] WhoAmI has dedicated `/jkt-jwt` endpoint with `RequireIssuerVerification = false`
+- [x] `/jkt-jwt` excluded from three-party catch-all middleware
+- [x] AgentConsole routes jkt-jwt to `/jkt-jwt`
+- [x] GuidedTour routes jkt-jwt to `/jkt-jwt`
+- [x] GuidedTour generates ephemeral key and proper naming JWT in `BuildSigningHandler`
+- [x] SampleApp `JktJwt.razor` page created with 3-step flow
+- [x] SampleApp NavMenu, Home page, and CSS wired
+- [x] JktJwt.razor content correctly separates AP setup from 2-party resource access
+- [x] AgentConsole `--ps` validation fixed (only `jwt` requires PS)
+- [x] `dotnet build` passes (0 errors, 0 warnings)
+- [x] `dotnet test` passes (571 tests, 0 failures)
+- [x] AgentConsole `--signing-mode jkt-jwt` returns 200 against mock servers (smoke tested)
+
+## Phase 8 — Naming JWT security hardening
+
+> **Added 2026-05-27.** Implements two items previously out of scope: naming JWT `exp` validation and `jti` replay detection at the resource.
+
+### 8a. Naming JWT expiration validation (middleware)
+
+| File | Change |
+|------|--------|
+| `src/AAuth/Server/AAuthVerificationMiddleware.cs` | After replay detection block, add `exp` validation for `jkt-jwt` scheme: parse `exp` claim, compare against `_options.Clock` + `_options.ClockSkew`, return 401 with `SignatureError.InvalidJwt` if expired |
+| `src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs` | Remove the resolver-level `exp` check (was using `DateTimeOffset.UtcNow`, not clock-aware). Add comment noting exp is validated by the middleware |
+
+### 8b. jti replay detection for naming JWTs (WhoAmI sample)
+
+| File | Change |
+|------|--------|
+| `samples/WhoAmI/Program.cs` | Register `InMemoryJtiStore` singleton after `TokenVerifier` — enables the existing middleware jti replay detection for all endpoints |
+
+### 8c. Tests
+
+| File | Change |
+|------|--------|
+| `tests/AAuth.Conformance/HttpSignatures/NamingJwtValidationTests.cs` | **New file.** Five tests: valid exp succeeds, expired exp returns 401, expired-within-clock-skew succeeds, duplicate jti rejected, different jti values both succeed |
+| `tests/AAuth.Conformance/HttpSignatures/JktJwtAndEcdsaTests.cs` | Update `JktJwtExpiredNamingJwtRejected` → `JktJwtExpiredNamingJwt_ResolverStillReturnsKey` (resolver no longer throws on expired JWT — middleware handles it) |
+
+### Definition of Done
+
+- [x] Middleware validates naming JWT `exp` with clock-skew support
+- [x] Resolver no longer checks `exp` (avoids `DateTimeOffset.UtcNow` in non-clock-aware code)
+- [x] WhoAmI registers `InMemoryJtiStore` for replay detection
+- [x] `NamingJwtValidationTests.cs` — 5 tests passing
+- [x] `JktJwtAndEcdsaTests.cs` — updated test passes (resolver returns key even for expired JWT)
+- [x] `dotnet build` passes (0 errors, 0 warnings)
+- [x] `dotnet test` passes (576 tests, 0 failures)
+
+## Validation
+
+* `dotnet build` and `dotnet test` from the repo root (576 tests, 0 failures).
+* Visual review of bootstrap-enrollment.md.
+* All four AgentConsole signing modes (`hwk`, `jwks_uri`, `jwt`, `jkt-jwt`) return 200 against mock servers.
+* jkt-jwt works without `--ps` (pseudonymous 2-party access, no Person Server required).
+* Naming JWT exp validation rejects expired tokens, accepts within clock skew.
+* jti replay detection rejects duplicate naming JWTs.
+
+## Commit / PR shape
+
+Single PR (alpha repo, all changes are coupled):
+
+1. SDK rename + thumbprint default + XML doc clarifications.
+2. Test updates.
+3. Sample updates.
+4. Documentation updates.
+5. Review fix-ups (kid bug, variable naming, doc gaps).
+6. SDK improvements (IKeyStore rename, two-key refresh, convenience API).
+7. jkt-jwt resource endpoint + sample routing + PS validation fix.
+8. Naming JWT security hardening (exp validation + jti replay detection + tests).
+
+PR title: "Rename EnrolledKeyId → LocalKeyHandle; clarify AP enrollment key identifiers"
+
+## Out of Scope
+
+| Item | Reason |
+|------|--------|
+| AP-side identifier policy in MockAgentProvider | Already does correct thumbprint-based lookup at refresh — no change needed |
+| Stale naming JWT refresh in long-running agents | AgentConsole is a single-request demo; long-lived agents need periodic ephemeral key + naming JWT rotation. Documented as TODO in code. |
diff --git a/.agent/plans/2026-05-27-ap-enrollment-key-naming/research.md b/.agent/plans/2026-05-27-ap-enrollment-key-naming/research.md
new file mode 100644
index 0000000..99051be
--- /dev/null
+++ b/.agent/plans/2026-05-27-ap-enrollment-key-naming/research.md
@@ -0,0 +1,367 @@
+---
+title: "AP Enrollment Key Naming — Research"
+description: Spec alignment review of how the SDK names and uses the durable key identifier produced by Agent Provider enrollment, and how it flows into refresh.
+ms.date: 2026-05-27
+---
+
+## Problem Statement
+
+The current SDK surfaces a single `EnrolledKeyId` value out of `AgentProviderClient.EnrolAsync` and threads it through `AgentProviderTokenRefresher`, `IKeyStore`, and `appsettings.json`. The name implies "an identifier shared with the AP that the AP uses to recognize this agent at refresh," which is not what the spec says and is not what the code actually does. The conflation invites readers to assume the agent and the AP share a keystore. They do not.
+
+## What the spec actually says
+
+Source: `aauth-spec/draft-hardt-aauth-bootstrap.md` (the bootstrap draft is the authoritative reference for AP enrollment and refresh).
+
+There are **three distinct identifiers** in play around enrollment / refresh; the spec keeps them disjoint:
+
+| # | Identifier | Owner / origin | Where it travels | Purpose |
+|---|------------|----------------|------------------|---------|
+| A | **JWK thumbprint of the durable key** (RFC 7638) | Derived from the public key | Implicit on every signed request | The AP uses this to look up the enrollment record (§ "Refresh Patterns", lines 284, 305) |
+| B | **JWT `kid` header** on the issued `aa-agent+jwt` | AP chooses (opaque string) | Inside the issued token | AP-internal; "Receivers treat the identifier as opaque" (§ "Agent Identifier Strategies", line 240). It is **not** an identifier the agent ever needs to send back to the AP. |
+| C | **Local handle** for the durable private key inside the agent's `IKeyStore` | Agent chooses | Never leaves the agent process | Used by `IKeyStore.LoadAsync(handle)` at app startup |
+
+Key quotes from the spec:
+
+* §6 Single-Key Refresh: "APs ... sign the refresh request directly with the durable key under the `hwk` scheme. The AP verifies the signature, **looks up the enrollment by the key's thumbprint**, and issues a fresh agent token."
+* §6 Two-Key Refresh: "The AP verifies the durable-key signature on the naming JWT, **looks up the enrollment by the durable key's thumbprint**, ..."
+* §5 Identifier Strategies: "APs are free to choose any opaque scheme for the local part: ... a deterministic derivation from the durable key's thumbprint ... Receivers treat the identifier as opaque."
+
+**Conclusion:** the spec permits — and explicitly endorses — deriving identifiers from the durable key's JWK thumbprint, and it treats any AP-returned string as opaque. The agent never needs to send back an enrollment identifier to refresh; the signature does the identification.
+
+## What the SDK does today
+
+`src/AAuth/Agent/AgentProviderClient.cs`:
+
+```csharp
+var key   = AAuthKey.Generate();
+var keyId = $"{agentId}:{Guid.NewGuid():N}";          // (1) locally fabricated
+...
+var assignedKeyId = (string?)body["key_id"] ?? keyId; // (2) prefers AP's response
+await _keyStore.StoreAsync(assignedKeyId, key, ct);   // (3) used as local handle
+return new EnrollResult { ..., EnrolledKeyId = assignedKeyId, ... };
+```
+
+`AgentProviderTokenRefresher` then takes the same string back and does `keyStore.LoadAsync(enrolledKeyId)` (no value of that string is ever sent to the AP — confirmed in `RefreshCoreAsync`).
+
+`samples/MockAgentProvider/Program.cs` confirms the AP-side behavior matches the spec: refresh ignores any client-supplied identifier and looks up the agent purely by JWK thumbprint (lines 192-196).
+
+### Why this is confusing
+
+1. **`EnrolledKeyId` name lies about ownership.** It sounds like an AP-issued identifier the AP also uses. In reality:
+   - The AP-returned `key_id` is the JWT `kid` header (identifier **B**), which is opaque to everyone except the AP and is never required for refresh.
+   - The value the agent persists is just a *local* handle for `IKeyStore` (identifier **C**).
+2. **One name covers three jobs.** The SDK uses the same string for the JWT `kid`, the local keystore handle, and the human-readable config value. Conflating them suggests a shared keystore that does not exist.
+3. **`{agentId}:{Guid.NewGuid():N}` fallback is misleading.** When the AP doesn't return `key_id`, the SDK fabricates a string that *looks* like an AP-assigned identifier. It is purely local.
+4. **Docs reinforce the mismatch.** `docs/workflows/bootstrap-enrollment.md` table caption "AP-enrolled — Key ID value: `aauth:myapp@ap.example:c34078382e` — Who assigns it: Agent Provider" reads as if this string is meaningful to the AP at refresh. It is not.
+
+## Is "use the JWK thumbprint" spec-compliant?
+
+Yes. The spec is explicit (§5, line 240) that derivation from the durable key's thumbprint is one of the listed acceptable strategies, and that receivers treat identifiers as opaque. Using the thumbprint as the **local handle** is purely a client-side choice and the spec has nothing to say about it (it never leaves the agent). Using the thumbprint as the **JWT `kid`** the AP puts inside the issued token is an AP policy decision; our MockAP currently uses a `{agentId}:{guid}` form, which is fine — that's the AP's `kid` (identifier B), opaque to the agent.
+
+## SDK design options for the local handle
+
+| Option | Pros | Cons |
+|--------|------|------|
+| **Use the durable key's JWK thumbprint** (RFC 7638, base64url SHA-256) | Stable, collision-free, derivable any time from the key itself, matches the value the AP uses internally, no need to fabricate anything | A bit opaque if a human is reading their `appsettings.json` |
+| Fabricate `{agentId}:{guid}` locally | Slightly more human-readable | Looks like an AP-assigned ID, but isn't; non-deterministic |
+| Use the AP's response `key_id` verbatim | Matches what's printed in AP logs | Conflates two unrelated identifiers; breaks if the AP doesn't return one |
+
+**Decision:** default the local handle to the durable key's JWK thumbprint. Drop the fabricated `{agentId}:{guid}` fallback. Allow callers to override the handle if they want a friendlier name. Expose any AP-returned `key_id` separately (as `AgentTokenKid`) for diagnostics — clearly labelled as AP-internal and opaque.
+
+## Affected types and members
+
+SDK (`src/AAuth/Agent/`):
+* `EnrollResult.EnrolledKeyId` → `LocalKeyHandle`; add `AgentTokenKid` (nullable).
+* `AgentProviderClient.EnrolAsync` — pick handle = `key.ComputeJwkThumbprint()`; record AP `key_id` separately.
+* `AgentProviderClient.RefreshAsync(refreshEndpoint, currentAgentToken, enrolledKeyId, ct)` — `currentAgentToken` param is unused and misleading; collapse to a single `RefreshAsync(refreshEndpoint, localKeyHandle, ct)` overload.
+* `AgentProviderTokenRefresher.Create(refreshEndpoint, enrolledKeyId)` — rename parameter to `localKeyHandle`; update XML docs.
+
+Tests:
+* `tests/AAuth.Tests/Agent/AgentProviderTokenRefresherTests.cs`
+
+Samples (string `EnrolledKeyId` / config key `AAuth:KeyId`):
+* `samples/AgentConsole/Program.cs`
+* `samples/SampleApp/EnrollmentService.cs`
+* `samples/SampleApp/Components/Pages/JwksUri.razor`
+* `samples/SampleApp/Components/Pages/Jwt.razor`
+* `samples/GuidedTour/CodeSnippets.cs`
+* `samples/Orchestrator/Program.cs` (comment only)
+
+(The `samples/MockAgentProvider/appsettings.json` `KeyId: ap-key-1` is the AP's **own** signing-key id — a different concept, unrelated to enrollment. Leave it alone.)
+
+Docs:
+* `docs/workflows/bootstrap-enrollment.md` (primary surface — rewrite Key IDs table and diagrams)
+* `docs/getting-started.md`
+* `docs/concepts.md` (one line clarifying AP↔agent share only public material)
+* `docs/reference/dependency-injection.md`
+* `docs/signing-modes/agent-token-jwt.md`
+* `docs/workflows/call-chaining.md`
+* `docs/workflows/deferred-consent.md`
+* `docs/workflows/federated-access.md`
+* `docs/workflows/identity-based-access.md`
+* `docs/workflows/ps-asserted-access.md`
+* `docs/workflows/resource-managed-access.md`
+* Top-level `README.md` (sweep for enrollment snippets)
+
+## Open questions resolved by the user
+
+* **Rename to `LocalKeyHandle`** — confirmed.
+* **Use JWK thumbprint as the default local handle** — confirmed spec-compliant; adopt.
+* **Breaking changes acceptable** — yes (alpha); no `[Obsolete]` shim required.
+
+## Out of scope
+
+> **Update (2026-05-27):** Two-key (`jkt-jwt`) refresh was pulled into scope as Phase 6c–6e. Resource-side jkt-jwt endpoint and sample routing were added as Phase 7.
+
+* ~~Two-key (`jkt-jwt`) refresh: the SDK only implements single-key (`hwk`) refresh today; renaming is orthogonal to that work.~~ → **Done in Phase 6.**
+* AP-side identifier policy in the MockAgentProvider (it already does the correct thumbprint-based lookup at refresh).
+
+---
+
+## Spec Reference Excerpts (for review validation)
+
+### From `draft-hardt-aauth-bootstrap.md` — § Refresh Patterns
+
+> Agent token lifetime is the AP's policy re-evaluation cadence — every refresh is the AP's chance to re-check device posture, attestation freshness, and account status before issuing a new token.
+
+> **Single-Key Refresh:** APs that opt for the single-durable pattern sign the refresh request directly with the durable key under the `hwk` scheme. **The AP verifies the signature, looks up the enrollment by the key's thumbprint**, and issues a fresh agent token with a new `exp`. The same `cnf.jwk` is carried through; the agent's key is unchanged.
+
+> **Two-Key Refresh:** The agent constructs a JWT signed by the **durable key**, naming the new ephemeral public key. [...] The AP verifies the durable-key signature on the naming JWT, **looks up the enrollment by the durable key's thumbprint**, verifies the HTTP signature against the ephemeral public key [...]
+
+### From `draft-hardt-aauth-bootstrap.md` — § Agent Identifier Strategies
+
+> APs are free to choose any opaque scheme for the local part: a random string assigned at enrollment, **a deterministic derivation from the durable key's thumbprint**, a sequential identifier, or a human-readable handle. When deriving from a thumbprint, use the durable key's thumbprint — the ephemeral key rotates on each refresh and is not a stable identifier. **Receivers treat the identifier as opaque.**
+
+### From `draft-hardt-aauth-bootstrap.md` — § Self-Hosted Agents
+
+> A self-hosted agent runs under a domain the user controls. [...] Self-hosted agents act as their own AP — they self-issue agent tokens signed by the JWKS-published key. There is no separate AP to refresh against, so the two-key pattern does not apply: the JWKS-published key serves both as the AP signing key (signing self-issued agent tokens) and as the key whose public part appears in `agent_token.cnf.jwk` (signing HTTP messages).
+
+### From `draft-hardt-oauth-aauth-protocol.md` — § Agent Provider Metadata
+
+> `jwks_uri` (REQUIRED): URL to the agent provider's JSON Web Key Set
+
+### From `draft-hardt-aauth-bootstrap.md` — § Example Agent Token Claims
+
+> JWT header: `{ "alg": "EdDSA", "typ": "aa-agent+jwt", "kid": "..." }`
+>
+> The `kid` in the JWT header is chosen by the AP. It is the AP's internal reference for key selection within its published JWKS. **Receivers use it to select the verification key from the AP's `jwks_uri`** — they do not interpret its content.
+
+---
+
+## Critical Review Findings (PR analysis)
+
+> **Note:** PR #22 review comments from `copilot-pull-request-reviewer` independently flagged findings 1–4 below, confirming these are real issues rather than false positives.
+
+### Finding 1: `jwks_uri` mode `kid` parameter inconsistency (BUG — flagged in PR review)
+
+The `AgentConsole/Program.cs` correctly uses:
+```csharp
+builder.UseJwksUri(jwksUrl, agentTokenKid ?? keyId);
+```
+
+But `SampleApp/Components/Pages/JwksUri.razor` (line ~148) and `GuidedTour/CodeSnippets.cs` (line 63) pass:
+```csharp
+.UseJwksUri(jwksUri, result.LocalKeyHandle)   // GuidedTour
+.UseJwksUri(jwksUri, keyId)                   // JwksUri.razor (keyId = _enrollment.LocalKeyHandle)
+```
+
+**Problem:** In `jwks_uri` mode the receiver fetches the JWKS and selects the key by `kid`. The AP publishes the JWK with whatever `kid` it chose (the value in `AgentTokenKid`). The `LocalKeyHandle` defaults to the JWK thumbprint — which may not match the AP's published `kid`. These samples will fail JWKS key lookup unless the AP happens to use the thumbprint as its `kid`.
+
+**PR reviewer comment:** "UseJwksUri expects the kid of the key inside the JWKS, not the local keystore handle. With the existing MockAgentProvider, the per-agent JWKS publishes record.KeyId from the enrollment response as kid, while LocalKeyHandle now defaults to the JWK thumbprint, so this sample will emit a kid that the verifier cannot resolve."
+
+**Fix needed:** `JwksUri.razor` and `GuidedTour/CodeSnippets.cs` should use `AgentTokenKid ?? LocalKeyHandle`, matching the AgentConsole pattern.
+
+### Finding 2: `Jwt.razor` HTML code block still shows old `keyId` variable
+
+The `
` illustrative snippet in `Jwt.razor` (line ~38) shows:
+```csharp
+return await ap.RefreshAsync(refreshEndpoint, keyId, ct);
+```
+
+Should be `localKeyHandle` for consistency with the rename. This is display-only HTML (not compiled) but readers will copy-paste it.
+
+### Finding 3: `GuidedTour/CodeSnippets.cs` — lines 73, 112, 214, 216 still use `keyId` (flagged in PR review)
+
+The `SignedGetJwt`, `TokenExchangeDirect`, and `FullAutomatic` snippets still reference `keyId` in:
+```csharp
+AgentProviderTokenRefresher.Create(refreshEndpoint, keyId)
+var key = await keyStore.LoadAsync(keyId);
+```
+
+These are illustrative string constants, but they should use `localKeyHandle` for consistency. The plan said to update CodeSnippets.cs but only partially addressed it.
+
+**PR reviewer comment:** "This snippet is only partially renamed: it tells users to persist enrol.LocalKeyHandle, but the startup section below still loads keyId and passes keyId to AgentProviderTokenRefresher.Create. As written, users copying the example have no declared variable matching the persisted handle; update the startup snippet/comment to use localKeyHandle consistently."
+
+### Finding 4: `AgentConsole/Program.cs` — variable still named `keyId` (flagged in PR review)
+
+After the PR, `AgentConsole/Program.cs` still declares `string keyId` and assigns `result.LocalKeyHandle` to it. The variable name `keyId` reintroduces the confusion this PR is eliminating. Should be `localKeyHandle` or `keyHandle`.
+
+**PR reviewer comment:** "keyId is also used as the kid for UseJwksUri later in this file, but LocalKeyHandle is only a local keystore handle and no longer necessarily matches the key id published in the AP's per-agent JWKS."
+
+Note: The final commit (e7783fe) partially addressed this by using `agentTokenKid ?? keyId` for the `UseJwksUri` call, but the variable name itself is still confusing.
+
+### Finding 5: `EnrollResult.AgentTokenKid` naming/documentation is misleading (flagged in PR review)
+
+**PR reviewer comment:** "The in-repo MockAgentProvider does not use enrollment key_id as the issued agent token's JWT kid: the token header kid is the AP signing key id, while the response key_id is published as the per-agent JWKS key id. Documenting this value as an agent-token kid and diagnostic-only leaves AP-enrolled callers without a correctly named value to pass to UseJwksUri alongside JwksUri."
+
+**Problem:** The `AgentTokenKid` property is described as "diagnostic only" but it's actually **required** for `jwks_uri` mode to work. The property should be named something like `JwksKid` or `ApPublishedKid` and documented as the key identifier needed for `UseJwksUri`. Calling it "diagnostic-only" invites callers to ignore it, causing the bug in Finding 1.
+
+### Finding 6: Implementation plan lacks Definition of Done checklists (flagged in PR review)
+
+**PR reviewer comment:** "This implementation plan defines phases but does not include Definition of Done checklists at the end of each phase. The repository planning workflow requires each phase in .agent/plans/*/implementation-plan.md to end with - [ ] / - [x] DoD checkboxes."
+
+### Finding 7: `docs/signing-modes/agent-identity-jwks-uri.md` not updated
+
+The plan lists this file's sibling `agent-token-jwt.md` in the touch-up list but **does not mention** `agent-identity-jwks-uri.md`. That file shows:
+```csharp
+.UseJwksUri("https://ap.example/agents/aauth:myapp@ap.example/jwks.json", "my-key-1")
+```
+
+This is fine as an illustrative example, but there's no clarifying comment explaining that `"my-key-1"` is the AP-published `kid` (`AgentTokenKid`), not the local key handle. Given the PR's goal of eliminating identifier confusion, this doc should add a one-line comment.
+
+### Finding 8: `docs/advanced/key-management.md` not updated
+
+This doc defines the `IKeyStore` interface and shows usage like:
+```csharp
+await keyStore.StoreAsync("my-agent-key", AAuthKey.Generate());
+var key = await keyStore.LoadAsync("my-agent-key");
+```
+
+It has no mention of what the `keyId` parameter represents semantically in the enrollment flow. Given the PR's clarification goal, this doc should include a note that for AP-enrolled agents the `keyId` passed to `IKeyStore` is the `LocalKeyHandle` (JWK thumbprint by default) — not an AP-assigned identifier.
+
+### Finding 9: `docs/signing-modes/overview.md` uses generic `kid` without explanation
+
+Line 35 shows:
+```csharp
+"jwks_uri" => new AAuthClientBuilder(key).UseJwksUri(jwksUri, kid).Build(),
+```
+
+No clarification that `kid` here is the AP-published JWT `kid` (for AP-enrolled agents) or a developer-chosen `kid` (for self-hosted). After this PR, this is a source of the same confusion the PR aims to eliminate.
+
+### Finding 10: No `appsettings.json` examples updated
+
+The plan references `AAuth:KeyId` → `AAuth:LocalKeyHandle` config key change, but no actual `appsettings.json` files in the samples directory were updated. Quick check:
+
+```
+samples/AgentConsole/ — no appsettings.json (uses cache file — OK)
+samples/SampleApp/appsettings.json — may contain AAuth section
+```
+
+### Finding 11: Research claims "collapse the two `RefreshAsync` overloads" but the implementation is clean
+
+The research noted the `currentAgentToken` parameter was unused. The implementation correctly removed it and collapsed to one overload. This is **good** — spec-aligned because the spec says the request body is empty and identification is by signature alone.
+
+---
+
+## jkt-jwt Spec Classification and Resource Access Model
+
+> **Update (2026-05-27):** Discovered during Phase 7 (WhoAmI endpoint + sample routing fixes) that the jkt-jwt signing mode was being incorrectly treated as three-party by the samples.
+
+### Key Finding: jkt-jwt is pseudonymous, not three-party
+
+Source: `aauth-spec/draft-hardt-oauth-aauth-protocol.md`, line 2076:
+
+> "For pseudonym: the agent uses scheme=hwk (inline public key) or scheme=jkt-jwt (delegation from a hardware-backed key)."
+
+**jkt-jwt and hwk belong to the same category**: pseudonymous keying material schemes. They are **2-party** at resource access time — the resource verifies the HTTP signature directly without contacting the AP.
+
+| Scheme | Category | Parties at resource access | AP role |
+|--------|----------|---------------------------|---------|
+| `jwt` | Three-party | Agent ↔ AP ↔ Resource | AP issues agent token; resource verifies via AP JWKS |
+| `jwks_uri` | Identity-based | Agent ↔ Resource (AP publishes JWKS) | AP publishes per-agent JWKS; resource fetches it |
+| `hwk` | Pseudonymous | Agent ↔ Resource (2-party) | None at resource access time |
+| `jkt-jwt` | Pseudonymous | Agent ↔ Resource (2-party) | None at resource access time |
+
+### How jkt-jwt resource access works (2-party)
+
+1. Agent generates an ephemeral key pair.
+2. Agent signs a naming JWT with the durable key, embedding the ephemeral public key as `cnf.jwk`.
+3. Agent signs the HTTP request with the ephemeral key.
+4. Agent attaches `Signature-Key: sig=jkt-jwt;jkt="";jwt=""` to the request.
+5. Resource verifies the HTTP signature against the ephemeral key (extracted from `cnf.jwk` in the naming JWT). No AP contact needed.
+6. Resource identifies the agent by the durable key's JWK thumbprint (from the naming JWT's `kid` header).
+
+### How jkt-jwt differs from hwk
+
+- **hwk**: single key signs HTTP requests; resource identifies agent by that key's thumbprint.
+- **jkt-jwt**: two keys — durable key (long-lived, hardware-backed) delegates to ephemeral key (short-lived). Resource still identifies agent by the durable key's thumbprint, but the actual HTTP signature is made by the ephemeral key. This allows hardware keys that can't do per-request signing to delegate to a software ephemeral key.
+
+### Where AP *is* involved for jkt-jwt
+
+The AP is involved only during **enrollment and refresh** (bootstrap phase), not during resource access:
+- **Enrollment**: Agent enrols its durable public key with the AP (same as all modes).
+- **Two-key refresh**: Agent signs the refresh request with the ephemeral key under `jkt-jwt` scheme. AP verifies the naming JWT signature against the enrolled durable key, then issues a fresh agent token.
+
+### Why this matters for WhoAmI routing
+
+The original WhoAmI `GET /` endpoint used `AAuthVerificationMiddleware` which performs **issuer verification** — it contacts the AP to verify the agent token. This is correct for `jwt` mode (three-party) but wrong for `jkt-jwt`:
+- jkt-jwt has no agent token at resource access time.
+- The `Signature-Key` header contains `typ=naming+jwt` (not `aa-agent+jwt`), which the issuer-verifying middleware rejects.
+
+**Solution**: Dedicated `/jkt-jwt` endpoint with `RequireIssuerVerification = false` — same pattern as the existing `/hwk` endpoint for pseudonymous access.
+
+### AgentConsole `--ps` validation was wrong
+
+The original code (prior to fix):
+```csharp
+if (personServer is null && signingMode is "jwt" or "jkt-jwt")
+{
+    Console.Error.WriteLine("Agent Token mode (jwt/jkt-jwt) requires a Person Server (--ps).");
+}
+```
+
+This incorrectly grouped `jkt-jwt` with `jwt` as requiring a Person Server. Per the spec, jkt-jwt is pseudonymous — it doesn't need PS for resource access. The PS is only needed for three-party `jwt` mode where the AP delegates consent decisions to the Person Server.
+
+**Fix**: Only `jwt` mode requires `--ps`. The `jkt-jwt` mode works without PS (same as `hwk`).
+
+---
+
+## SDK Improvement Opportunities (beyond this PR)
+
+### 1. `UseJwksUri` should accept `AgentTokenKid` directly from `EnrollResult`
+
+The pattern `UseJwksUri(result.JwksUri, result.AgentTokenKid ?? result.LocalKeyHandle)` is error-prone — every sample gets it wrong differently. A better API would be:
+
+```csharp
+// Proposed: accept EnrollResult directly
+builder.UseJwksUri(result);
+// Or: UseJwksUri with smart defaulting
+builder.UseJwksUri(result.JwksUri, result.AgentTokenKid);
+```
+
+### 2. `IKeyStore` parameter name is `keyId` — should be `handle` or `name`
+
+The `IKeyStore` interface uses `string keyId` as the parameter name. After this rename PR, the semantic is clearly "a local handle/name" — but the interface parameter still says `keyId`, which perpetuates the old confusion. Renaming the interface parameter to `handle` or `name` would complete the cleanup.
+
+### 3. No convenience `EnrollResult.BuildClient()` method
+
+Every sample after enrollment manually extracts fields and wires them together:
+```csharp
+var key = result.Key;
+var keyId = result.LocalKeyHandle;
+builder.UseJwksUri(result.JwksUri, result.AgentTokenKid ?? keyId);
+```
+
+A fluent `result.BuildClient()` or `AAuthClientBuilder.From(result)` would eliminate this boilerplate and prevent kid/handle confusion.
+
+### 4. `AgentConsole` still names variable `keyId` (confusing)
+
+After the PR, `AgentConsole/Program.cs` still declares `string keyId` and assigns `result.LocalKeyHandle` to it. The variable name `keyId` reintroduces the confusion this PR is eliminating. Should be `localKeyHandle` or `keyHandle`.
+
+---
+
+## Missing Items from the Plan (focused on docs and samples)
+
+| # | Missing Item | Category | Impact | PR Review Flagged? |
+|---|---|---|---|---|
+| 1 | `samples/SampleApp/Components/Pages/JwksUri.razor` — `kid` parameter should use `AgentTokenKid ?? LocalKeyHandle` | Sample Bug | Runtime failure when AP `kid` ≠ thumbprint | Yes |
+| 2 | `samples/GuidedTour/CodeSnippets.cs` — `SignedGetJwksUri` passes `result.LocalKeyHandle` as kid | Sample Bug | Same as above | Yes |
+| 3 | `samples/GuidedTour/CodeSnippets.cs` — `SignedGetJwt`, `TokenExchangeDirect`, `FullAutomatic` still use `keyId` variable | Sample Inconsistency | Copy-paste confusion | Yes |
+| 4 | `samples/SampleApp/Components/Pages/Jwt.razor` — HTML code block shows `keyId` in RefreshAsync | Doc Inconsistency | Reader confusion | — |
+| 5 | `EnrollResult.AgentTokenKid` — documented as "diagnostic only" but required for `jwks_uri` mode | API Design Flaw | Callers ignore it, then `jwks_uri` breaks | Yes |
+| 6 | `samples/AgentConsole/Program.cs` — variable still named `keyId` | Naming Inconsistency | Contradicts the PR's intent | Yes |
+| 7 | Implementation plan missing DoD checklists per phase | Process Gap | Doesn't follow repo planning workflow | Yes |
+| 8 | `docs/advanced/key-management.md` — no mention of the enrollment-handle semantic | Doc Gap | Missing context about what the `keyId` param is in enrollment context | — |
+| 9 | `docs/signing-modes/agent-identity-jwks-uri.md` — no comment clarifying `kid` is AP-published | Doc Gap | Perpetuates kid/handle confusion for jwks_uri mode | — |
+| 10 | `docs/signing-modes/overview.md` — `kid` variable in code block unexplained | Doc Gap | Same confusion | — |
+| 11 | Top-level `README.md` — not swept for enrollment snippets (plan said to sweep) | Doc Gap | Possibly stale | — |
diff --git a/docs/advanced/key-management.md b/docs/advanced/key-management.md
index f71f586..d639362 100644
--- a/docs/advanced/key-management.md
+++ b/docs/advanced/key-management.md
@@ -10,14 +10,16 @@ AAuth agents need persistent signing keys. The SDK provides two built-in storage
 
 The `IKeyStore` interface defines async key storage for agent workflows (enrollment, token refresh). The SDK ships two built-in implementations: `InMemoryKeyStore` and `FileKeyStore`.
 
+> **Note:** For AP-enrolled agents, the `handle` parameter passed to `IKeyStore` methods is the `LocalKeyHandle` returned by `EnrolAsync` (defaults to the durable key's JWK thumbprint). It is a purely local identifier — not an AP-assigned value. The AP identifies the agent at refresh time from the HTTP signature, never from this string.
+
 ```csharp
 namespace AAuth.Crypto;
 
 public interface IKeyStore
 {
-    Task LoadAsync(string keyId, CancellationToken ct = default);
-    Task StoreAsync(string keyId, IAAuthKey key, CancellationToken ct = default);
-    Task DeleteAsync(string keyId, CancellationToken ct = default);
+    Task LoadAsync(string handle, CancellationToken ct = default);
+    Task StoreAsync(string handle, IAAuthKey key, CancellationToken ct = default);
+    Task DeleteAsync(string handle, CancellationToken ct = default);
     Task ListAsync(CancellationToken ct = default);
 }
 ```
@@ -108,11 +110,14 @@ public sealed class AzureKeyVaultStore : IKeyStore
 
     public AzureKeyVaultStore(SecretClient client) => _client = client;
 
-    public async Task LoadAsync(string keyId, CancellationToken ct)
+    // Spec: 'handle' is agent-chosen, never leaves the agent.
+    // It is distinct from the AP-published kid (AgentTokenKid) and
+    // the JWK thumbprint used for cryptographic identity.
+    public async Task LoadAsync(string handle, CancellationToken ct)
     {
         try
         {
-            var secret = await _client.GetSecretAsync(keyId, cancellationToken: ct);
+            var secret = await _client.GetSecretAsync(handle, cancellationToken: ct);
             return AAuthKey.FromJwkJson(secret.Value.Value);
         }
         catch (RequestFailedException ex) when (ex.Status == 404)
@@ -121,15 +126,15 @@ public sealed class AzureKeyVaultStore : IKeyStore
         }
     }
 
-    public async Task StoreAsync(string keyId, IAAuthKey key, CancellationToken ct)
+    public async Task StoreAsync(string handle, IAAuthKey key, CancellationToken ct)
     {
         var jwk = ((AAuthKey)key).ToPrivateJwk().ToJsonString();
-        await _client.SetSecretAsync(new KeyVaultSecret(keyId, jwk), ct);
+        await _client.SetSecretAsync(new KeyVaultSecret(handle, jwk), ct);
     }
 
-    public async Task DeleteAsync(string keyId, CancellationToken ct)
+    public async Task DeleteAsync(string handle, CancellationToken ct)
     {
-        await _client.StartDeleteSecretAsync(keyId, ct);
+        await _client.StartDeleteSecretAsync(handle, ct);
     }
 
     public async Task ListAsync(CancellationToken ct)
diff --git a/docs/concepts.md b/docs/concepts.md
index 1350ee6..83a27c8 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -11,7 +11,7 @@ AAuth is a protocol for autonomous agent authorization. This page maps protocol
 | **Person Server (PS)** | Represents the user. Manages consent, federates to AS. | `TokenExchangeClient`, `ServerMetadata` |
 | **Access Server (AS)** | Issues auth tokens. Enforces resource access policy. | `AuthTokenBuilder` |
 
-> **Agent Provider (AP)** is a supporting role — it issues agent tokens binding keys to identities (`AgentProviderClient`) but is not one of the four protocol participants.
+> **Agent Provider (AP)** is a supporting role — it issues agent tokens binding keys to identities (`AgentProviderClient`) but is not one of the four protocol participants. The AP and the agent never share a keystore: the agent holds the **private** durable key locally in its own `IKeyStore`; the AP holds only the **public** key, indexed by JWK thumbprint. At refresh time the AP identifies the agent from the HTTP signature, not from any string the agent sends. See [Bootstrap & Enrollment](workflows/bootstrap-enrollment.md#key-identifiers-what-goes-where) for the three identifiers in play.
 
 ## Three Layers
 
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 01f5be0..ddd17a6 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -131,14 +131,14 @@ var enrol = await AAuthClientBuilder
     .WithKeyStore(keyStore)
     .EnrolAsync();
 
-// Only the key ID needs to be recorded in app config
-// (the key itself is already in the keystore)
-Console.WriteLine($"Enrolled. Add to config: AAuth:KeyId = {enrol.EnrolledKeyId}");
+// Only the local key handle needs to be recorded in app config
+// (the key itself is already in the keystore; defaults to the JWK thumbprint)
+Console.WriteLine($"Enrolled. Add to config: AAuth:LocalKeyHandle = {enrol.LocalKeyHandle}");
 ```
 
 ### Application (every startup)
 
-Load the key by ID from the store and let the SDK manage agent tokens:
+Load the key by handle from the store and let the SDK manage agent tokens:
 
 ```csharp
 using AAuth.Agent;
@@ -146,15 +146,15 @@ using AAuth.Crypto;
 using AAuth.HttpSig;
 
 var keyStore = FileKeyStore.Default();
-var keyId = configuration["AAuth:KeyId"]!;
+var localKeyHandle = configuration["AAuth:LocalKeyHandle"]!;
 var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!;
-var key = await keyStore.LoadAsync(keyId)
-    ?? throw new InvalidOperationException($"Key '{keyId}' not found. Run enrollment first.");
+var key = await keyStore.LoadAsync(localKeyHandle)
+    ?? throw new InvalidOperationException($"Key '{localKeyHandle}' not found. Run enrollment first.");
 
 // The SDK acquires the agent token lazily on first request
 // via WithTokenRefresh, then keeps it fresh automatically.
 using var client = new AAuthClientBuilder(key)
-    .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, keyId)
+    .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle)
         .WithKeyStore(keyStore)
         .Build())
     .WithChallengeHandling("https://ps.example")
@@ -164,6 +164,22 @@ var response = await client.GetAsync("https://resource.example/protected");
 Console.WriteLine(await response.Content.ReadAsStringAsync());
 ```
 
+> **Shortcut — `From(EnrollResult)`**: If you still have the enrollment result object
+> (e.g. in a CLI that enrols and immediately calls a resource), use the convenience factory
+> to auto-configure the signing mode:
+>
+> ```csharp
+> using var client = AAuthClientBuilder.From(enrol)
+>     .WithTokenRefresh(AgentProviderTokenRefresher.Create(enrol.ApRefreshEndpoint, enrol.LocalKeyHandle)
+>         .WithKeyStore(keyStore)
+>         .Build())
+>     .WithChallengeHandling("https://ps.example")
+>     .Build();
+> ```
+>
+> `From()` sets up `UseJwksUri` when the enrollment includes a `JwksUri` and `AgentTokenKid`,
+> falling back to the default `jwt` mode otherwise.
+
 
Step-by-Step (Advanced) @@ -182,16 +198,17 @@ var enrol = await apClient.EnrolAsync( enrollEndpoint: "https://ap.example/enrol", personServer: "https://ps.example"); -// enrol.Key — your Ed25519 signing key (in keystore) -// enrol.EnrolledKeyId — persisted key identifier (save this to config) -// enrol.AgentToken — initial aa-agent+jwt (short-lived, do not persist) +// enrol.Key — your Ed25519 signing key (in keystore) +// enrol.LocalKeyHandle — agent-local IKeyStore handle (defaults to JWK thumbprint); persist this +// enrol.AgentTokenKid — AP-internal JWT `kid` (opaque; diagnostic only) +// enrol.AgentToken — initial aa-agent+jwt (short-lived, do not persist) ``` ### 2. Build the Signed Client with Challenge Handling ```csharp using var client = new AAuthClientBuilder(enrol.Key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create("https://ap.example/refresh", enrol.EnrolledKeyId) + .WithTokenRefresh(AgentProviderTokenRefresher.Create("https://ap.example/refresh", enrol.LocalKeyHandle) .WithKeyStore(keyStore) .Build()) .WithChallengeHandling(personServer: "https://ps.example") diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index 177b56c..627f944 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -18,7 +18,7 @@ flowchart LR H2 --> H3["Runtime: self-issue token via AgentTokenBuilder"] end subgraph CLI/Desktop - P["Provisioning: EnrolAsync(keyStore)"] --> C["App config: AAuth:KeyId"] + P["Provisioning: EnrolAsync(keyStore)"] --> C["App config: AAuth:LocalKeyHandle"] C --> S["Startup: keyStore.LoadAsync → AddAAuthAgent"] S --> R["Runtime: SDK calls AP refresh before expiry"] end @@ -75,20 +75,20 @@ app.MapAAuthAgentWellKnown(new AAuthAgentMetadataOptions ### Identity-Based (JWT) — AP-Enrolled (CLI/Desktop Agents) -Load the key by ID from the store and configure token refresh: +Load the key by local handle from the store and configure token refresh: ```csharp var keyStore = FileKeyStore.Default(); -var keyId = configuration["AAuth:KeyId"]!; -var key = await keyStore.LoadAsync(keyId) - ?? throw new InvalidOperationException($"Key '{keyId}' not found."); +var localKeyHandle = configuration["AAuth:LocalKeyHandle"]!; +var key = await keyStore.LoadAsync(localKeyHandle) + ?? throw new InvalidOperationException($"Key '{localKeyHandle}' not found."); var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; builder.Services.AddAAuthAgent("identity", options => { options.Key = key; options.PersonServer = "https://ps.example"; - options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, keyId) + options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build(); }); @@ -101,7 +101,7 @@ builder.Services.AddAAuthAgent("identity", options => { options.Key = key; options.PersonServer = "https://ps.example"; - options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, keyId) + options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build(); }); @@ -116,7 +116,7 @@ builder.Services.AddAAuthAgent("interactive", options => { options.Key = key; options.PersonServer = "https://ps.example"; - options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, keyId) + options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build(); options.InteractionHandling = true; @@ -141,7 +141,7 @@ builder.Services.AddAAuthAgent("refreshing", options => { options.Key = key; options.PersonServer = "https://ps.example"; - options.TokenRefresher = AgentProviderTokenRefresher.Create("https://ap.example/refresh", keyId) + options.TokenRefresher = AgentProviderTokenRefresher.Create("https://ap.example/refresh", localKeyHandle) .WithKeyStore(keyStore) .Build(); }); @@ -264,7 +264,7 @@ builder.Services.AddAAuthAgent("downstream", options => { options.Key = agentKey; options.PersonServer = "https://ps.example"; - options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, keyId) + options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build(); }); diff --git a/docs/signing-modes/agent-identity-jwks-uri.md b/docs/signing-modes/agent-identity-jwks-uri.md index 28e30ff..0bd7a91 100644 --- a/docs/signing-modes/agent-identity-jwks-uri.md +++ b/docs/signing-modes/agent-identity-jwks-uri.md @@ -40,6 +40,8 @@ var key = AAuthKey.Generate(); // The jwks_uri comes from the AP's enrollment response — it points // to the per-agent JWKS endpoint where the AP publishes this agent's key. +// "my-key-1" is the AP-published kid (EnrollResult.AgentTokenKid). +// The AP chooses this value — there is no valid fallback if the AP didn't provide it. using var client = new AAuthClientBuilder(key) .UseJwksUri("https://ap.example/agents/aauth:myapp@ap.example/jwks.json", "my-key-1") .Build(); diff --git a/docs/signing-modes/agent-token-jwt.md b/docs/signing-modes/agent-token-jwt.md index 297e4ef..de59894 100644 --- a/docs/signing-modes/agent-token-jwt.md +++ b/docs/signing-modes/agent-token-jwt.md @@ -46,7 +46,7 @@ using AAuth.Crypto; using AAuth.HttpSig; var keyStore = FileKeyStore.Default(); -var key = await keyStore.LoadAsync(configuration["AAuth:KeyId"]!); +var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!); var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; using var client = new AAuthClientBuilder(key!) diff --git a/docs/signing-modes/overview.md b/docs/signing-modes/overview.md index e7788a8..be84eac 100644 --- a/docs/signing-modes/overview.md +++ b/docs/signing-modes/overview.md @@ -29,6 +29,7 @@ using AAuth.HttpSig; var key = AAuthKey.Generate(); // Builder API (recommended) +// For jwks_uri: kid = AP-published JWKS kid (AgentTokenKid) or self-chosen kid for self-hosted using var client = mode switch { "hwk" => new AAuthClientBuilder(key).UseHwk().Build(), diff --git a/docs/workflows/bootstrap-enrollment.md b/docs/workflows/bootstrap-enrollment.md index 1569a95..07e00a6 100644 --- a/docs/workflows/bootstrap-enrollment.md +++ b/docs/workflows/bootstrap-enrollment.md @@ -17,12 +17,14 @@ sequenceDiagram Agent->>AP: GET /.well-known/aauth-agent.json AP-->>Agent: metadata (enrol_endpoint, jwks_uri) Agent->>AP: POST /enrol {agent_id, jwk, ps?} - AP-->>Agent: {agent_token, key_id, jwks_uri} + AP-->>Agent: {agent_token, key_id?, jwks_uri} ``` ## Enrollment Is a Provisioning Step -Enrollment is **not** part of your application's normal startup — it's a separate operational step, analogous to running a database migration or issuing a TLS certificate. You run it once per device/install (in a CLI tool, setup script, or CI pipeline). The durable signing key is generated **inside a keystore** (HSM, TPM, file store) and never extracted — the application references it by ID. +Enrollment is **not** part of your application's normal startup — it's a separate operational step, analogous to running a database migration or issuing a TLS certificate. You run it once per device/install (in a CLI tool, setup script, or CI pipeline). The durable signing key is generated **inside a keystore** (HSM, TPM, file store) and never extracted — the application references it by a local handle. + +> **The agent and the AP never share a keystore.** The agent holds the **private** durable key locally in its own `IKeyStore`. The AP holds only the **public** key, indexed in its enrollment database by JWK thumbprint. At refresh time the AP identifies the agent from the HTTP signature — never from any string the agent sends. The agent token is short-lived (typically 1 hour, max 24 hours per spec) and refreshed automatically by the SDK at runtime using the durable key. @@ -31,18 +33,18 @@ flowchart LR subgraph Provisioning["Provisioning (run once)"] E1[EnrolAsync with keyStore] E2[Key generated inside store] - E3[Key ID returned] + E3[Local key handle returned
defaults to JWK thumbprint] E1 --> E2 --> E3 end subgraph Runtime["Application Runtime (every startup)"] - R1[keyStore.LoadAsync keyId] + R1[keyStore.LoadAsync localKeyHandle] R2[Load key by reference] R3[SDK refreshes token via AP] R1 --> R2 --> R3 end - E3 -- "config: key ID only" --> R1 + E3 -- "config: local key handle only" --> R1 ``` ## Code Example @@ -71,12 +73,13 @@ var enrol = await AAuthClientBuilder .WithKeyStore(keyStore) .EnrolAsync(); -// Only the key ID needs to go into app config -Console.WriteLine($"Enrolled. KeyId: {enrol.EnrolledKeyId}"); -Console.WriteLine($"Add to appsettings: AAuth:KeyId = {enrol.EnrolledKeyId}"); +// Only the local key handle needs to go into app config. +// (Defaults to the durable key's JWK thumbprint — opaque to the AP.) +Console.WriteLine($"Enrolled. Local key handle: {enrol.LocalKeyHandle}"); +Console.WriteLine($"Add to appsettings: AAuth:LocalKeyHandle = {enrol.LocalKeyHandle}"); ``` -### Application: Load Key by ID and Build Client +### Application: Load Key by Local Handle and Build Client ```csharp using AAuth.Agent; @@ -85,15 +88,15 @@ using AAuth.HttpSig; // Key stays in the store — loaded by reference, never extracted var keyStore = FileKeyStore.Default(); -var keyId = configuration["AAuth:KeyId"]!; +var localKeyHandle = configuration["AAuth:LocalKeyHandle"]!; var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; -var key = await keyStore.LoadAsync(keyId) - ?? throw new InvalidOperationException($"Key '{keyId}' not found. Run enrollment."); +var key = await keyStore.LoadAsync(localKeyHandle) + ?? throw new InvalidOperationException($"Key '{localKeyHandle}' not found. Run enrollment."); // The SDK acquires the agent token lazily on first request // via WithTokenRefresh, then keeps it fresh automatically. using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, keyId) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build()) .WithChallengeHandling(personServer: "https://ps.example") @@ -116,9 +119,11 @@ var result = await apClient.EnrolAsync( personServer: "https://ps.example" // optional: include if using three-party flows ); -// result.AgentToken = the aa-agent+jwt -// result.Key = the generated signing key -// result.EnrolledKeyId = the key ID at the AP +// result.AgentToken = the aa-agent+jwt issued by the AP +// result.Key = the generated durable signing key +// result.LocalKeyHandle = agent-local IKeyStore handle (defaults to the JWK thumbprint) +// result.AgentTokenKid = AP-published kid for jwks_uri mode (required for UseJwksUri) +// result.JwksUri = per-agent JWKS URI (for jwks_uri signing mode) ``` ## What Bootstrap Produces @@ -128,8 +133,9 @@ var result = await apClient.EnrolAsync( - `sub`: agent identifier (`aauth:local@domain`) - `cnf.jwk`: the agent's public key (bound to identity) - `ps`: Person Server URL (optional, only if agent has a PS) -- The agent's private key stored in `IKeyStore` -- A `key_id` assigned by the AP (stable for the key's lifetime) +- The agent's **private** key stored in `IKeyStore` (the AP only ever sees the public key) +- A **local key handle** (`EnrollResult.LocalKeyHandle`) for `IKeyStore.LoadAsync` — defaults to the durable key's JWK thumbprint (RFC 7638). Purely agent-local; never sent to the AP. +- An **AP-published JWT `kid`** (`EnrollResult.AgentTokenKid`) — the AP chooses this value and publishes it in the per-agent JWKS. Required for `jwks_uri` signing mode (passed to `UseJwksUri(url, kid)`); opaque to receivers per spec § "Agent Identifier Strategies". - A `jwks_uri` pointing to the per-agent JWKS endpoint where the AP publishes the agent's public key (used with `scheme=jwks_uri`) ## Token Refresh @@ -138,29 +144,58 @@ Agent tokens are short-lived (typically 1 hour, max 24 hours per spec). The SDK ### AP-Enrolled Agents (CLI, desktop, mobile) -The AP issued the original token during enrollment. At refresh time the SDK signs a POST to the AP's refresh endpoint with the enrolled key — the AP verifies the signature, looks up the agent by key ID, and returns a fresh token. +The AP issued the original token during enrollment. At refresh time the SDK signs a POST to the AP's refresh endpoint with the **durable key** — the AP verifies the signature, looks the enrollment up by the key's **JWK thumbprint**, and returns a fresh token. **No identifier travels in the request body**; the signature alone identifies the agent. ```mermaid sequenceDiagram participant Agent participant AP as Agent Provider Note over Agent: Token nearing expiry - Agent->>AP: POST /refresh (signed with enrolled key) - AP->>AP: Verify signature, look up key_id + Agent->>AP: POST /refresh (signed with durable key) + AP->>AP: Verify signature, look up by JWK thumbprint AP-->>Agent: New aa-agent+jwt ``` ```csharp -// keyId = the AP-assigned key identifier from enrollment (e.g. "aauth:myapp@ap.example:c34078382e") -// This is the ID the AP uses to look up the agent's public key for signature verification. -// It is also the filename/reference under which IKeyStore stores the private key. +// localKeyHandle = the agent-local IKeyStore handle returned by EnrolAsync +// (defaults to the durable key's JWK thumbprint). +// Used only by IKeyStore.LoadAsync — it is never sent to the AP. using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, keyId) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build()) .Build(); ``` +### Two-Key Refresh (jkt-jwt — key rotation) + +For agents using the `jkt-jwt` signing mode, the refresh flow uses **two keys**: the enrolled durable key for identity proof, and a fresh ephemeral key for signing HTTP requests. This enables key rotation without re-enrollment. + +```mermaid +sequenceDiagram + participant Agent + participant AP as Agent Provider + Note over Agent: Token nearing expiry + Agent->>Agent: Generate ephemeral Ed25519 key + Agent->>Agent: Build naming JWT (signed by durable key,
embeds ephemeral key as cnf.jwk) + Agent->>AP: POST /refresh (signed with ephemeral key,
Signature-Key: sig=jkt-jwt;jwt="naming-jwt") + AP->>AP: Extract durable key thumbprint from naming JWT kid + AP->>AP: Verify naming JWT signature against enrolled durable key + AP->>AP: Verify HTTP signature against ephemeral key + AP-->>Agent: New aa-agent+jwt (cnf.jwk = ephemeral key) +``` + +```csharp +// Spec: "The AP verifies the durable-key signature on the naming JWT, +// looks up the enrollment by the durable key's thumbprint" +using var client = new AAuthClientBuilder(key) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) + .WithKeyStore(keyStore) + .WithRefreshMode(RefreshMode.TwoKey, apIssuer) + .Build()) + .Build(); +``` + ### Self-Issued Tokens (hosted services) Hosted services with a stable HTTPS URL act as their own issuer — no AP is needed. The SDK mints a fresh JWT locally on each refresh, signed with the service's own key. @@ -178,29 +213,36 @@ using var client = new AAuthClientBuilder(key) .Build(); ``` -## Key IDs: What Goes Where +## Key Identifiers: What Goes Where + +The term "key ID" gets overloaded in AP enrollment. There are actually **three different identifiers** in play, and the spec keeps them disjoint: + +| Identifier | Owner / origin | Travels where | Used for | +|-----------|----------------|---------------|----------| +| **JWK thumbprint** of the durable key (RFC 7638) | derived from the public key | implicit on every signed request | The AP looks the agent up in its enrollment DB by this thumbprint at refresh time | +| **Local key handle** (`EnrollResult.LocalKeyHandle`) | the agent (SDK chooses; defaults to the JWK thumbprint) | never leaves the agent process | `IKeyStore.LoadAsync(localKeyHandle)` — loads the private key at app startup | +| **AP-published JWT `kid`** (`EnrollResult.AgentTokenKid`) | the AP picks (opaque) | inside the issued `aa-agent+jwt` header; in `Signature-Key` for `jwks_uri` mode | Required for `UseJwksUri(url, kid)` — the agent passes it when building the client. Opaque to receivers per spec § "Agent Identifier Strategies". **Not sent back to the AP at refresh** — refresh uses the HTTP signature only. | -The term "key ID" appears in several contexts. This table clarifies which value is which: +For the second flavour of enrollment, **self-issued** (hosted services), only one identifier matters: | Scenario | Key ID value | Who assigns it | Where it's stored | What uses it | |----------|-------------|----------------|-------------------|--------------| -| AP-enrolled | `aauth:myapp@ap.example:c34078382e` | Agent Provider (during enrollment) | Agent's local `IKeyStore` (filename) + `appsettings.json` (reference) | `IKeyStore.LoadAsync(keyId)` loads the private key for signing. The AP never receives this string — it identifies the agent by verifying the HTTP signature and matching the JWK thumbprint against its enrollment database. | | Self-issued | Any stable string (e.g. `"svc-key-1"`) or JWK thumbprint | You (the developer) | Hardcoded or in config | JWT `kid` header — resources use it to select the correct key from your JWKS | ### AP-Enrolled: Key ID Flow ```mermaid flowchart LR - AP["Agent Provider
assigns key_id at enrollment"] --> Store["Agent's local IKeyStore
stores private key under key_id"] - Store --> Config["appsettings.json
persists key_id string"] - Config --> Load["keyStore.LoadAsync(keyId)
loads private key"] + AP["Agent Provider
records public key
by JWK thumbprint"] --> Store["Agent's local IKeyStore
stores private key under
local key handle
(defaults to thumbprint)"] + Store --> Config["appsettings.json
persists local key handle"] + Config --> Load["keyStore.LoadAsync(localKeyHandle)
loads private key"] Load --> Sign["Signs refresh request
(HTTP Signature, hwk scheme)"] - Sign --> APVerify["AP verifies signature
matches JWK thumbprint
in enrollment DB"] + Sign --> APVerify["AP verifies signature,
looks up enrollment
by JWK thumbprint"] ``` -1. **Enrollment** — AP generates `key_id` (e.g. `aauth:myapp@ap.example:c34078382e`) and the SDK stores the private key locally under that ID. The AP stores only the public key, indexed by JWK thumbprint. -2. **Config** — You persist only the `key_id` string in `appsettings.json` (a local keystore reference). -3. **Runtime** — `keyStore.LoadAsync(keyId)` retrieves the private key from the agent's local store. The refresher signs the HTTP request. The AP identifies the agent by verifying the signature against its enrolled public keys (matched by thumbprint) — it never receives the `key_id` string itself. +1. **Enrollment** — the agent generates a durable key inside its `IKeyStore`. The AP records only the **public** key, indexed by JWK thumbprint. The SDK stores the **private** key locally under a local key handle (default: the same thumbprint, for convenience). +2. **Config** — you persist only the `localKeyHandle` string in `appsettings.json` (a local keystore reference). The AP doesn't know or care about it. +3. **Runtime** — `keyStore.LoadAsync(localKeyHandle)` retrieves the private key from the agent's local store. The refresher signs the HTTP request with that key. The AP identifies the agent purely by verifying the signature against its enrolled public keys (matched by JWK thumbprint). ### Self-Issued: Key ID Flow @@ -221,6 +263,7 @@ flowchart LR |------|:----------------:|-----| | Pseudonymous (hwk) | No | Just needs a bare keypair | | Agent Identity (jwks_uri) | Yes | AP publishes the agent's key at a per-agent JWKS endpoint | +| Key Rotation (jkt-jwt) | Yes | Durable key must be enrolled; AP issues tokens bound to ephemeral keys | | Three-party (jwt) | Yes | Agent token required for PS interactions | ## Key Persistence diff --git a/docs/workflows/call-chaining.md b/docs/workflows/call-chaining.md index 3868d4d..46747ae 100644 --- a/docs/workflows/call-chaining.md +++ b/docs/workflows/call-chaining.md @@ -37,13 +37,13 @@ using AAuth.Crypto; using AAuth.HttpSig; var keyStore = FileKeyStore.Default(); -var keyId = configuration["AAuth:KeyId"]!; -var key = await keyStore.LoadAsync(keyId) +var localKeyHandle = configuration["AAuth:LocalKeyHandle"]!; +var key = await keyStore.LoadAsync(localKeyHandle) ?? throw new InvalidOperationException("Key not found."); var refreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, keyId) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build()) .WithChallengeHandling(personServer) diff --git a/docs/workflows/deferred-consent.md b/docs/workflows/deferred-consent.md index a923fd0..5d4389f 100644 --- a/docs/workflows/deferred-consent.md +++ b/docs/workflows/deferred-consent.md @@ -70,7 +70,7 @@ using AAuth.Crypto; using AAuth.HttpSig; var keyStore = FileKeyStore.Default(); -var key = await keyStore.LoadAsync(configuration["AAuth:KeyId"]!) +var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!) ?? throw new InvalidOperationException("Key not found. Run enrollment first."); var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; @@ -92,7 +92,7 @@ using var client = new AAuthClientBuilder(key) ## DI Registration ```csharp -var key = await keyStore.LoadAsync(configuration["AAuth:KeyId"]!); +var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!); builder.Services.AddAAuthAgent("deferred", options => { diff --git a/docs/workflows/federated-access.md b/docs/workflows/federated-access.md index 8fbac09..7ae3080 100644 --- a/docs/workflows/federated-access.md +++ b/docs/workflows/federated-access.md @@ -30,7 +30,7 @@ using AAuth.Crypto; using AAuth.HttpSig; var keyStore = FileKeyStore.Default(); -var key = await keyStore.LoadAsync(configuration["AAuth:KeyId"]!) +var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!) ?? throw new InvalidOperationException("Key not found. Run enrollment first."); var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; @@ -51,7 +51,7 @@ using AAuth.Agent; using AAuth.Crypto; var keyStore = FileKeyStore.Default(); -var key = await keyStore.LoadAsync(configuration["AAuth:KeyId"]!); +var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!); var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; builder.Services.AddAAuthAgent("federated", options => diff --git a/docs/workflows/identity-based-access.md b/docs/workflows/identity-based-access.md index 060843b..d40893c 100644 --- a/docs/workflows/identity-based-access.md +++ b/docs/workflows/identity-based-access.md @@ -56,7 +56,7 @@ using AAuth.Agent; using AAuth.Crypto; IKeyStore keyStore = FileKeyStore.Default(); -var key = await keyStore.LoadAsync(configuration["AAuth:KeyId"]!); +var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!); builder.Services.AddAAuthAgent("identity-hwk", options => { diff --git a/docs/workflows/ps-asserted-access.md b/docs/workflows/ps-asserted-access.md index 14b42c3..6f805ce 100644 --- a/docs/workflows/ps-asserted-access.md +++ b/docs/workflows/ps-asserted-access.md @@ -59,13 +59,13 @@ using AAuth.Crypto; using AAuth.HttpSig; var keyStore = FileKeyStore.Default(); -var keyId = configuration["AAuth:KeyId"]!; -var key = await keyStore.LoadAsync(keyId) +var localKeyHandle = configuration["AAuth:LocalKeyHandle"]!; +var key = await keyStore.LoadAsync(localKeyHandle) ?? throw new InvalidOperationException("Key not found. Run enrollment first."); var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, keyId) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build()) .WithChallengeHandling(personServer: "https://ps.example") @@ -88,7 +88,7 @@ using AAuth.Discovery; using AAuth.HttpSig; var keyStore = FileKeyStore.Default(); -var key = await keyStore.LoadAsync(configuration["AAuth:KeyId"]!); +var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!); var agentToken = "..."; // acquired via AP refresh endpoint var tokenHolder = new AAuthTokenHolder(agentToken); @@ -122,15 +122,15 @@ using AAuth.Agent; using AAuth.Crypto; var keyStore = FileKeyStore.Default(); -var keyId = configuration["AAuth:KeyId"]!; -var key = await keyStore.LoadAsync(keyId); +var localKeyHandle = configuration["AAuth:LocalKeyHandle"]!; +var key = await keyStore.LoadAsync(localKeyHandle); var apRefreshEndpoint = configuration["AAuth:ApRefreshEndpoint"]!; builder.Services.AddAAuthAgent("ps-asserted", options => { options.Key = key!; options.PersonServer = "https://ps.example"; - options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, keyId) + options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build(); }); diff --git a/docs/workflows/resource-managed-access.md b/docs/workflows/resource-managed-access.md index a741508..28dcd69 100644 --- a/docs/workflows/resource-managed-access.md +++ b/docs/workflows/resource-managed-access.md @@ -73,7 +73,7 @@ builder.Services.AddSingleton(new InMemoryOpaqueTokenStore()) ### Agent-Side ```csharp -var key = await keyStore.LoadAsync(configuration["AAuth:KeyId"]!); +var key = await keyStore.LoadAsync(configuration["AAuth:LocalKeyHandle"]!); builder.Services.AddAAuthAgent("resource-managed", options => { diff --git a/samples/AgentConsole/Program.cs b/samples/AgentConsole/Program.cs index c2a7403..52049cd 100644 --- a/samples/AgentConsole/Program.cs +++ b/samples/AgentConsole/Program.cs @@ -82,17 +82,17 @@ return 1; } -if (personServer is not null && signingMode is not ("jwt" or "jkt-jwt")) +if (personServer is not null && signingMode is not "jwt") { - Console.Error.WriteLine("Three-party flows (--ps) require --signing-mode jwt or jkt-jwt per spec."); - Console.Error.WriteLine("Non-jwt modes (hwk, jwks_uri) are for identity-based access only."); + Console.Error.WriteLine("Three-party flows (--ps) require --signing-mode jwt."); + Console.Error.WriteLine("Pseudonymous modes (hwk, jwks_uri, jkt-jwt) are for identity-based access only."); return 1; } -if (personServer is null && signingMode is "jwt" or "jkt-jwt") +if (personServer is null && signingMode is "jwt") { - Console.Error.WriteLine("Agent Token mode (jwt/jkt-jwt) requires a Person Server (--ps)."); - Console.Error.WriteLine("For identity-based access without a PS, use --signing-mode hwk or jwks_uri."); + Console.Error.WriteLine("Agent Token mode (jwt) requires a Person Server (--ps)."); + Console.Error.WriteLine("For identity-based access without a PS, use --signing-mode hwk, jwks_uri, or jkt-jwt."); return 1; } @@ -104,12 +104,13 @@ } IAAuthKey key; -string keyId; +string localKeyHandle; +string? agentTokenKid; string? agentJwksUri; string refreshEndpoint; // Per spec, agent keys are long-lived (spanning the agent install). -// The key lives in a durable keystore — we only persist its ID + AP metadata. +// The key lives in a durable keystore — we only persist its handle + AP metadata. IKeyStore keyStore = FileKeyStore.Default(); var enrollCacheFile = Path.Combine( @@ -119,13 +120,14 @@ if (File.Exists(enrollCacheFile)) { var cached = JsonNode.Parse(File.ReadAllText(enrollCacheFile))!; - keyId = (string)cached["key_id"]!; + localKeyHandle = (string)cached["key_id"]!; + agentTokenKid = (string?)cached["agent_token_kid"]; agentJwksUri = (string?)cached["jwks_uri"]; refreshEndpoint = (string)cached["refresh_endpoint"]!; - key = await keyStore.LoadAsync(keyId) - ?? throw new InvalidOperationException($"Key '{keyId}' not found in store. Delete {enrollCacheFile} and re-enrol."); - Console.WriteLine($"Loaded enrolled agent. Key ID: {keyId}"); + key = await keyStore.LoadAsync(localKeyHandle) + ?? throw new InvalidOperationException($"Key '{localKeyHandle}' not found in store. Delete {enrollCacheFile} and re-enrol."); + Console.WriteLine($"Loaded enrolled agent. Local key handle: {localKeyHandle}"); } else { @@ -142,21 +144,23 @@ var apClient = new AgentProviderClient(new HttpClient(), keyStore); var result = await apClient.EnrolAsync(apBase, subject, enrolEndpoint, personServer); key = result.Key; - keyId = result.EnrolledKeyId; + localKeyHandle = result.LocalKeyHandle; + agentTokenKid = result.AgentTokenKid; agentJwksUri = result.JwksUri; - Console.WriteLine($"Enrolled successfully. Key ID: {keyId}"); + Console.WriteLine($"Enrolled successfully. Local key handle: {localKeyHandle}"); // Persist only metadata — key lives in the keystore, token is short-lived Directory.CreateDirectory(Path.GetDirectoryName(enrollCacheFile)!); File.WriteAllText(enrollCacheFile, JsonSerializer.Serialize(new { - key_id = keyId, + key_id = localKeyHandle, + agent_token_kid = agentTokenKid, jwks_uri = agentJwksUri, refresh_endpoint = refreshEndpoint, })); } -Console.WriteLine($"Using key: {keyId}"); +Console.WriteLine($"Using key handle: {localKeyHandle}"); Console.WriteLine($"Public JWK thumbprint: {key.ComputeJwkThumbprint()}"); Console.WriteLine(); @@ -173,30 +177,40 @@ break; case "jwks_uri": var jwksUrl = agentJwksUri ?? $"{apUrl.TrimEnd('/')}/agents/{subject}/jwks.json"; - builder.UseJwksUri(jwksUrl, keyId); + // Per spec, the receiver looks up the key in the JWKS by `kid`. + // The AP chooses the kid and returns it as `key_id` at enrollment. + // If the AP didn't provide one, jwks_uri mode cannot work — the agent + // has no way to know what kid the AP published the key under. + if (agentTokenKid is null) + throw new InvalidOperationException( + "Cannot use jwks_uri signing mode: the AP did not return a key_id at enrollment. " + + "Re-enrol with an AP that supports jwks_uri identity."); + builder.UseJwksUri(jwksUrl, agentTokenKid); break; case "jkt-jwt": - var jktEndpoint = refreshEndpoint; - var jktKeyId = keyId; - builder.UseJktJwt(() => - { - // In jkt-jwt mode the naming JWT is refreshed from the AP - // just like a regular agent token — the AP signs a JWT that - // binds the current key thumbprint via cnf.jkt. - var apClient2 = new AgentProviderClient(new HttpClient(), keyStore); - return apClient2.RefreshAsync(jktEndpoint, jktKeyId).GetAwaiter().GetResult(); - }); - // Three-party challenge handling still needs a full agent token - // for the exchange with the PS. + // Two-key refresh: do initial refresh to get ephemeral key + naming JWT. + // The durable key signs the naming JWT; the ephemeral key signs HTTP requests. + var twoKeyClient = new AgentProviderClient(new HttpClient(), keyStore); + var twoKeyResult = twoKeyClient.RefreshTwoKeyAsync( + refreshEndpoint, localKeyHandle, apUrl.TrimEnd('/')).GetAwaiter().GetResult(); + // Rebuild the builder with the ephemeral key (not the durable key) + builder = new AAuthClientBuilder(twoKeyResult.EphemeralKey); + // TODO: In a long-running client, the naming JWT (5-min expiry) and ephemeral key + // must be regenerated on refresh. For this single-request demo, the initial pair suffices. + var currentNamingJwt = NamingJwtBuilder.Build( + key, twoKeyResult.EphemeralKey, apUrl.TrimEnd('/'), key.ComputeJwkThumbprint()); + builder.UseJktJwt(() => currentNamingJwt); + // Three-party challenge handling uses the refreshed agent token if (personServer is not null) { - builder.WithTokenRefresh(AgentProviderTokenRefresher.Create(jktEndpoint, keyId) + builder.WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) + .WithRefreshMode(RefreshMode.TwoKey, apUrl.TrimEnd('/')) .Build()); } break; default: // "jwt" - builder.WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, keyId) + builder.WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build()); break; @@ -236,8 +250,22 @@ using var client = builder.Build(); -var request = new HttpRequestMessage(HttpMethod.Get, url); -Console.WriteLine($"GET {url}"); +// If the target URL has no path (or just "/"), append the signing-mode-specific +// path so the WhoAmI sample routes to the correct verification middleware. +var targetUrl = url; +if (url.AbsolutePath is "/" or "") +{ + targetUrl = signingMode switch + { + "hwk" => new Uri(url, "/hwk"), + "jkt-jwt" => new Uri(url, "/jkt-jwt"), + "jwks_uri" => new Uri(url, "/jwks-uri"), + _ => url, // jwt uses the root path (three-party) + }; +} + +var request = new HttpRequestMessage(HttpMethod.Get, targetUrl); +Console.WriteLine($"GET {targetUrl}"); HttpResponseMessage response; try diff --git a/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs index 6b41388..126b94f 100644 --- a/samples/GuidedTour/CodeSnippets.cs +++ b/samples/GuidedTour/CodeSnippets.cs @@ -39,7 +39,8 @@ internal static class CodeSnippets // result.Key — Ed25519 signing key // result.AgentToken — aa-agent+jwt from the AP - // result.EnrolledKeyId — key ID at the AP + // result.LocalKeyHandle — agent-local IKeyStore handle (defaults to the durable key's JWK thumbprint) + // result.AgentTokenKid — AP-published kid (required for jwks_uri mode) // result.JwksUri — per-agent JWKS endpoint """; @@ -59,8 +60,12 @@ internal static class CodeSnippets """; public const string SignedGetJwksUri = """ + // kid must match the AP's published JWKS entry. + // The AP returns this as key_id at enrollment — there is no valid fallback. + var kid = result.AgentTokenKid + ?? throw new InvalidOperationException("AP did not return key_id for jwks_uri mode."); using var client = new AAuthClientBuilder(key) - .UseJwksUri(result.JwksUri, result.EnrolledKeyId) + .UseJwksUri(result.JwksUri!, kid) .Build(); var response = await client.GetAsync("https://resource.example/data"); @@ -69,7 +74,7 @@ internal static class CodeSnippets public const string SignedGetJwt = """ using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, keyId) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build()) .Build(); @@ -79,9 +84,16 @@ internal static class CodeSnippets """; public const string SignedGetJktJwt = """ - // jkt-jwt mode: naming JWT binds key via thumbprint confirmation. - // Supports key rotation without re-enrolment. - using var client = new AAuthClientBuilder(key) + // jkt-jwt mode: the durable key signs a naming JWT that binds + // the ephemeral signing key via cnf.jwk. The ephemeral key signs + // the HTTP request. Supports key rotation without re-enrolment. + // + // Spec: "The AP verifies the durable-key signature on the naming JWT, + // looks up the enrollment by the durable key's thumbprint" + var namingJwt = NamingJwtBuilder.Build( + durableKey, ephemeralKey, apIssuer, durableKey.ComputeJwkThumbprint()); + + using var client = new AAuthClientBuilder(ephemeralKey) .UseJktJwt(() => namingJwt) .Build(); @@ -108,7 +120,7 @@ internal static class CodeSnippets public const string TokenExchangeDirect = """ // Automatic (recommended): using var client = new AAuthClientBuilder(key) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, keyId) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build()) .WithChallengeHandling(personServer: "https://ps.example") @@ -202,17 +214,18 @@ internal static class CodeSnippets public const string FullAutomatic = """ // --- Provisioning (separate tool / CLI — run once per install) --- var keyStore = FileKeyStore.Default(); - var enrol = await AAuthClientBuilder + var enrolResult = await AAuthClientBuilder .Bootstrap("https://ap.example/enrol", "aauth:myapp@ap.example") .WithPersonServer("https://ps.example") .WithKeyStore(keyStore) // key generated inside store, never extracted .EnrolAsync(); - // Record enrol.EnrolledKeyId in app config — that's all you need + // Record enrolResult.LocalKeyHandle in app config — that's all you need - // --- Application (every startup — load key by ID) --- - var key = await keyStore.LoadAsync(keyId); - using var client = new AAuthClientBuilder(key!) - .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, keyId) + // --- Application (every startup — load key by handle) --- + var key = await keyStore.LoadAsync(localKeyHandle); + // From() auto-configures signing mode from the enrollment result + using var client = AAuthClientBuilder.From(enrolResult) + .WithTokenRefresh(AgentProviderTokenRefresher.Create(refreshEndpoint, localKeyHandle) .WithKeyStore(keyStore) .Build()) .WithChallengeHandling("https://ps.example") diff --git a/samples/GuidedTour/Components/Pages/Tour.razor b/samples/GuidedTour/Components/Pages/Tour.razor index 6bd07fd..1ff02b4 100644 --- a/samples/GuidedTour/Components/Pages/Tour.razor +++ b/samples/GuidedTour/Components/Pages/Tour.razor @@ -44,6 +44,7 @@ } @@ -91,6 +92,10 @@ Agent Identity: resource discovers agent's public key via JWKS URI — replaces API keys. break; + case SigningMode.JktJwt: + Key Rotation: + durable key signs a naming JWT binding an ephemeral signing key — enables rotation without re-enrolment. + break; } break; diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs index 336b4df..5576e51 100644 --- a/samples/GuidedTour/TourSession.cs +++ b/samples/GuidedTour/TourSession.cs @@ -25,6 +25,7 @@ public sealed class TourSession : IAsyncDisposable private readonly TourAgentIdentity _selfIdentity; private AAuthKey? _agentKey; + private AAuthKey? _ephemeralKey; private string? _agentToken; private string? _assignedKeyId; private string? _agentJwksUri; @@ -113,6 +114,7 @@ public SigningMode SigningMode private string EffectiveResourceUrl => EffectiveSigningMode switch { SigningMode.Hwk => $"{_options.WhoAmIUrl.TrimEnd('/')}/hwk", + SigningMode.JktJwt => $"{_options.WhoAmIUrl.TrimEnd('/')}/jkt-jwt", SigningMode.JwksUri => $"{_options.WhoAmIUrl.TrimEnd('/')}/jwks-uri", _ => _options.WhoAmIUrl, }; @@ -288,6 +290,7 @@ public void Reset() { ResetTimeline(); _agentKey = null; + _ephemeralKey = null; _agentToken = null; _assignedKeyId = null; _agentJwksUri = null; @@ -342,13 +345,24 @@ private HttpMessageHandler BuildSigningHandler( builder.UseHwk(); break; case SigningMode.JwksUri: + // Spec: In AP-enrolled flows, _assignedKeyId is the AP's published kid (opaque). + // In self-hosted flows (this tour), the server's own kid is used as fallback. builder.UseJwksUri( _agentJwksUri ?? $"{_selfIdentity.Issuer.TrimEnd('/')}/.well-known/jwks.json", _assignedKeyId ?? _selfIdentity.KeyId); break; case SigningMode.JktJwt: - builder.UseJktJwt(tokenFactory); - break; + // jkt-jwt: ephemeral key signs the HTTP request; durable key signs the naming JWT. + _ephemeralKey ??= AAuthKey.Generate(); + var apIssuer = _options.AgentProviderUrl!.TrimEnd('/'); + var durableThumbprint = _agentKey!.ComputeJwkThumbprint(); + builder = new AAuthClientBuilder(_ephemeralKey) + .WithInnerHandler(inner); + if (onSignatureBase is not null) + builder.OnSignatureBase(onSignatureBase); + builder.UseJktJwt(() => NamingJwtBuilder.Build( + _agentKey!, _ephemeralKey, apIssuer, durableThumbprint)); + return builder.BuildHandler(); default: builder.WithTokenRefresh(async (ctx, ct) => tokenFactory()); break; @@ -817,6 +831,13 @@ private async Task StepSignedGetAsync(CancellationToken ct) "fetches the agent's public key from that URI and learns the agent's " + "full cryptographic identity. Use for: access control by agent identity, " + "replacing API keys.", + SigningMode.JktJwt => + "The agent signs the request per RFC 9421. The Signature-Key header " + + "carries `sig=jkt-jwt` with a naming JWT and the durable key's JWK " + + "thumbprint. The naming JWT (signed by the durable key) binds the " + + "current ephemeral signing key via `cnf.jwk`. The resource verifies " + + "the HTTP signature against the ephemeral key — enabling key rotation " + + "without re-enrolment.", _ => "The agent signs the request per RFC 9421. The Signature-Key header " + "carries `sig=jwt` with the full agent token inline. The resource " + @@ -835,6 +856,7 @@ private async Task StepSignedGetAsync(CancellationToken ct) { SigningMode.Hwk => CodeSnippets.SignedGetHwk, SigningMode.JwksUri => CodeSnippets.SignedGetJwksUri, + SigningMode.JktJwt => CodeSnippets.SignedGetJktJwt, _ => CodeSnippets.SignedGetJwt, }, }); diff --git a/samples/MockAgentProvider/Program.cs b/samples/MockAgentProvider/Program.cs index 381faab..30a4de5 100644 --- a/samples/MockAgentProvider/Program.cs +++ b/samples/MockAgentProvider/Program.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); @@ -143,16 +144,18 @@ }); // ── POST /refresh — refresh an agent token ────────────────────────────────── -// Per spec: the refresh request is HTTP-signed with the durable key (hwk scheme). -// The AP verifies the signature and identifies the agent by key thumbprint. +// Per spec: supports both single-key (hwk) and two-key (jkt-jwt) refresh. +// - hwk: AP verifies signature against durable key, looks up agent by thumbprint. +// - jkt-jwt: AP verifies naming JWT (signed by durable key), verifies HTTP sig +// against ephemeral key, issues token with ephemeral key as cnf.jwk. app.MapPost("/refresh", (HttpContext ctx) => { - // Extract Signature-Key header — agent must sign with hwk scheme + // Extract Signature-Key header — agent must sign the refresh request var signatureKeyHeader = ctx.Request.Headers["Signature-Key"].FirstOrDefault(); if (string.IsNullOrEmpty(signatureKeyHeader)) return Results.Json(new JsonObject { ["error"] = "invalid_request", ["error_description"] = "Missing Signature-Key header — refresh must be signed" }, statusCode: 401); - // Parse the hwk scheme to get the agent's public key + // Parse the scheme AAuth.HttpSig.SignatureKeyParser.ParsedSignatureKeyInfo parsedKey; try { @@ -163,8 +166,8 @@ return Results.Json(new JsonObject { ["error"] = "invalid_request", ["error_description"] = "Cannot parse Signature-Key header" }, statusCode: 400); } - if (parsedKey.Scheme != "hwk" || parsedKey.ConfirmationKey is null) - return Results.Json(new JsonObject { ["error"] = "invalid_request", ["error_description"] = "Refresh requires hwk scheme with inline key" }, statusCode: 400); + if (parsedKey.Scheme is not ("hwk" or "jkt-jwt")) + return Results.Json(new JsonObject { ["error"] = "invalid_request", ["error_description"] = "Refresh requires hwk or jkt-jwt scheme" }, statusCode: 400); // Verify the HTTP signature var sigInput = ctx.Request.Headers["Signature-Input"].FirstOrDefault(); @@ -172,6 +175,58 @@ if (string.IsNullOrEmpty(sigInput) || string.IsNullOrEmpty(sigHeader)) return Results.Json(new JsonObject { ["error"] = "invalid_signature", ["error_description"] = "Missing signature headers" }, statusCode: 401); + // Determine the signing key and the durable key for enrollment lookup + IAAuthKey signingKey; + AAuthKey? ephemeralKey = null; + AgentRecord? record; + + if (parsedKey.Scheme == "hwk") + { + // Single-key: the signing key IS the durable key + if (parsedKey.ConfirmationKey is null) + return Results.Json(new JsonObject { ["error"] = "invalid_request", ["error_description"] = "hwk scheme missing inline key" }, statusCode: 400); + signingKey = parsedKey.ConfirmationKey; + + var thumbprint = signingKey.ComputeJwkThumbprint(); + record = agents.Values.FirstOrDefault(a => a.PublicKey.ComputeJwkThumbprint() == thumbprint); + } + else // jkt-jwt + { + // Two-key: naming JWT is signed by durable key, HTTP sig by ephemeral key + if (parsedKey.ConfirmationKey is null || parsedKey.Jwt is null || parsedKey.Header is null) + return Results.Json(new JsonObject { ["error"] = "invalid_request", ["error_description"] = "jkt-jwt scheme missing required fields" }, statusCode: 400); + + // The ephemeral key (from cnf.jwk in naming JWT) is what signed the HTTP request + signingKey = parsedKey.ConfirmationKey; + ephemeralKey = signingKey as AAuthKey ?? AAuthKey.FromJwk(parsedKey.Payload!["cnf"]!["jwk"]!.AsObject()); + + // Look up agent by durable key thumbprint from naming JWT's kid header + var durableKid = (string?)parsedKey.Header["kid"]; + if (string.IsNullOrEmpty(durableKid)) + return Results.Json(new JsonObject { ["error"] = "invalid_request", ["error_description"] = "Naming JWT header missing kid" }, statusCode: 400); + + // The kid in the naming JWT is the durable key's thumbprint + record = agents.Values.FirstOrDefault(a => a.PublicKey.ComputeJwkThumbprint() == durableKid); + if (record is null) + return Results.Json(new JsonObject { ["error"] = "invalid_grant", ["error_description"] = "No enrolled agent matches durable key thumbprint in naming JWT kid" }, statusCode: 400); + + // Verify the naming JWT signature against the enrolled durable key + var namingJwtParts = parsedKey.Jwt.Split('.'); + if (namingJwtParts.Length != 3) + return Results.Json(new JsonObject { ["error"] = "invalid_request", ["error_description"] = "Naming JWT is not a valid compact JWS" }, statusCode: 400); + + var signingInputBytes = System.Text.Encoding.ASCII.GetBytes(namingJwtParts[0] + "." + namingJwtParts[1]); + var namingSig = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(namingJwtParts[2]); + if (!record.PublicKey.Verify(signingInputBytes, namingSig)) + return Results.Json(new JsonObject { ["error"] = "invalid_signature", ["error_description"] = "Naming JWT signature verification failed against enrolled durable key" }, statusCode: 401); + + // Validate naming JWT expiration + var exp = (long?)parsedKey.Payload?["exp"]; + if (exp is null || DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp.Value) + return Results.Json(new JsonObject { ["error"] = "invalid_grant", ["error_description"] = "Naming JWT has expired" }, statusCode: 401); + } + + // Verify the HTTP message signature var verifier = new AAuth.HttpSig.AAuthVerifier { MaxAge = TimeSpan.FromSeconds(120) }; try { @@ -182,22 +237,36 @@ signatureKeyHeader, sigInput, sigHeader, - parsedKey.ConfirmationKey); + signingKey); } catch (AAuth.HttpSig.AAuthVerificationException ex) { return Results.Json(new JsonObject { ["error"] = "invalid_signature", ["error_description"] = ex.Message }, statusCode: 401); } - // Look up agent by key thumbprint - var thumbprint = parsedKey.ConfirmationKey.ComputeJwkThumbprint(); - var record = agents.Values.FirstOrDefault(a => a.PublicKey.ComputeJwkThumbprint() == thumbprint); + if (record is null) + { + // For hwk: look up was done above but might be null + var thumbprint = signingKey.ComputeJwkThumbprint(); + record = agents.Values.FirstOrDefault(a => a.PublicKey.ComputeJwkThumbprint() == thumbprint); + } if (record is null) return Results.Json(new JsonObject { ["error"] = "invalid_grant", ["error_description"] = "No enrolled agent matches this key" }, statusCode: 400); - // Issue fresh token - var newToken = IssueAgentToken(record); - Console.WriteLine($"[REFRESH] {record.AgentId} (verified by key thumbprint)"); + // Issue fresh token — for two-key refresh, use the ephemeral key as cnf.jwk + string newToken; + if (ephemeralKey is not null) + { + // Two-key: agent token's cnf.jwk is the NEW ephemeral key + var twoKeyRecord = record with { PublicKey = ephemeralKey }; + newToken = IssueAgentToken(twoKeyRecord); + Console.WriteLine($"[REFRESH] {record.AgentId} (two-key: verified durable key, new ephemeral key)"); + } + else + { + newToken = IssueAgentToken(record); + Console.WriteLine($"[REFRESH] {record.AgentId} (single-key: verified by key thumbprint)"); + } return Results.Json(new JsonObject { diff --git a/samples/Orchestrator/Program.cs b/samples/Orchestrator/Program.cs index a428aed..36d624b 100644 --- a/samples/Orchestrator/Program.cs +++ b/samples/Orchestrator/Program.cs @@ -161,7 +161,7 @@ await adminHttp.PostAsJsonAsync( // .WithTokenRefresh(async (_, ct) => // { // var apClient = new AgentProviderClient(new HttpClient(), keyStore!); -// return await apClient.RefreshAsync(refreshEndpoint!, enrolledKeyId!, ct); +// return await apClient.RefreshAsync(refreshEndpoint!, localKeyHandle!, ct); // }) // .WithCallChaining(ctx) // .WithChallengeHandling(opts => diff --git a/samples/SampleApp/Components/Layout/NavMenu.razor b/samples/SampleApp/Components/Layout/NavMenu.razor index 3d6f37f..a01248d 100644 --- a/samples/SampleApp/Components/Layout/NavMenu.razor +++ b/samples/SampleApp/Components/Layout/NavMenu.razor @@ -38,6 +38,12 @@ + +
diff --git a/samples/SampleApp/EnrollmentService.cs b/samples/SampleApp/EnrollmentService.cs index 85df385..11ee0c4 100644 --- a/samples/SampleApp/EnrollmentService.cs +++ b/samples/SampleApp/EnrollmentService.cs @@ -7,7 +7,7 @@ namespace SampleApp; /// /// Manages one-time enrollment with the Agent Provider. /// In production, enrollment is a separate provisioning step — only the -/// key ID is persisted. Here we enrol on first use for demo simplicity. +/// local key handle is persisted. Here we enrol on first use for demo simplicity. /// public sealed class EnrollmentService { @@ -15,7 +15,8 @@ public sealed class EnrollmentService private readonly SemaphoreSlim _semaphore = new(1, 1); private IAAuthKey? _key; - private string? _keyId; + private string? _localKeyHandle; + private string? _agentTokenKid; private string? _jwksUri; private string? _refreshEndpoint; private IKeyStore? _keyStore; @@ -26,7 +27,8 @@ public EnrollmentService(IConfiguration config) } public IAAuthKey Key => _key ?? throw new InvalidOperationException("Not enrolled yet."); - public string EnrolledKeyId => _keyId ?? throw new InvalidOperationException("Not enrolled yet."); + public string LocalKeyHandle => _localKeyHandle ?? throw new InvalidOperationException("Not enrolled yet."); + public string? AgentTokenKid => _agentTokenKid; public string? JwksUri => _jwksUri; public string RefreshEndpoint => _refreshEndpoint ?? throw new InvalidOperationException("Not enrolled yet."); public IKeyStore KeyStore => _keyStore ?? throw new InvalidOperationException("Not enrolled yet."); @@ -60,12 +62,13 @@ public async Task EnsureEnrolledAsync() var apClient = new AgentProviderClient(new HttpClient(), keyStore); var result = await apClient.EnrolAsync(apBase, agentId, enrolEndpoint, personServer); - // Deliberately discard result.AgentToken — we only keep the key ID. + // Deliberately discard result.AgentToken — we only keep the local key handle. // At runtime the SDK acquires a fresh token via the refresh endpoint // (signed with the durable key). This simulates out-of-band enrollment // where the app never sees the initial token. _key = result.Key; - _keyId = result.EnrolledKeyId; + _localKeyHandle = result.LocalKeyHandle; + _agentTokenKid = result.AgentTokenKid; _jwksUri = result.JwksUri; } finally diff --git a/samples/WhoAmI/Program.cs b/samples/WhoAmI/Program.cs index ebbe6b5..770a527 100644 --- a/samples/WhoAmI/Program.cs +++ b/samples/WhoAmI/Program.cs @@ -27,6 +27,7 @@ MaxAge = TimeSpan.FromSeconds(signatureWindowSeconds), }); builder.Services.AddSingleton(new TokenVerifier()); +builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new MetadataClient(sp.GetRequiredService().CreateClient("aauth-metadata"))); builder.Services.AddSingleton(sp => @@ -66,6 +67,15 @@ RequireIssuerVerification = false, })); +// /jkt-jwt — pseudonymous with key delegation: HTTP signature verified against +// the ephemeral key bound in the naming JWT. No issuer check needed. +app.UseWhen( + ctx => ctx.Request.Path.StartsWithSegments("/jkt-jwt"), + branch => branch.UseAAuthVerification(new AAuthVerificationOptions + { + RequireIssuerVerification = false, + })); + // /jwks-uri — agent identity: HTTP signature verified against published JWKS. app.UseWhen( ctx => ctx.Request.Path.StartsWithSegments("/jwks-uri"), @@ -78,6 +88,7 @@ app.UseWhen( ctx => !ctx.Request.Path.StartsWithSegments("/.well-known") && !ctx.Request.Path.StartsWithSegments("/hwk") + && !ctx.Request.Path.StartsWithSegments("/jkt-jwt") && !ctx.Request.Path.StartsWithSegments("/jwks-uri"), branch => branch.UseAAuthVerification(new AAuthVerificationOptions { @@ -102,6 +113,26 @@ }); }); +// ----------------------------------------------------------------------- +// GET /jkt-jwt — Pseudonymous access with key delegation. +// The naming JWT proves delegation from a hardware-backed durable key to +// an ephemeral signing key. The resource identifies the agent by the +// durable key's JWK thumbprint (jkt). +// ----------------------------------------------------------------------- +app.MapGet("/jkt-jwt", (HttpContext ctx) => +{ + var parsed = (SignatureKeyParser.ParsedSignatureKeyInfo)ctx.Items[ + AAuthVerificationMiddleware.ParsedInfoItemKey]!; + + return Results.Ok(new + { + mode = "pseudonymous", + scheme = "jkt-jwt", + jkt = parsed.Jkt, + note = "Delegation from hardware-backed key via naming JWT — agent known by durable key thumbprint.", + }); +}); + // ----------------------------------------------------------------------- // GET /jwks-uri — Agent Identity access (agent's key verified via JWKS). // ----------------------------------------------------------------------- diff --git a/src/AAuth/Agent/AgentProviderClient.cs b/src/AAuth/Agent/AgentProviderClient.cs index 2b3ed02..c80828e 100644 --- a/src/AAuth/Agent/AgentProviderClient.cs +++ b/src/AAuth/Agent/AgentProviderClient.cs @@ -14,6 +14,14 @@ namespace AAuth.Agent; /// Handles enrollment (generating a key, registering with the AP) and /// refreshing agent tokens before expiration. /// +/// +/// The AP and the agent never share a keystore. The agent holds the durable +/// private key in its own ; the AP holds only +/// the public key, indexed in its enrollment database by JWK thumbprint. +/// At refresh time the AP identifies the agent from the HTTP signature +/// (matching the thumbprint of the bound JWK) — never from a string the agent +/// sends. See aauth-spec/draft-hardt-aauth-bootstrap.md § "Refresh Patterns". +/// public sealed class AgentProviderClient { private readonly HttpClient _http; @@ -53,9 +61,13 @@ public async Task EnrolAsync( ArgumentException.ThrowIfNullOrEmpty(agentId); ArgumentException.ThrowIfNullOrEmpty(enrollEndpoint); - // Generate a new key pair for this agent + // Generate a new key pair for this agent. + // The local handle is the durable key's JWK thumbprint (RFC 7638) — + // stable, collision-free, derivable from the key itself, and spec- + // endorsed (§ "Agent Identifier Strategies"). It is a purely local + // identifier used by IKeyStore; it is never sent to the AP. var key = AAuthKey.Generate(); - var keyId = $"{agentId}:{Guid.NewGuid():N}"; + var localKeyHandle = key.ComputeJwkThumbprint(); // Build enrollment request var request = new JsonObject @@ -69,7 +81,7 @@ public async Task EnrolAsync( } // Platform attestation if supported - var attestation = await _attestor.AttestAsync(keyId, ct); + var attestation = await _attestor.AttestAsync(localKeyHandle, ct); if (!string.IsNullOrEmpty(attestation)) { request["attestation"] = attestation; @@ -84,77 +96,116 @@ public async Task EnrolAsync( var agentToken = (string?)body["agent_token"] ?? throw new InvalidOperationException("AP enrollment response missing 'agent_token'."); - // Use the kid assigned by the AP (authoritative), falling back to locally generated one - var assignedKeyId = (string?)body["key_id"] ?? keyId; + // The AP may return an opaque "key_id" — this is the AP-internal JWT + // `kid` it uses inside the issued agent token. Receivers treat it as + // opaque (spec § "Agent Identifier Strategies") and the agent never + // needs to send it back at refresh time. We expose it on the result + // for diagnostics only; the local keystore key remains the thumbprint. + var agentTokenKid = (string?)body["key_id"]; - // Persist the key - await _keyStore.StoreAsync(assignedKeyId, key, ct); + // Persist the key under the local handle (thumbprint). + await _keyStore.StoreAsync(localKeyHandle, key, ct); return new EnrollResult { AgentToken = agentToken, - EnrolledKeyId = assignedKeyId, + LocalKeyHandle = localKeyHandle, + AgentTokenKid = agentTokenKid, Key = key, JwksUri = (string?)body["jwks_uri"], }; } /// - /// Refresh an agent token. Signs the request with the durable key per spec - /// (single-key refresh, hwk scheme). The AP identifies the agent by verifying - /// the HTTP signature and matching the JWK thumbprint against its enrollment database. + /// Request a fresh agent token from the AP using the durable key. /// + /// + /// Signs the request with the durable key per spec (single-key refresh, hwk + /// scheme). The body is empty — the AP identifies the agent by verifying the + /// HTTP signature and matching the JWK thumbprint against its enrollment + /// database. The never leaves the agent; + /// it is used only by to load the private key. + /// /// The AP's refresh/token endpoint. - /// The current agent token (informational, not required by spec). - /// Local keystore reference to load the durable signing key. + /// Agent-local handle for the durable signing key (returned from as ). /// Cancellation token. /// New agent token. public async Task RefreshAsync( string refreshEndpoint, - string currentAgentToken, - string enrolledKeyId, + string localKeyHandle, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); - ArgumentException.ThrowIfNullOrEmpty(currentAgentToken); - ArgumentException.ThrowIfNullOrEmpty(enrolledKeyId); + ArgumentException.ThrowIfNullOrEmpty(localKeyHandle); - return await RefreshCoreAsync(refreshEndpoint, enrolledKeyId, ct); + return await RefreshCoreAsync(refreshEndpoint, localKeyHandle, ct); + } + + private async Task RefreshCoreAsync( + string refreshEndpoint, + string localKeyHandle, + CancellationToken ct) + { + var key = await _keyStore.LoadAsync(localKeyHandle, ct) + ?? throw new InvalidOperationException($"Key '{localKeyHandle}' not found in store."); + + // Per spec: single-key refresh signs the POST with the durable key (hwk scheme). + // The body is empty — the AP identifies the agent via the signature. + using var signingHandler = new HttpSig.AAuthSigningHandler( + key, new HttpSig.HwkSignatureKeyProvider(key)) + { + InnerHandler = new HttpClientHandler(), + }; + using var signedClient = new HttpClient(signingHandler); + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, refreshEndpoint) + { + Content = JsonContent.Create(new JsonObject()), + }; + + using var response = await signedClient.SendAsync(httpRequest, ct); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("AP refresh response is not a JSON object."); + + return (string?)body["agent_token"] + ?? throw new InvalidOperationException("AP refresh response missing 'agent_token'."); } /// - /// Request a fresh agent token from the AP using only the durable key. - /// Used for initial token acquisition (lazy startup) when no current token exists. - /// The AP identifies the agent by verifying the HTTP signature and matching - /// the JWK thumbprint against its enrollment database. + /// Two-key (jkt-jwt) refresh per the bootstrap spec (§ Two-Key Refresh). + /// Generates a fresh ephemeral key, creates a naming JWT signed by the durable key, + /// and signs the refresh request with the ephemeral key. /// - /// The AP's refresh/token endpoint. - /// Local keystore reference to load the durable signing key. + /// The AP's refresh endpoint URL. + /// Agent-local key handle for the durable signing key. + /// The AP's issuer URL (for the naming JWT iss claim). /// Cancellation token. - /// New agent token. - public async Task RefreshAsync( + /// The new agent token and the ephemeral key used for signing. + public async Task RefreshTwoKeyAsync( string refreshEndpoint, - string enrolledKeyId, + string localKeyHandle, + string apIssuer, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); - ArgumentException.ThrowIfNullOrEmpty(enrolledKeyId); + ArgumentException.ThrowIfNullOrEmpty(localKeyHandle); + ArgumentException.ThrowIfNullOrEmpty(apIssuer); - return await RefreshCoreAsync(refreshEndpoint, enrolledKeyId, ct); - } + var durableKey = await _keyStore.LoadAsync(localKeyHandle, ct) + ?? throw new InvalidOperationException($"Key '{localKeyHandle}' not found in store."); - private async Task RefreshCoreAsync( - string refreshEndpoint, - string enrolledKeyId, - CancellationToken ct) - { - var key = await _keyStore.LoadAsync(enrolledKeyId, ct) - ?? throw new InvalidOperationException($"Key '{enrolledKeyId}' not found in store."); + // Generate fresh ephemeral key + var ephemeralKey = AAuthKey.Generate(); - // Per spec: single-key refresh signs the POST with the durable key (hwk scheme). - // The body is empty — the AP identifies the agent via the signature. + // Build naming JWT: signed by durable key, names ephemeral key via cnf.jwk + var durableThumbprint = durableKey.ComputeJwkThumbprint(); + var namingJwt = NamingJwtBuilder.Build(durableKey, ephemeralKey, apIssuer, durableThumbprint); + + // Sign the refresh request with the ephemeral key under jkt-jwt scheme using var signingHandler = new HttpSig.AAuthSigningHandler( - key, new HttpSig.HwkSignatureKeyProvider(key)) + ephemeralKey, new HttpSig.JktJwtSignatureKeyProvider(ephemeralKey, () => namingJwt)) { InnerHandler = new HttpClientHandler(), }; @@ -171,23 +222,67 @@ private async Task RefreshCoreAsync( var body = await response.Content.ReadFromJsonAsync(ct) ?? throw new InvalidOperationException("AP refresh response is not a JSON object."); - return (string?)body["agent_token"] + var agentToken = (string?)body["agent_token"] ?? throw new InvalidOperationException("AP refresh response missing 'agent_token'."); + + return new TwoKeyRefreshResult + { + AgentToken = agentToken, + EphemeralKey = ephemeralKey, + }; } } +/// Result of a two-key refresh. +public sealed class TwoKeyRefreshResult +{ + /// The new aa-agent+jwt token (whose cnf.jwk is the ephemeral key). + public required string AgentToken { get; init; } + + /// The ephemeral signing key to use until the next refresh. + public required AAuthKey EphemeralKey { get; init; } +} + /// Result of enrolling with an Agent Provider. public sealed class EnrollResult { - /// The issued agent token. + /// The issued aa-agent+jwt token. public required string AgentToken { get; init; } - /// The AP-assigned key identifier used as the local keystore reference. The AP identifies the agent by JWK thumbprint at refresh time, not by this string. - public required string EnrolledKeyId { get; init; } + /// + /// Agent-local handle for the durable private key inside . + /// Persist this in your application config so the agent can re-load the key + /// at startup (IKeyStore.LoadAsync(LocalKeyHandle)). + /// + /// + /// Defaults to the durable key's JWK thumbprint (RFC 7638). This value is + /// purely local — it never leaves the agent process. At refresh time the AP + /// identifies the agent from the HTTP signature (matching the JWK thumbprint + /// in its enrollment database), not from this string. + /// + public required string LocalKeyHandle { get; init; } - /// The generated key (for immediate use in signing). + /// The generated durable signing key (for immediate use without re-loading from the keystore). public required AAuthKey Key { get; init; } + /// + /// AP-published key identifier returned in the enrollment response (key_id field). + /// Required as the kid parameter for + /// when using jwks_uri signing mode — the receiver selects the + /// verification key from the AP's per-agent JWKS by this value. + /// For other signing modes (hwk, jwt, jkt-jwt), this + /// value is informational only. + /// + /// + /// The AP chooses this identifier (spec § "Agent Identifier Strategies": + /// "Receivers treat the identifier as opaque"). The agent never sends it + /// back at refresh time — refresh is identified by HTTP signature alone. + /// Null when the AP did not return a key_id in the enrollment response; + /// in that case jwks_uri signing mode is not available (the agent has + /// no way to know what kid the AP published the key under). + /// + public string? AgentTokenKid { get; init; } + /// /// The per-agent JWKS URI where the AP publishes this agent's public key. /// Used with scheme=jwks_uri for identity-based access. diff --git a/src/AAuth/Agent/AgentProviderTokenRefresher.cs b/src/AAuth/Agent/AgentProviderTokenRefresher.cs index 1e843f6..3853ed6 100644 --- a/src/AAuth/Agent/AgentProviderTokenRefresher.cs +++ b/src/AAuth/Agent/AgentProviderTokenRefresher.cs @@ -6,63 +6,106 @@ namespace AAuth.Agent; +/// Refresh mode for AP token refresh. +public enum RefreshMode +{ + /// + /// Single-key refresh: signs the refresh POST with the durable key under hwk scheme. + /// The AP returns a token whose cnf.jwk is the same durable key. + /// + SingleKey, + + /// + /// Two-key refresh: generates a fresh ephemeral key, creates a naming JWT signed by + /// the durable key, signs the refresh POST with the ephemeral key under jkt-jwt scheme. + /// The AP returns a token whose cnf.jwk is the new ephemeral key. + /// + TwoKey, +} + /// /// Built-in that refreshes agent tokens via an /// Agent Provider's refresh endpoint. Wraps . /// /// /// Use this for agents enrolled with an AP that need automatic token refresh. -/// The AP identifies the agent by verifying the HTTP signature against the -/// enrolled key (looked up by thumbprint). +/// +/// The AP and the agent never share a keystore. The agent holds the durable +/// private key locally in its own ; the AP holds only +/// the public key, indexed by JWK thumbprint. At refresh time the AP identifies +/// the enrolment from the HTTP signature — never from any string the agent sends. +/// /// public sealed class AgentProviderTokenRefresher : ITokenRefresher { private readonly AgentProviderClient _client; private readonly string _refreshEndpoint; - private readonly string _enrolledKeyId; + private readonly string _localKeyHandle; + private readonly RefreshMode _mode; + private readonly string? _apIssuer; + + /// + /// The latest ephemeral key produced by a two-key refresh. + /// Null when is used or before the first refresh. + /// + public AAuthKey? LatestEphemeralKey { get; private set; } /// Create a refresher that delegates to an Agent Provider. - /// HttpClient for AP communication (reused across refreshes). - /// Key store containing the agent's durable signing key. - /// The AP's refresh/token endpoint URL. - /// Local keystore reference assigned during enrollment. Used to load the private key for signing refresh requests. The AP identifies the agent by verifying the signature (matching the JWK thumbprint), not by receiving this string. - public AgentProviderTokenRefresher(HttpClient http, IKeyStore keyStore, string refreshEndpoint, string enrolledKeyId) + public AgentProviderTokenRefresher( + HttpClient http, + IKeyStore keyStore, + string refreshEndpoint, + string localKeyHandle, + RefreshMode mode = RefreshMode.SingleKey, + string? apIssuer = null) { ArgumentNullException.ThrowIfNull(http); ArgumentNullException.ThrowIfNull(keyStore); ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); - ArgumentException.ThrowIfNullOrEmpty(enrolledKeyId); + ArgumentException.ThrowIfNullOrEmpty(localKeyHandle); + if (mode == RefreshMode.TwoKey && string.IsNullOrEmpty(apIssuer)) + throw new ArgumentException("apIssuer is required for TwoKey refresh mode.", nameof(apIssuer)); _client = new AgentProviderClient(http, keyStore); _refreshEndpoint = refreshEndpoint; - _enrolledKeyId = enrolledKeyId; + _localKeyHandle = localKeyHandle; + _mode = mode; + _apIssuer = apIssuer; } /// Start building a refresher with required parameters. /// The AP's refresh/token endpoint URL. - /// Local keystore reference for the durable signing key (assigned during enrollment). - public static RefresherBuilder Create(string refreshEndpoint, string enrolledKeyId) => new(refreshEndpoint, enrolledKeyId); + /// Agent-local handle for the durable signing key (assigned during enrollment). + public static RefresherBuilder Create(string refreshEndpoint, string localKeyHandle) => new(refreshEndpoint, localKeyHandle); /// - public Task RefreshAsync(TokenRefreshContext context, CancellationToken cancellationToken) + public async Task RefreshAsync(TokenRefreshContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - return _client.RefreshAsync(_refreshEndpoint, _enrolledKeyId, cancellationToken); + if (_mode == RefreshMode.TwoKey) + { + var result = await _client.RefreshTwoKeyAsync(_refreshEndpoint, _localKeyHandle, _apIssuer!, cancellationToken); + LatestEphemeralKey = result.EphemeralKey; + return result.AgentToken; + } + return await _client.RefreshAsync(_refreshEndpoint, _localKeyHandle, cancellationToken); } /// Fluent builder for . public sealed class RefresherBuilder { private readonly string _refreshEndpoint; - private readonly string _enrolledKeyId; + private readonly string _localKeyHandle; private HttpClient? _http; private IKeyStore? _keyStore; + private RefreshMode _mode = RefreshMode.SingleKey; + private string? _apIssuer; - internal RefresherBuilder(string refreshEndpoint, string enrolledKeyId) + internal RefresherBuilder(string refreshEndpoint, string localKeyHandle) { ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); - ArgumentException.ThrowIfNullOrEmpty(enrolledKeyId); + ArgumentException.ThrowIfNullOrEmpty(localKeyHandle); _refreshEndpoint = refreshEndpoint; - _enrolledKeyId = enrolledKeyId; + _localKeyHandle = localKeyHandle; } /// Use a custom instead of creating one internally. @@ -71,9 +114,19 @@ internal RefresherBuilder(string refreshEndpoint, string enrolledKeyId) /// Use a custom instead of . public RefresherBuilder WithKeyStore(IKeyStore keyStore) { _keyStore = keyStore; return this; } + /// Set the refresh mode. Default is . + /// Refresh mode to use. + /// AP issuer URL (required for ). + public RefresherBuilder WithRefreshMode(RefreshMode mode, string? apIssuer = null) + { + _mode = mode; + _apIssuer = apIssuer; + return this; + } + /// Build the refresher. public AgentProviderTokenRefresher Build() - => new(_http ?? new HttpClient(), _keyStore ?? FileKeyStore.Default(), _refreshEndpoint, _enrolledKeyId); + => new(_http ?? new HttpClient(), _keyStore ?? FileKeyStore.Default(), _refreshEndpoint, _localKeyHandle, _mode, _apIssuer); /// Implicit conversion so the builder can be passed directly where is expected. public static implicit operator AgentProviderTokenRefresher(RefresherBuilder b) => b.Build(); diff --git a/src/AAuth/Agent/NamingJwtBuilder.cs b/src/AAuth/Agent/NamingJwtBuilder.cs new file mode 100644 index 0000000..75c635c --- /dev/null +++ b/src/AAuth/Agent/NamingJwtBuilder.cs @@ -0,0 +1,49 @@ +using System; +using System.Text.Json.Nodes; +using AAuth.Crypto; +using AAuth.Tokens; +using Microsoft.IdentityModel.Tokens; + +namespace AAuth.Agent; + +/// +/// Builds a naming JWT for two-key (jkt-jwt) refresh per the bootstrap spec +/// (§ Two-Key Refresh). The naming JWT is signed by the durable key and names +/// the new ephemeral key via its cnf.jwk claim. +/// +public static class NamingJwtBuilder +{ + /// + /// Create a naming JWT signed by that delegates + /// to . + /// + /// The agent's durable enrollment key (signs this JWT). + /// The fresh ephemeral key whose public half is embedded as cnf.jwk. + /// AP issuer URL (used as iss so the AP can verify against its own JWKS). + /// Key identifier for the JWT header (kid) — the durable key's thumbprint. + public static string Build(IAAuthKey durableKey, IAAuthKey ephemeralKey, string issuer, string kid) + { + var now = DateTimeOffset.UtcNow; + + var header = new JsonObject + { + ["alg"] = AAuthKey.Algorithm, + ["typ"] = "naming+jwt", + ["kid"] = kid, + }; + + var payload = new JsonObject + { + ["iss"] = issuer, + ["iat"] = now.ToUnixTimeSeconds(), + ["exp"] = now.Add(TimeSpan.FromMinutes(5)).ToUnixTimeSeconds(), + ["jti"] = Guid.NewGuid().ToString("N"), + ["cnf"] = new JsonObject + { + ["jwk"] = ephemeralKey.ToPublicJwk(), + }, + }; + + return JwtWriter.SignCompact(header, payload, durableKey); + } +} diff --git a/src/AAuth/Crypto/FileKeyStore.cs b/src/AAuth/Crypto/FileKeyStore.cs index 110fb57..6a3f064 100644 --- a/src/AAuth/Crypto/FileKeyStore.cs +++ b/src/AAuth/Crypto/FileKeyStore.cs @@ -113,10 +113,10 @@ public AAuthKey LoadOrCreate(string name) // ── IKeyStore async implementation ────────────────────────────────────── /// - Task IKeyStore.LoadAsync(string keyId, CancellationToken ct) + Task IKeyStore.LoadAsync(string handle, CancellationToken ct) { - ValidateName(keyId); - var path = PathFor(keyId); + ValidateName(handle); + var path = PathFor(handle); if (!File.Exists(path)) return Task.FromResult(null); @@ -125,11 +125,11 @@ public AAuthKey LoadOrCreate(string name) } /// - Task IKeyStore.StoreAsync(string keyId, IAAuthKey key, CancellationToken ct) + Task IKeyStore.StoreAsync(string handle, IAAuthKey key, CancellationToken ct) { if (key is AAuthKey concreteKey) { - Save(keyId, concreteKey); + Save(handle, concreteKey); } else { @@ -139,10 +139,10 @@ Task IKeyStore.StoreAsync(string keyId, IAAuthKey key, CancellationToken ct) } /// - Task IKeyStore.DeleteAsync(string keyId, CancellationToken ct) + Task IKeyStore.DeleteAsync(string handle, CancellationToken ct) { - ValidateName(keyId); - var path = PathFor(keyId); + ValidateName(handle); + var path = PathFor(handle); if (File.Exists(path)) File.Delete(path); return Task.CompletedTask; diff --git a/src/AAuth/Crypto/IKeyStore.cs b/src/AAuth/Crypto/IKeyStore.cs index 31d1b07..ec72709 100644 --- a/src/AAuth/Crypto/IKeyStore.cs +++ b/src/AAuth/Crypto/IKeyStore.cs @@ -10,13 +10,13 @@ namespace AAuth.Crypto; public interface IKeyStore { /// Load a key by identifier. Returns null if not found. - Task LoadAsync(string keyId, CancellationToken ct = default); + Task LoadAsync(string handle, CancellationToken ct = default); /// Store a key. Overwrites if already present. - Task StoreAsync(string keyId, IAAuthKey key, CancellationToken ct = default); + Task StoreAsync(string handle, IAAuthKey key, CancellationToken ct = default); /// Delete a key by identifier. - Task DeleteAsync(string keyId, CancellationToken ct = default); + Task DeleteAsync(string handle, CancellationToken ct = default); /// List all stored key identifiers. Task ListAsync(CancellationToken ct = default); diff --git a/src/AAuth/Crypto/InMemoryKeyStore.cs b/src/AAuth/Crypto/InMemoryKeyStore.cs index aaa85d3..6a751f5 100644 --- a/src/AAuth/Crypto/InMemoryKeyStore.cs +++ b/src/AAuth/Crypto/InMemoryKeyStore.cs @@ -15,25 +15,25 @@ public sealed class InMemoryKeyStore : IKeyStore private readonly ConcurrentDictionary _keys = new(); /// - public Task LoadAsync(string keyId, CancellationToken ct = default) + public Task LoadAsync(string handle, CancellationToken ct = default) { - _keys.TryGetValue(keyId, out var key); + _keys.TryGetValue(handle, out var key); return Task.FromResult(key); } /// - public Task StoreAsync(string keyId, IAAuthKey key, CancellationToken ct = default) + public Task StoreAsync(string handle, IAAuthKey key, CancellationToken ct = default) { - ArgumentException.ThrowIfNullOrEmpty(keyId); + ArgumentException.ThrowIfNullOrEmpty(handle); ArgumentNullException.ThrowIfNull(key); - _keys[keyId] = key; + _keys[handle] = key; return Task.CompletedTask; } /// - public Task DeleteAsync(string keyId, CancellationToken ct = default) + public Task DeleteAsync(string handle, CancellationToken ct = default) { - _keys.TryRemove(keyId, out _); + _keys.TryRemove(handle, out _); return Task.CompletedTask; } diff --git a/src/AAuth/HttpSig/AAuthClientBuilder.cs b/src/AAuth/HttpSig/AAuthClientBuilder.cs index 91d9d19..496e892 100644 --- a/src/AAuth/HttpSig/AAuthClientBuilder.cs +++ b/src/AAuth/HttpSig/AAuthClientBuilder.cs @@ -44,6 +44,26 @@ public static BootstrapBuilder Bootstrap(string enrollEndpoint, string agentId) return new BootstrapBuilder(enrollEndpoint, agentId); } + /// + /// Create a builder pre-configured from an . + /// If the enrollment includes a JwksUri and AgentTokenKid, + /// the builder is configured for jwks_uri signing mode. + /// Callers may chain additional methods (e.g. + /// for JWT mode, ) which will + /// override the default signing mode. + /// + /// The enrollment result from . + public static AAuthClientBuilder From(EnrollResult result) + { + ArgumentNullException.ThrowIfNull(result); + var builder = new AAuthClientBuilder(result.Key); + if (result.JwksUri is not null && result.AgentTokenKid is not null) + { + builder.UseJwksUri(result.JwksUri, result.AgentTokenKid); + } + return builder; + } + // Challenge handling state private bool _challengeHandling; private string? _personServer; diff --git a/src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs b/src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs index 0ce7586..1051c85 100644 --- a/src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs +++ b/src/AAuth/HttpSig/DefaultSignatureKeyResolver.cs @@ -117,15 +117,8 @@ private async Task ResolveJktJwtAsync( throw new AAuthVerificationException( "Signature-Key jkt-jwt scheme: naming JWT 'iss' must be an absolute https:// URL (or http://localhost)."); - // Validate expiration — an expired naming JWT must not be trusted. - var expNode = info.Payload["exp"]; - if (expNode is not null) - { - var exp = DateTimeOffset.FromUnixTimeSeconds(expNode.GetValue()); - if (exp < DateTimeOffset.UtcNow) - throw new AAuthVerificationException( - "Signature-Key jkt-jwt scheme: naming JWT has expired."); - } + // Note: expiration is validated by the middleware (clock-skew-aware), + // not here in the resolver. The resolver's job is key resolution only. var kid = (string?)info.Header["kid"]; if (string.IsNullOrEmpty(kid)) diff --git a/src/AAuth/Server/AAuthVerificationMiddleware.cs b/src/AAuth/Server/AAuthVerificationMiddleware.cs index a05d177..4ab7f1f 100644 --- a/src/AAuth/Server/AAuthVerificationMiddleware.cs +++ b/src/AAuth/Server/AAuthVerificationMiddleware.cs @@ -157,6 +157,24 @@ storeObj is IJtiStore jtiStore && } } + // Naming JWT expiration check: for jkt-jwt scheme, reject expired naming JWTs + // regardless of RequireIssuerVerification. The naming JWT has a short lifetime + // (typically 5 min) to limit the window of delegation from the durable key. + if (parsedInfo.Scheme == "jkt-jwt" && + parsedInfo.Payload?["exp"] is JsonNode expClaim) + { + var now = (_options.Clock ?? (() => DateTimeOffset.UtcNow))(); + var expTime = DateTimeOffset.FromUnixTimeSeconds(expClaim.GetValue()); + if (now > expTime + _options.ClockSkew) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers[SignatureError.HeaderName] = + SignatureError.Format(SignatureErrorCode.InvalidJwt); + context.Response.Headers["AAuth-Error"] = "Naming JWT has expired."; + return; + } + } + // Step 4-6: JWT issuer verification (for jwt and jkt-jwt schemes with carrier tokens). if (_options.RequireIssuerVerification && parsedInfo.Scheme is "jwt" or "jkt-jwt" && diff --git a/tests/AAuth.Conformance/HttpSignatures/JktJwtAndEcdsaTests.cs b/tests/AAuth.Conformance/HttpSignatures/JktJwtAndEcdsaTests.cs index d7ef2ad..4fe110f 100644 --- a/tests/AAuth.Conformance/HttpSignatures/JktJwtAndEcdsaTests.cs +++ b/tests/AAuth.Conformance/HttpSignatures/JktJwtAndEcdsaTests.cs @@ -328,8 +328,8 @@ public async Task JktJwtThumbprintMismatchRejected() metadataHost.Dispose(); } - [Fact(DisplayName = "§jkt-jwt — expired naming JWT rejected")] - public async Task JktJwtExpiredNamingJwtRejected() + [Fact(DisplayName = "§jkt-jwt — resolver returns key even when naming JWT is expired (exp enforced by middleware)")] + public async Task JktJwtExpiredNamingJwt_ResolverStillReturnsKey() { var durableKey = AAuthKey.Generate(); var ephemeralKey = AAuthKey.Generate(); @@ -346,9 +346,9 @@ public async Task JktJwtExpiredNamingJwtRejected() var signatureKeyHeader = $"sig=jkt-jwt;jkt=\"{jkt}\";jwt=\"{namingJwt}\""; var info = SignatureKeyParser.ParseAny(signatureKeyHeader); - var ex = await Assert.ThrowsAsync(() => - resolver.ResolveAsync(info)); - Assert.Contains("expired", ex.Message); + // Resolver should succeed — exp is validated by the middleware, not here. + var resolution = await resolver.ResolveAsync(info); + Assert.NotNull(resolution.PublicKey); await metadataHost.StopAsync(); metadataHost.Dispose(); diff --git a/tests/AAuth.Conformance/HttpSignatures/NamingJwtValidationTests.cs b/tests/AAuth.Conformance/HttpSignatures/NamingJwtValidationTests.cs new file mode 100644 index 0000000..ed5deab --- /dev/null +++ b/tests/AAuth.Conformance/HttpSignatures/NamingJwtValidationTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using AAuth.Crypto; +using AAuth.DependencyInjection; +using AAuth.HttpSig; +using AAuth.Server; +using AAuth.Tokens; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace AAuth.Conformance.HttpSignatures; + +/// +/// Tests for naming JWT validation in the jkt-jwt scheme: +/// - exp (expiration) enforcement +/// - jti (replay detection) when IJtiStore is registered +/// +public class NamingJwtValidationTests : IAsyncLifetime +{ + private static readonly DateTimeOffset FixedClock = new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero); + + private readonly AAuthKey _durableKey = AAuthKey.Generate(); + private readonly AAuthKey _ephemeralKey = AAuthKey.Generate(); + + private IHost? _host; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddSingleton(new AAuthVerifier { Clock = () => FixedClock }); + builder.Services.AddSingleton(); + var app = builder.Build(); + app.UseAAuthVerification(new AAuthVerificationOptions + { + RequireIssuerVerification = false, + Clock = () => FixedClock, + ClockSkew = TimeSpan.FromSeconds(30), + }); + app.MapGet("/jkt-jwt", () => Results.Ok("ok")); + await app.StartAsync(); + _host = app; + } + + public async Task DisposeAsync() + { + if (_host is not null) { await _host.StopAsync(); _host.Dispose(); } + } + + private HttpClient Client => _host!.GetTestClient(); + + [Fact(DisplayName = "§jkt-jwt — valid naming JWT with future exp succeeds")] + public async Task ValidNamingJwt_Succeeds() + { + var namingJwt = BuildNamingJwt(exp: FixedClock.AddMinutes(5)); + var response = await SendSignedRequest(namingJwt); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(DisplayName = "§jkt-jwt — expired naming JWT returns 401")] + public async Task ExpiredNamingJwt_Returns401() + { + // exp is 2 minutes in the past (beyond 30s clock skew) + var namingJwt = BuildNamingJwt(exp: FixedClock.AddMinutes(-2)); + var response = await SendSignedRequest(namingJwt); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact(DisplayName = "§jkt-jwt — naming JWT expired within clock skew still succeeds")] + public async Task NamingJwtExpiredWithinClockSkew_Succeeds() + { + // exp is 10 seconds in the past (within 30s clock skew) + var namingJwt = BuildNamingJwt(exp: FixedClock.AddSeconds(-10)); + var response = await SendSignedRequest(namingJwt); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(DisplayName = "§jkt-jwt — replay detection rejects duplicate jti")] + public async Task DuplicateJti_Returns401() + { + var fixedJti = "replay-test-jti-12345"; + var namingJwt = BuildNamingJwt(exp: FixedClock.AddMinutes(5), jti: fixedJti); + + // First request succeeds + var response1 = await SendSignedRequest(namingJwt); + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + + // Second request with same jti is rejected + var response2 = await SendSignedRequest(namingJwt); + Assert.Equal(HttpStatusCode.Unauthorized, response2.StatusCode); + } + + [Fact(DisplayName = "§jkt-jwt — different jti values both succeed")] + public async Task DifferentJti_BothSucceed() + { + var jwt1 = BuildNamingJwt(exp: FixedClock.AddMinutes(5), jti: "unique-1"); + var jwt2 = BuildNamingJwt(exp: FixedClock.AddMinutes(5), jti: "unique-2"); + + var response1 = await SendSignedRequest(jwt1); + Assert.Equal(HttpStatusCode.OK, response1.StatusCode); + + var response2 = await SendSignedRequest(jwt2); + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private string BuildNamingJwt(DateTimeOffset exp, string? jti = null) + { + var header = new JsonObject + { + ["alg"] = AAuthKey.Algorithm, + ["typ"] = "naming+jwt", + ["kid"] = _durableKey.ComputeJwkThumbprint(), + }; + + var payload = new JsonObject + { + ["iss"] = "https://ap.example", + ["iat"] = FixedClock.ToUnixTimeSeconds(), + ["exp"] = exp.ToUnixTimeSeconds(), + ["jti"] = jti ?? Guid.NewGuid().ToString("N"), + ["cnf"] = new JsonObject + { + ["jwk"] = _ephemeralKey.ToPublicJwk(), + }, + }; + + return JwtWriter.SignCompact(header, payload, _durableKey); + } + + private async Task SendSignedRequest(string namingJwt) + { + // Sign a request targeting the test server's host + var capture = new CaptureHandler(); + var signingHandler = new AAuthSigningHandler( + _ephemeralKey, + new JktJwtSignatureKeyProvider(_ephemeralKey, () => namingJwt), + () => FixedClock) + { + InnerHandler = capture, + }; + using var signingClient = new HttpClient(signingHandler); + await signingClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://localhost/jkt-jwt")); + var signed = capture.Captured!; + + // Relay the signed headers to the test server + var relay = new HttpRequestMessage(HttpMethod.Get, "http://localhost/jkt-jwt"); + foreach (var h in signed.Headers) + relay.Headers.TryAddWithoutValidation(h.Key, h.Value); + + return await _host!.GetTestClient().SendAsync(relay); + } + + private sealed class CaptureHandler : HttpMessageHandler + { + public HttpRequestMessage? Captured { get; private set; } + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + Captured = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } +} diff --git a/tests/AAuth.Tests/Agent/AgentProviderTokenRefresherTests.cs b/tests/AAuth.Tests/Agent/AgentProviderTokenRefresherTests.cs index d89a816..255d7d3 100644 --- a/tests/AAuth.Tests/Agent/AgentProviderTokenRefresherTests.cs +++ b/tests/AAuth.Tests/Agent/AgentProviderTokenRefresherTests.cs @@ -34,7 +34,7 @@ public void Constructor_ThrowsOnEmptyEndpoint() } [Fact] - public void Constructor_ThrowsOnEmptyEnrolledKeyId() + public void Constructor_ThrowsOnEmptyLocalKeyHandle() { var keyStore = new InMemoryKeyStore(); Assert.Throws(() =>