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 @@
+
+