From d2f5f165f63f1c37f0dfcf3f314237d597064bf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 13:37:02 +0000 Subject: [PATCH 1/9] Add research and implementation plan for AP enrollment key naming --- .../implementation-plan.md | 87 +++++++++++++ .../research.md | 115 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 .agent/plans/2026-05-27-ap-enrollment-key-naming/implementation-plan.md create mode 100644 .agent/plans/2026-05-27-ap-enrollment-key-naming/research.md 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..d66ae65 --- /dev/null +++ b/.agent/plans/2026-05-27-ap-enrollment-key-naming/implementation-plan.md @@ -0,0 +1,87 @@ +--- +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. + +## 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`.) + +## 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. + +## 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`. + +## Validation + +* `dotnet build` and `dotnet test` from the repo root. +* Visual review of bootstrap-enrollment.md. +* `parallel_validation` before opening the PR. + +## 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. + +PR title: "Rename EnrolledKeyId → LocalKeyHandle; clarify AP enrollment key identifiers" 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..3e643b0 --- /dev/null +++ b/.agent/plans/2026-05-27-ap-enrollment-key-naming/research.md @@ -0,0 +1,115 @@ +--- +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 + +* Two-key (`jkt-jwt`) refresh: the SDK only implements single-key (`hwk`) refresh today; renaming is orthogonal to that work. +* AP-side identifier policy in the MockAgentProvider (it already does the correct thumbprint-based lookup at refresh). From 3382c18bcfdc927156d80439bd4ee1c2279ad839 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 13:40:44 +0000 Subject: [PATCH 2/9] =?UTF-8?q?Rename=20EnrolledKeyId=20=E2=86=92=20LocalK?= =?UTF-8?q?eyHandle;=20default=20to=20JWK=20thumbprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- samples/AgentConsole/Program.cs | 2 +- samples/GuidedTour/CodeSnippets.cs | 7 +- samples/Orchestrator/Program.cs | 2 +- .../SampleApp/Components/Pages/JwksUri.razor | 4 +- samples/SampleApp/Components/Pages/Jwt.razor | 2 +- samples/SampleApp/EnrollmentService.cs | 10 +- src/AAuth/Agent/AgentProviderClient.cs | 110 ++++++++++-------- .../Agent/AgentProviderTokenRefresher.cs | 34 +++--- .../Agent/AgentProviderTokenRefresherTests.cs | 2 +- 9 files changed, 97 insertions(+), 76 deletions(-) diff --git a/samples/AgentConsole/Program.cs b/samples/AgentConsole/Program.cs index c2a7403..066d582 100644 --- a/samples/AgentConsole/Program.cs +++ b/samples/AgentConsole/Program.cs @@ -142,7 +142,7 @@ var apClient = new AgentProviderClient(new HttpClient(), keyStore); var result = await apClient.EnrolAsync(apBase, subject, enrolEndpoint, personServer); key = result.Key; - keyId = result.EnrolledKeyId; + keyId = result.LocalKeyHandle; agentJwksUri = result.JwksUri; Console.WriteLine($"Enrolled successfully. Key ID: {keyId}"); diff --git a/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs index 6b41388..99d41ae 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-internal JWT `kid` (opaque; diagnostic only) // result.JwksUri — per-agent JWKS endpoint """; @@ -60,7 +61,7 @@ internal static class CodeSnippets public const string SignedGetJwksUri = """ using var client = new AAuthClientBuilder(key) - .UseJwksUri(result.JwksUri, result.EnrolledKeyId) + .UseJwksUri(result.JwksUri, result.LocalKeyHandle) .Build(); var response = await client.GetAsync("https://resource.example/data"); @@ -207,7 +208,7 @@ internal static class CodeSnippets .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 enrol.LocalKeyHandle in app config — that's all you need // --- Application (every startup — load key by ID) --- var key = await keyStore.LoadAsync(keyId); 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/Pages/JwksUri.razor b/samples/SampleApp/Components/Pages/JwksUri.razor index c704b8c..e969681 100644 --- a/samples/SampleApp/Components/Pages/JwksUri.razor +++ b/samples/SampleApp/Components/Pages/JwksUri.razor @@ -64,7 +64,7 @@ app.MapGet("/jwks-uri", (HttpContext ctx) => else {
- Enrolled. Key ID: @_enrollment!.EnrolledKeyId
+ Enrolled. Local key handle: @_enrollment!.LocalKeyHandle
JWKS URI: @_enrollment.JwksUri
@@ -138,7 +138,7 @@ else try { var key = _enrollment.Key; - var keyId = _enrollment.EnrolledKeyId; + var keyId = _enrollment.LocalKeyHandle; var jwksUri = _enrollment.JwksUri ?? $"{Config["AAuth:AgentProvider"]!.TrimEnd('/')}/agents/{Config["AAuth:AgentId"]}/jwks.json"; diff --git a/samples/SampleApp/Components/Pages/Jwt.razor b/samples/SampleApp/Components/Pages/Jwt.razor index 929a69f..920e813 100644 --- a/samples/SampleApp/Components/Pages/Jwt.razor +++ b/samples/SampleApp/Components/Pages/Jwt.razor @@ -27,7 +27,7 @@ var result = await apClient.EnrolAsync( apBase, agentId, enrolEndpoint, personServer); // Runtime — build signed client -var key = await keyStore.LoadAsync(result.EnrolledKeyId); +var key = await keyStore.LoadAsync(result.LocalKeyHandle); using var client = new AAuthClientBuilder(key) .WithTokenRefresh(async (ctx, ct) => diff --git a/samples/SampleApp/EnrollmentService.cs b/samples/SampleApp/EnrollmentService.cs index 85df385..d34e949 100644 --- a/samples/SampleApp/EnrollmentService.cs +++ b/samples/SampleApp/EnrollmentService.cs @@ -7,7 +7,7 @@ namespace SampleApp; /// /// Manages one-time enrollment with the Agent Provider. /// In production, enrollment is a separate provisioning step — only the -/// key ID is persisted. Here we enrol on first use for demo simplicity. +/// local key handle is persisted. Here we enrol on first use for demo simplicity. /// public sealed class EnrollmentService { @@ -15,7 +15,7 @@ public sealed class EnrollmentService private readonly SemaphoreSlim _semaphore = new(1, 1); private IAAuthKey? _key; - private string? _keyId; + private string? _localKeyHandle; private string? _jwksUri; private string? _refreshEndpoint; private IKeyStore? _keyStore; @@ -26,7 +26,7 @@ public EnrollmentService(IConfiguration config) } public IAAuthKey Key => _key ?? throw new InvalidOperationException("Not enrolled yet."); - public string EnrolledKeyId => _keyId ?? throw new InvalidOperationException("Not enrolled yet."); + public string LocalKeyHandle => _localKeyHandle ?? throw new InvalidOperationException("Not enrolled yet."); public string? JwksUri => _jwksUri; public string RefreshEndpoint => _refreshEndpoint ?? throw new InvalidOperationException("Not enrolled yet."); public IKeyStore KeyStore => _keyStore ?? throw new InvalidOperationException("Not enrolled yet."); @@ -60,12 +60,12 @@ public async Task EnsureEnrolledAsync() var apClient = new AgentProviderClient(new HttpClient(), keyStore); var result = await apClient.EnrolAsync(apBase, agentId, enrolEndpoint, personServer); - // Deliberately discard result.AgentToken — we only keep the key ID. + // Deliberately discard result.AgentToken — we only keep the local key handle. // At runtime the SDK acquires a fresh token via the refresh endpoint // (signed with the durable key). This simulates out-of-band enrollment // where the app never sees the initial token. _key = result.Key; - _keyId = result.EnrolledKeyId; + _localKeyHandle = result.LocalKeyHandle; _jwksUri = result.JwksUri; } finally diff --git a/src/AAuth/Agent/AgentProviderClient.cs b/src/AAuth/Agent/AgentProviderClient.cs index 2b3ed02..6021651 100644 --- a/src/AAuth/Agent/AgentProviderClient.cs +++ b/src/AAuth/Agent/AgentProviderClient.cs @@ -14,6 +14,14 @@ namespace AAuth.Agent; /// Handles enrollment (generating a key, registering with the AP) and /// refreshing agent tokens before expiration. /// +/// +/// The AP and the agent never share a keystore. The agent holds the durable +/// private key in its own ; the AP holds only +/// the public key, indexed in its enrollment database by JWK thumbprint. +/// At refresh time the AP identifies the agent from the HTTP signature +/// (matching the thumbprint of the bound JWK) — never from a string the agent +/// sends. See aauth-spec/draft-hardt-aauth-bootstrap.md § "Refresh Patterns". +/// public sealed class AgentProviderClient { private readonly HttpClient _http; @@ -53,9 +61,13 @@ public async Task EnrolAsync( ArgumentException.ThrowIfNullOrEmpty(agentId); ArgumentException.ThrowIfNullOrEmpty(enrollEndpoint); - // Generate a new key pair for this agent + // Generate a new key pair for this agent. + // The local handle is the durable key's JWK thumbprint (RFC 7638) — + // stable, collision-free, derivable from the key itself, and spec- + // endorsed (§ "Agent Identifier Strategies"). It is a purely local + // identifier used by IKeyStore; it is never sent to the AP. var key = AAuthKey.Generate(); - var keyId = $"{agentId}:{Guid.NewGuid():N}"; + var localKeyHandle = key.ComputeJwkThumbprint(); // Build enrollment request var request = new JsonObject @@ -69,7 +81,7 @@ public async Task EnrolAsync( } // Platform attestation if supported - var attestation = await _attestor.AttestAsync(keyId, ct); + var attestation = await _attestor.AttestAsync(localKeyHandle, ct); if (!string.IsNullOrEmpty(attestation)) { request["attestation"] = attestation; @@ -84,72 +96,58 @@ public async Task EnrolAsync( var agentToken = (string?)body["agent_token"] ?? throw new InvalidOperationException("AP enrollment response missing 'agent_token'."); - // Use the kid assigned by the AP (authoritative), falling back to locally generated one - var assignedKeyId = (string?)body["key_id"] ?? keyId; + // The AP may return an opaque "key_id" — this is the AP-internal JWT + // `kid` it uses inside the issued agent token. Receivers treat it as + // opaque (spec § "Agent Identifier Strategies") and the agent never + // needs to send it back at refresh time. We expose it on the result + // for diagnostics only; the local keystore key remains the thumbprint. + var agentTokenKid = (string?)body["key_id"]; - // Persist the key - await _keyStore.StoreAsync(assignedKeyId, key, ct); + // Persist the key under the local handle (thumbprint). + await _keyStore.StoreAsync(localKeyHandle, key, ct); return new EnrollResult { AgentToken = agentToken, - EnrolledKeyId = assignedKeyId, + LocalKeyHandle = localKeyHandle, + AgentTokenKid = agentTokenKid, Key = key, JwksUri = (string?)body["jwks_uri"], }; } /// - /// Refresh an agent token. Signs the request with the durable key per spec - /// (single-key refresh, hwk scheme). The AP identifies the agent by verifying - /// the HTTP signature and matching the JWK thumbprint against its enrollment database. + /// Request a fresh agent token from the AP using the durable key. /// + /// + /// Signs the request with the durable key per spec (single-key refresh, hwk + /// scheme). The body is empty — the AP identifies the agent by verifying the + /// HTTP signature and matching the JWK thumbprint against its enrollment + /// database. The never leaves the agent; + /// it is used only by to load the private key. + /// /// The AP's refresh/token endpoint. - /// The current agent token (informational, not required by spec). - /// Local keystore reference to load the durable signing key. + /// Agent-local handle for the durable signing key (returned from as ). /// Cancellation token. /// New agent token. public async Task RefreshAsync( string refreshEndpoint, - string currentAgentToken, - string enrolledKeyId, + string localKeyHandle, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); - ArgumentException.ThrowIfNullOrEmpty(currentAgentToken); - ArgumentException.ThrowIfNullOrEmpty(enrolledKeyId); + ArgumentException.ThrowIfNullOrEmpty(localKeyHandle); - return await RefreshCoreAsync(refreshEndpoint, enrolledKeyId, ct); - } - - /// - /// Request a fresh agent token from the AP using only the durable key. - /// Used for initial token acquisition (lazy startup) when no current token exists. - /// The AP identifies the agent by verifying the HTTP signature and matching - /// the JWK thumbprint against its enrollment database. - /// - /// The AP's refresh/token endpoint. - /// Local keystore reference to load the durable signing key. - /// Cancellation token. - /// New agent token. - public async Task RefreshAsync( - string refreshEndpoint, - string enrolledKeyId, - CancellationToken ct = default) - { - ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); - ArgumentException.ThrowIfNullOrEmpty(enrolledKeyId); - - return await RefreshCoreAsync(refreshEndpoint, enrolledKeyId, ct); + return await RefreshCoreAsync(refreshEndpoint, localKeyHandle, ct); } private async Task RefreshCoreAsync( string refreshEndpoint, - string enrolledKeyId, + string localKeyHandle, CancellationToken ct) { - var key = await _keyStore.LoadAsync(enrolledKeyId, ct) - ?? throw new InvalidOperationException($"Key '{enrolledKeyId}' not found in store."); + var key = await _keyStore.LoadAsync(localKeyHandle, ct) + ?? throw new InvalidOperationException($"Key '{localKeyHandle}' not found in store."); // Per spec: single-key refresh signs the POST with the durable key (hwk scheme). // The body is empty — the AP identifies the agent via the signature. @@ -179,15 +177,33 @@ private async Task RefreshCoreAsync( /// Result of enrolling with an Agent Provider. public sealed class EnrollResult { - /// The issued agent token. + /// The issued aa-agent+jwt token. public required string AgentToken { get; init; } - /// The AP-assigned key identifier used as the local keystore reference. The AP identifies the agent by JWK thumbprint at refresh time, not by this string. - public required string EnrolledKeyId { get; init; } - - /// The generated key (for immediate use in signing). + /// + /// Agent-local handle for the durable private key inside . + /// Persist this in your application config so the agent can re-load the key + /// at startup (IKeyStore.LoadAsync(LocalKeyHandle)). + /// + /// + /// Defaults to the durable key's JWK thumbprint (RFC 7638). This value is + /// purely local — it never leaves the agent process. At refresh time the AP + /// identifies the agent from the HTTP signature (matching the JWK thumbprint + /// in its enrollment database), not from this string. + /// + public required string LocalKeyHandle { get; init; } + + /// The generated durable signing key (for immediate use without re-loading from the keystore). public required AAuthKey Key { get; init; } + /// + /// AP-internal opaque identifier returned by the AP in the enrollment response + /// (typically the JWT kid header on the issued agent token). Diagnostic + /// only — receivers treat it as opaque (spec § "Agent Identifier Strategies"), + /// and the agent never needs to send it back to refresh. + /// + public string? AgentTokenKid { get; init; } + /// /// The per-agent JWKS URI where the AP publishes this agent's public key. /// Used with scheme=jwks_uri for identity-based access. diff --git a/src/AAuth/Agent/AgentProviderTokenRefresher.cs b/src/AAuth/Agent/AgentProviderTokenRefresher.cs index 1e843f6..0c66e71 100644 --- a/src/AAuth/Agent/AgentProviderTokenRefresher.cs +++ b/src/AAuth/Agent/AgentProviderTokenRefresher.cs @@ -12,57 +12,61 @@ namespace AAuth.Agent; /// /// /// Use this for agents enrolled with an AP that need automatic token refresh. -/// The AP identifies the agent by verifying the HTTP signature against the -/// enrolled key (looked up by thumbprint). +/// +/// The AP and the agent never share a keystore. The agent holds the durable +/// private key locally in its own ; the AP holds only +/// the public key, indexed by JWK thumbprint. At refresh time the AP identifies +/// the enrolment from the HTTP signature — never from any string the agent sends. +/// /// public sealed class AgentProviderTokenRefresher : ITokenRefresher { private readonly AgentProviderClient _client; private readonly string _refreshEndpoint; - private readonly string _enrolledKeyId; + private readonly string _localKeyHandle; /// Create a refresher that delegates to an Agent Provider. /// HttpClient for AP communication (reused across refreshes). /// Key store containing the agent's durable signing key. /// The AP's refresh/token endpoint URL. - /// Local keystore reference assigned during enrollment. Used to load the private key for signing refresh requests. The AP identifies the agent by verifying the signature (matching the JWK thumbprint), not by receiving this string. - public AgentProviderTokenRefresher(HttpClient http, IKeyStore keyStore, string refreshEndpoint, string enrolledKeyId) + /// Agent-local handle for the durable signing key (returned from as ). Used to load the private key for signing refresh requests. This value never leaves the agent — the AP identifies the agent by verifying the HTTP signature (JWK thumbprint), not by receiving this string. + public AgentProviderTokenRefresher(HttpClient http, IKeyStore keyStore, string refreshEndpoint, string localKeyHandle) { ArgumentNullException.ThrowIfNull(http); ArgumentNullException.ThrowIfNull(keyStore); ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); - ArgumentException.ThrowIfNullOrEmpty(enrolledKeyId); + ArgumentException.ThrowIfNullOrEmpty(localKeyHandle); _client = new AgentProviderClient(http, keyStore); _refreshEndpoint = refreshEndpoint; - _enrolledKeyId = enrolledKeyId; + _localKeyHandle = localKeyHandle; } /// Start building a refresher with required parameters. /// The AP's refresh/token endpoint URL. - /// Local keystore reference for the durable signing key (assigned during enrollment). - public static RefresherBuilder Create(string refreshEndpoint, string enrolledKeyId) => new(refreshEndpoint, enrolledKeyId); + /// Agent-local handle for the durable signing key (assigned during enrollment). + public static RefresherBuilder Create(string refreshEndpoint, string localKeyHandle) => new(refreshEndpoint, localKeyHandle); /// public Task RefreshAsync(TokenRefreshContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - return _client.RefreshAsync(_refreshEndpoint, _enrolledKeyId, cancellationToken); + return _client.RefreshAsync(_refreshEndpoint, _localKeyHandle, cancellationToken); } /// Fluent builder for . public sealed class RefresherBuilder { private readonly string _refreshEndpoint; - private readonly string _enrolledKeyId; + private readonly string _localKeyHandle; private HttpClient? _http; private IKeyStore? _keyStore; - internal RefresherBuilder(string refreshEndpoint, string enrolledKeyId) + internal RefresherBuilder(string refreshEndpoint, string localKeyHandle) { ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); - ArgumentException.ThrowIfNullOrEmpty(enrolledKeyId); + ArgumentException.ThrowIfNullOrEmpty(localKeyHandle); _refreshEndpoint = refreshEndpoint; - _enrolledKeyId = enrolledKeyId; + _localKeyHandle = localKeyHandle; } /// Use a custom instead of creating one internally. @@ -73,7 +77,7 @@ internal RefresherBuilder(string refreshEndpoint, string enrolledKeyId) /// Build the refresher. public AgentProviderTokenRefresher Build() - => new(_http ?? new HttpClient(), _keyStore ?? FileKeyStore.Default(), _refreshEndpoint, _enrolledKeyId); + => new(_http ?? new HttpClient(), _keyStore ?? FileKeyStore.Default(), _refreshEndpoint, _localKeyHandle); /// Implicit conversion so the builder can be passed directly where is expected. public static implicit operator AgentProviderTokenRefresher(RefresherBuilder b) => b.Build(); diff --git a/tests/AAuth.Tests/Agent/AgentProviderTokenRefresherTests.cs b/tests/AAuth.Tests/Agent/AgentProviderTokenRefresherTests.cs index d89a816..255d7d3 100644 --- a/tests/AAuth.Tests/Agent/AgentProviderTokenRefresherTests.cs +++ b/tests/AAuth.Tests/Agent/AgentProviderTokenRefresherTests.cs @@ -34,7 +34,7 @@ public void Constructor_ThrowsOnEmptyEndpoint() } [Fact] - public void Constructor_ThrowsOnEmptyEnrolledKeyId() + public void Constructor_ThrowsOnEmptyLocalKeyHandle() { var keyStore = new InMemoryKeyStore(); Assert.Throws(() => From 6614635bb63881057943b8e4d47e506103d41313 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 13:44:18 +0000 Subject: [PATCH 3/9] Update docs to use LocalKeyHandle and clarify AP/agent key separation --- docs/concepts.md | 2 +- docs/getting-started.md | 25 +++---- docs/reference/dependency-injection.md | 20 +++--- docs/signing-modes/agent-token-jwt.md | 2 +- docs/workflows/bootstrap-enrollment.md | 83 +++++++++++++---------- docs/workflows/call-chaining.md | 6 +- docs/workflows/deferred-consent.md | 4 +- docs/workflows/federated-access.md | 4 +- docs/workflows/identity-based-access.md | 2 +- docs/workflows/ps-asserted-access.md | 14 ++-- docs/workflows/resource-managed-access.md | 2 +- 11 files changed, 89 insertions(+), 75 deletions(-) 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..454710a 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") @@ -182,16 +182,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-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/workflows/bootstrap-enrollment.md b/docs/workflows/bootstrap-enrollment.md index 1569a95..c8dc08e 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-internal JWT `kid` (opaque; diagnostic only) +// 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. +- Optionally, an **AP-internal JWT `kid`** (`EnrollResult.AgentTokenKid`) carried inside the issued agent token — opaque to receivers and diagnostic-only for the agent. - 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,24 +144,24 @@ 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(); @@ -178,29 +184,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-internal JWT `kid`** (`EnrollResult.AgentTokenKid`) | the AP picks (opaque) | inside the issued `aa-agent+jwt` header | Opaque to receivers (spec § "Agent Identifier Strategies"); diagnostic-only for the agent — **the agent never sends this back to refresh** | -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 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 => { From e7783fe2a73563b100983b33c16152b04b731935 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 13:59:58 +0000 Subject: [PATCH 4/9] AgentConsole: use AP-published kid (AgentTokenKid) for jwks_uri scheme --- samples/AgentConsole/Program.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/samples/AgentConsole/Program.cs b/samples/AgentConsole/Program.cs index 066d582..ce74453 100644 --- a/samples/AgentConsole/Program.cs +++ b/samples/AgentConsole/Program.cs @@ -105,6 +105,7 @@ IAAuthKey key; string keyId; +string? agentTokenKid; string? agentJwksUri; string refreshEndpoint; @@ -120,6 +121,7 @@ { var cached = JsonNode.Parse(File.ReadAllText(enrollCacheFile))!; keyId = (string)cached["key_id"]!; + agentTokenKid = (string?)cached["agent_token_kid"]; agentJwksUri = (string?)cached["jwks_uri"]; refreshEndpoint = (string)cached["refresh_endpoint"]!; @@ -143,6 +145,7 @@ var result = await apClient.EnrolAsync(apBase, subject, enrolEndpoint, personServer); key = result.Key; keyId = result.LocalKeyHandle; + agentTokenKid = result.AgentTokenKid; agentJwksUri = result.JwksUri; Console.WriteLine($"Enrolled successfully. Key ID: {keyId}"); @@ -151,6 +154,7 @@ File.WriteAllText(enrollCacheFile, JsonSerializer.Serialize(new { key_id = keyId, + agent_token_kid = agentTokenKid, jwks_uri = agentJwksUri, refresh_endpoint = refreshEndpoint, })); @@ -173,7 +177,13 @@ break; case "jwks_uri": var jwksUrl = agentJwksUri ?? $"{apUrl.TrimEnd('/')}/agents/{subject}/jwks.json"; - builder.UseJwksUri(jwksUrl, keyId); + // Per spec (§ Signature Verification), the receiver looks up the key in + // the JWKS by `kid`. Use the AP-published kid (AgentTokenKid) when + // available — the local key handle is the JWK thumbprint which does not + // generally match the AP's published kid. Fall back to the thumbprint + // only when the AP returned no kid (in which case the AP is expected to + // publish the JWK keyed by its thumbprint). + builder.UseJwksUri(jwksUrl, agentTokenKid ?? keyId); break; case "jkt-jwt": var jktEndpoint = refreshEndpoint; From 8e372a10036e93ad50643864bbcfadd425488db8 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Wed, 27 May 2026 17:46:05 +0000 Subject: [PATCH 5/9] Refactor key management and enhance jwks_uri support - Updated IKeyStore interface methods to use local key handles instead of key IDs for better clarity and consistency. - Enhanced documentation to clarify the usage of the local key handle and the importance of the AP-published kid for jwks_uri signing mode. - Modified samples and documentation to reflect changes in key handling and jwks_uri integration. - Implemented two-key refresh mechanism in AgentProviderClient, allowing for more secure token refresh using ephemeral keys. - Added NamingJwtBuilder to facilitate the creation of naming JWTs for two-key refresh, ensuring compliance with the bootstrap spec. - Updated AgentProviderTokenRefresher to support both single-key and two-key refresh modes, improving flexibility in token management. --- .../implementation-plan.md | 176 ++++++++++++++++- .../research.md | 187 ++++++++++++++++++ docs/advanced/key-management.md | 8 +- docs/signing-modes/agent-identity-jwks-uri.md | 2 + docs/signing-modes/overview.md | 1 + samples/AgentConsole/Program.cs | 83 ++++---- samples/GuidedTour/CodeSnippets.cs | 25 ++- samples/MockAgentProvider/Program.cs | 95 +++++++-- .../SampleApp/Components/Pages/JwksUri.razor | 15 +- samples/SampleApp/Components/Pages/Jwt.razor | 2 +- samples/SampleApp/EnrollmentService.cs | 3 + src/AAuth/Agent/AgentProviderClient.cs | 87 +++++++- .../Agent/AgentProviderTokenRefresher.cs | 65 +++++- src/AAuth/Agent/NamingJwtBuilder.cs | 49 +++++ src/AAuth/Crypto/FileKeyStore.cs | 16 +- src/AAuth/Crypto/IKeyStore.cs | 6 +- src/AAuth/Crypto/InMemoryKeyStore.cs | 14 +- src/AAuth/HttpSig/AAuthClientBuilder.cs | 20 ++ 18 files changed, 759 insertions(+), 95 deletions(-) create mode 100644 src/AAuth/Agent/NamingJwtBuilder.cs 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 index d66ae65..0ed6d31 100644 --- 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 @@ -24,6 +24,16 @@ Breaking renames are OK (alpha). * 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` @@ -32,6 +42,11 @@ Breaking renames are OK (alpha). (Sweep for any other test files touching `EnrolledKeyId`.) +### Definition of Done + +- [x] Test method renamed +- [x] `dotnet test` passes + ## Phase 3 — Samples | File | Change | @@ -45,6 +60,14 @@ Breaking renames are OK (alpha). 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: @@ -69,11 +92,155 @@ Touch-ups (rename string `EnrolledKeyId` and `AAuth:KeyId` → `AAuth:LocalKeyHa 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
+
 ## Validation
 
 * `dotnet build` and `dotnet test` from the repo root.
 * Visual review of bootstrap-enrollment.md.
-* `parallel_validation` before opening the PR.
+* All four AgentConsole signing modes (`hwk`, `jwks_uri`, `jwt`, `jkt-jwt`) return 200 against mock servers.
 
 ## Commit / PR shape
 
@@ -83,5 +250,12 @@ Single PR (alpha repo, all changes are coupled):
 2. Test updates.
 3. Sample updates.
 4. Documentation updates.
+5. Review fix-ups (kid bug, variable naming, doc gaps).
 
 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 |
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
index 3e643b0..d69001e 100644
--- a/.agent/plans/2026-05-27-ap-enrollment-key-naming/research.md
+++ b/.agent/plans/2026-05-27-ap-enrollment-key-naming/research.md
@@ -113,3 +113,190 @@ Docs:
 
 * Two-key (`jkt-jwt`) refresh: the SDK only implements single-key (`hwk`) refresh today; renaming is orthogonal to that work.
 * 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.
+
+---
+
+## 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..dadc158 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);
 }
 ```
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/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/samples/AgentConsole/Program.cs b/samples/AgentConsole/Program.cs
index ce74453..5dfdd3b 100644
--- a/samples/AgentConsole/Program.cs
+++ b/samples/AgentConsole/Program.cs
@@ -104,13 +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(
@@ -120,14 +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
 {
@@ -144,23 +144,23 @@
     var apClient = new AgentProviderClient(new HttpClient(), keyStore);
     var result = await apClient.EnrolAsync(apBase, subject, enrolEndpoint, personServer);
     key = result.Key;
-    keyId = result.LocalKeyHandle;
+    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();
 
@@ -177,36 +177,40 @@
         break;
     case "jwks_uri":
         var jwksUrl = agentJwksUri ?? $"{apUrl.TrimEnd('/')}/agents/{subject}/jwks.json";
-        // Per spec (§ Signature Verification), the receiver looks up the key in
-        // the JWKS by `kid`. Use the AP-published kid (AgentTokenKid) when
-        // available — the local key handle is the JWK thumbprint which does not
-        // generally match the AP's published kid. Fall back to the thumbprint
-        // only when the AP returned no kid (in which case the AP is expected to
-        // publish the JWK keyed by its thumbprint).
-        builder.UseJwksUri(jwksUrl, agentTokenKid ?? 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;
@@ -246,8 +250,21 @@
 
 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"),
+        "jwks_uri" => new Uri(url, "/jwks-uri"),
+        _ => url, // jwt and jkt-jwt use 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 99d41ae..80ad29e 100644
--- a/samples/GuidedTour/CodeSnippets.cs
+++ b/samples/GuidedTour/CodeSnippets.cs
@@ -40,7 +40,7 @@ internal static class CodeSnippets
         // result.Key             — Ed25519 signing key
         // result.AgentToken      — aa-agent+jwt from the AP
         // result.LocalKeyHandle  — agent-local IKeyStore handle (defaults to the durable key's JWK thumbprint)
-        // result.AgentTokenKid   — AP-internal JWT `kid` (opaque; diagnostic only)
+        // result.AgentTokenKid   — AP-published kid (required for jwks_uri mode)
         // result.JwksUri         — per-agent JWKS endpoint
         """;
 
@@ -60,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.LocalKeyHandle)
+            .UseJwksUri(result.JwksUri!, kid)
             .Build();
 
         var response = await client.GetAsync("https://resource.example/data");
@@ -70,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();
@@ -109,7 +113,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")
@@ -203,17 +207,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.LocalKeyHandle 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/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/SampleApp/Components/Pages/JwksUri.razor b/samples/SampleApp/Components/Pages/JwksUri.razor
index e969681..76d2d69 100644
--- a/samples/SampleApp/Components/Pages/JwksUri.razor
+++ b/samples/SampleApp/Components/Pages/JwksUri.razor
@@ -19,11 +19,12 @@
 
Client Code
-
// After enrollment, the AP provides a jwks_uri
+
// After enrollment, the AP provides a jwks_uri and key_id
 var jwksUri = enrollment.JwksUri;
+var kid = enrollment.AgentTokenKid; // AP-published kid
 
 using var client = new AAuthClientBuilder(key)
-    .UseJwksUri(jwksUri, keyId)
+    .UseJwksUri(jwksUri, kid)
     .Build();
@@ -138,13 +139,19 @@ else try { var key = _enrollment.Key; - var keyId = _enrollment.LocalKeyHandle; + // Per spec, the kid must match what the AP publishes in its per-agent + // JWKS. The AP returns this as key_id at enrollment (AgentTokenKid). + // There is no valid fallback — if the AP didn't provide a key_id, + // it doesn't support jwks_uri identity mode. + var kid = _enrollment.AgentTokenKid + ?? throw new InvalidOperationException( + "Cannot use jwks_uri mode: AP did not return a key_id at enrollment."); var jwksUri = _enrollment.JwksUri ?? $"{Config["AAuth:AgentProvider"]!.TrimEnd('/')}/agents/{Config["AAuth:AgentId"]}/jwks.json"; // Build client with jwks_uri mode — identity-based, no token exchange using var client = new AAuthClientBuilder(key) - .UseJwksUri(jwksUri, keyId) + .UseJwksUri(jwksUri, kid) .Build(); var resourceUrl = Config["AAuth:Resource"]!; diff --git a/samples/SampleApp/Components/Pages/Jwt.razor b/samples/SampleApp/Components/Pages/Jwt.razor index 920e813..7dc39e9 100644 --- a/samples/SampleApp/Components/Pages/Jwt.razor +++ b/samples/SampleApp/Components/Pages/Jwt.razor @@ -35,7 +35,7 @@ using var client = new AAuthClientBuilder(key) var ap = new AgentProviderClient( new HttpClient(), keyStore); return await ap.RefreshAsync( - refreshEndpoint, keyId, ct); + refreshEndpoint, localKeyHandle, ct); }) .WithChallengeHandling(personServer) .Build();
diff --git a/samples/SampleApp/EnrollmentService.cs b/samples/SampleApp/EnrollmentService.cs index d34e949..11ee0c4 100644 --- a/samples/SampleApp/EnrollmentService.cs +++ b/samples/SampleApp/EnrollmentService.cs @@ -16,6 +16,7 @@ public sealed class EnrollmentService private IAAuthKey? _key; private string? _localKeyHandle; + private string? _agentTokenKid; private string? _jwksUri; private string? _refreshEndpoint; private IKeyStore? _keyStore; @@ -27,6 +28,7 @@ public EnrollmentService(IConfiguration config) public IAAuthKey Key => _key ?? throw new InvalidOperationException("Not enrolled yet."); public string LocalKeyHandle => _localKeyHandle ?? throw new InvalidOperationException("Not enrolled yet."); + public string? AgentTokenKid => _agentTokenKid; public string? JwksUri => _jwksUri; public string RefreshEndpoint => _refreshEndpoint ?? throw new InvalidOperationException("Not enrolled yet."); public IKeyStore KeyStore => _keyStore ?? throw new InvalidOperationException("Not enrolled yet."); @@ -66,6 +68,7 @@ public async Task EnsureEnrolledAsync() // where the app never sees the initial token. _key = result.Key; _localKeyHandle = result.LocalKeyHandle; + _agentTokenKid = result.AgentTokenKid; _jwksUri = result.JwksUri; } finally diff --git a/src/AAuth/Agent/AgentProviderClient.cs b/src/AAuth/Agent/AgentProviderClient.cs index 6021651..c80828e 100644 --- a/src/AAuth/Agent/AgentProviderClient.cs +++ b/src/AAuth/Agent/AgentProviderClient.cs @@ -172,6 +172,75 @@ private async Task RefreshCoreAsync( return (string?)body["agent_token"] ?? throw new InvalidOperationException("AP refresh response missing 'agent_token'."); } + + /// + /// Two-key (jkt-jwt) refresh per the bootstrap spec (§ Two-Key Refresh). + /// Generates a fresh ephemeral key, creates a naming JWT signed by the durable key, + /// and signs the refresh request with the ephemeral key. + /// + /// The AP's refresh endpoint URL. + /// Agent-local key handle for the durable signing key. + /// The AP's issuer URL (for the naming JWT iss claim). + /// Cancellation token. + /// The new agent token and the ephemeral key used for signing. + public async Task RefreshTwoKeyAsync( + string refreshEndpoint, + string localKeyHandle, + string apIssuer, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); + ArgumentException.ThrowIfNullOrEmpty(localKeyHandle); + ArgumentException.ThrowIfNullOrEmpty(apIssuer); + + var durableKey = await _keyStore.LoadAsync(localKeyHandle, ct) + ?? throw new InvalidOperationException($"Key '{localKeyHandle}' not found in store."); + + // Generate fresh ephemeral key + var ephemeralKey = AAuthKey.Generate(); + + // Build naming JWT: signed by durable key, names ephemeral key via cnf.jwk + var durableThumbprint = durableKey.ComputeJwkThumbprint(); + var namingJwt = NamingJwtBuilder.Build(durableKey, ephemeralKey, apIssuer, durableThumbprint); + + // Sign the refresh request with the ephemeral key under jkt-jwt scheme + using var signingHandler = new HttpSig.AAuthSigningHandler( + ephemeralKey, new HttpSig.JktJwtSignatureKeyProvider(ephemeralKey, () => namingJwt)) + { + InnerHandler = new HttpClientHandler(), + }; + using var signedClient = new HttpClient(signingHandler); + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, refreshEndpoint) + { + Content = JsonContent.Create(new JsonObject()), + }; + + using var response = await signedClient.SendAsync(httpRequest, ct); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("AP refresh response is not a JSON object."); + + var agentToken = (string?)body["agent_token"] + ?? throw new InvalidOperationException("AP refresh response missing 'agent_token'."); + + return new TwoKeyRefreshResult + { + AgentToken = agentToken, + EphemeralKey = ephemeralKey, + }; + } +} + +/// Result of a two-key refresh. +public sealed class TwoKeyRefreshResult +{ + /// The new aa-agent+jwt token (whose cnf.jwk is the ephemeral key). + public required string AgentToken { get; init; } + + /// The ephemeral signing key to use until the next refresh. + public required AAuthKey EphemeralKey { get; init; } } /// Result of enrolling with an Agent Provider. @@ -197,11 +266,21 @@ public sealed class EnrollResult public required AAuthKey Key { get; init; } /// - /// AP-internal opaque identifier returned by the AP in the enrollment response - /// (typically the JWT kid header on the issued agent token). Diagnostic - /// only — receivers treat it as opaque (spec § "Agent Identifier Strategies"), - /// and the agent never needs to send it back to refresh. + /// AP-published key identifier returned in the enrollment response (key_id field). + /// Required as the kid parameter for + /// when using jwks_uri signing mode — the receiver selects the + /// verification key from the AP's per-agent JWKS by this value. + /// For other signing modes (hwk, jwt, jkt-jwt), this + /// value is informational only. /// + /// + /// The AP chooses this identifier (spec § "Agent Identifier Strategies": + /// "Receivers treat the identifier as opaque"). The agent never sends it + /// back at refresh time — refresh is identified by HTTP signature alone. + /// Null when the AP did not return a key_id in the enrollment response; + /// in that case jwks_uri signing mode is not available (the agent has + /// no way to know what kid the AP published the key under). + /// public string? AgentTokenKid { get; init; } /// diff --git a/src/AAuth/Agent/AgentProviderTokenRefresher.cs b/src/AAuth/Agent/AgentProviderTokenRefresher.cs index 0c66e71..3853ed6 100644 --- a/src/AAuth/Agent/AgentProviderTokenRefresher.cs +++ b/src/AAuth/Agent/AgentProviderTokenRefresher.cs @@ -6,6 +6,23 @@ namespace AAuth.Agent; +/// Refresh mode for AP token refresh. +public enum RefreshMode +{ + /// + /// Single-key refresh: signs the refresh POST with the durable key under hwk scheme. + /// The AP returns a token whose cnf.jwk is the same durable key. + /// + SingleKey, + + /// + /// Two-key refresh: generates a fresh ephemeral key, creates a naming JWT signed by + /// the durable key, signs the refresh POST with the ephemeral key under jkt-jwt scheme. + /// The AP returns a token whose cnf.jwk is the new ephemeral key. + /// + TwoKey, +} + /// /// Built-in that refreshes agent tokens via an /// Agent Provider's refresh endpoint. Wraps . @@ -24,21 +41,35 @@ public sealed class AgentProviderTokenRefresher : ITokenRefresher private readonly AgentProviderClient _client; private readonly string _refreshEndpoint; private readonly string _localKeyHandle; + private readonly RefreshMode _mode; + private readonly string? _apIssuer; + + /// + /// The latest ephemeral key produced by a two-key refresh. + /// Null when is used or before the first refresh. + /// + public AAuthKey? LatestEphemeralKey { get; private set; } /// Create a refresher that delegates to an Agent Provider. - /// HttpClient for AP communication (reused across refreshes). - /// Key store containing the agent's durable signing key. - /// The AP's refresh/token endpoint URL. - /// Agent-local handle for the durable signing key (returned from as ). Used to load the private key for signing refresh requests. This value never leaves the agent — the AP identifies the agent by verifying the HTTP signature (JWK thumbprint), not by receiving this string. - public AgentProviderTokenRefresher(HttpClient http, IKeyStore keyStore, string refreshEndpoint, string localKeyHandle) + public AgentProviderTokenRefresher( + HttpClient http, + IKeyStore keyStore, + string refreshEndpoint, + string localKeyHandle, + RefreshMode mode = RefreshMode.SingleKey, + string? apIssuer = null) { ArgumentNullException.ThrowIfNull(http); ArgumentNullException.ThrowIfNull(keyStore); ArgumentException.ThrowIfNullOrEmpty(refreshEndpoint); ArgumentException.ThrowIfNullOrEmpty(localKeyHandle); + if (mode == RefreshMode.TwoKey && string.IsNullOrEmpty(apIssuer)) + throw new ArgumentException("apIssuer is required for TwoKey refresh mode.", nameof(apIssuer)); _client = new AgentProviderClient(http, keyStore); _refreshEndpoint = refreshEndpoint; _localKeyHandle = localKeyHandle; + _mode = mode; + _apIssuer = apIssuer; } /// Start building a refresher with required parameters. @@ -47,10 +78,16 @@ public AgentProviderTokenRefresher(HttpClient http, IKeyStore keyStore, string r public static RefresherBuilder Create(string refreshEndpoint, string localKeyHandle) => new(refreshEndpoint, localKeyHandle); /// - public Task RefreshAsync(TokenRefreshContext context, CancellationToken cancellationToken) + public async Task RefreshAsync(TokenRefreshContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - return _client.RefreshAsync(_refreshEndpoint, _localKeyHandle, cancellationToken); + if (_mode == RefreshMode.TwoKey) + { + var result = await _client.RefreshTwoKeyAsync(_refreshEndpoint, _localKeyHandle, _apIssuer!, cancellationToken); + LatestEphemeralKey = result.EphemeralKey; + return result.AgentToken; + } + return await _client.RefreshAsync(_refreshEndpoint, _localKeyHandle, cancellationToken); } /// Fluent builder for . @@ -60,6 +97,8 @@ public sealed class RefresherBuilder private readonly string _localKeyHandle; private HttpClient? _http; private IKeyStore? _keyStore; + private RefreshMode _mode = RefreshMode.SingleKey; + private string? _apIssuer; internal RefresherBuilder(string refreshEndpoint, string localKeyHandle) { @@ -75,9 +114,19 @@ internal RefresherBuilder(string refreshEndpoint, string localKeyHandle) /// Use a custom instead of . public RefresherBuilder WithKeyStore(IKeyStore keyStore) { _keyStore = keyStore; return this; } + /// Set the refresh mode. Default is . + /// Refresh mode to use. + /// AP issuer URL (required for ). + public RefresherBuilder WithRefreshMode(RefreshMode mode, string? apIssuer = null) + { + _mode = mode; + _apIssuer = apIssuer; + return this; + } + /// Build the refresher. public AgentProviderTokenRefresher Build() - => new(_http ?? new HttpClient(), _keyStore ?? FileKeyStore.Default(), _refreshEndpoint, _localKeyHandle); + => new(_http ?? new HttpClient(), _keyStore ?? FileKeyStore.Default(), _refreshEndpoint, _localKeyHandle, _mode, _apIssuer); /// Implicit conversion so the builder can be passed directly where is expected. public static implicit operator AgentProviderTokenRefresher(RefresherBuilder b) => b.Build(); diff --git a/src/AAuth/Agent/NamingJwtBuilder.cs b/src/AAuth/Agent/NamingJwtBuilder.cs new file mode 100644 index 0000000..75c635c --- /dev/null +++ b/src/AAuth/Agent/NamingJwtBuilder.cs @@ -0,0 +1,49 @@ +using System; +using System.Text.Json.Nodes; +using AAuth.Crypto; +using AAuth.Tokens; +using Microsoft.IdentityModel.Tokens; + +namespace AAuth.Agent; + +/// +/// Builds a naming JWT for two-key (jkt-jwt) refresh per the bootstrap spec +/// (§ Two-Key Refresh). The naming JWT is signed by the durable key and names +/// the new ephemeral key via its cnf.jwk claim. +/// +public static class NamingJwtBuilder +{ + /// + /// Create a naming JWT signed by that delegates + /// to . + /// + /// The agent's durable enrollment key (signs this JWT). + /// The fresh ephemeral key whose public half is embedded as cnf.jwk. + /// AP issuer URL (used as iss so the AP can verify against its own JWKS). + /// Key identifier for the JWT header (kid) — the durable key's thumbprint. + public static string Build(IAAuthKey durableKey, IAAuthKey ephemeralKey, string issuer, string kid) + { + var now = DateTimeOffset.UtcNow; + + var header = new JsonObject + { + ["alg"] = AAuthKey.Algorithm, + ["typ"] = "naming+jwt", + ["kid"] = kid, + }; + + var payload = new JsonObject + { + ["iss"] = issuer, + ["iat"] = now.ToUnixTimeSeconds(), + ["exp"] = now.Add(TimeSpan.FromMinutes(5)).ToUnixTimeSeconds(), + ["jti"] = Guid.NewGuid().ToString("N"), + ["cnf"] = new JsonObject + { + ["jwk"] = ephemeralKey.ToPublicJwk(), + }, + }; + + return JwtWriter.SignCompact(header, payload, durableKey); + } +} diff --git a/src/AAuth/Crypto/FileKeyStore.cs b/src/AAuth/Crypto/FileKeyStore.cs index 110fb57..6a3f064 100644 --- a/src/AAuth/Crypto/FileKeyStore.cs +++ b/src/AAuth/Crypto/FileKeyStore.cs @@ -113,10 +113,10 @@ public AAuthKey LoadOrCreate(string name) // ── IKeyStore async implementation ────────────────────────────────────── /// - Task IKeyStore.LoadAsync(string keyId, CancellationToken ct) + Task IKeyStore.LoadAsync(string handle, CancellationToken ct) { - ValidateName(keyId); - var path = PathFor(keyId); + ValidateName(handle); + var path = PathFor(handle); if (!File.Exists(path)) return Task.FromResult(null); @@ -125,11 +125,11 @@ public AAuthKey LoadOrCreate(string name) } /// - Task IKeyStore.StoreAsync(string keyId, IAAuthKey key, CancellationToken ct) + Task IKeyStore.StoreAsync(string handle, IAAuthKey key, CancellationToken ct) { if (key is AAuthKey concreteKey) { - Save(keyId, concreteKey); + Save(handle, concreteKey); } else { @@ -139,10 +139,10 @@ Task IKeyStore.StoreAsync(string keyId, IAAuthKey key, CancellationToken ct) } /// - Task IKeyStore.DeleteAsync(string keyId, CancellationToken ct) + Task IKeyStore.DeleteAsync(string handle, CancellationToken ct) { - ValidateName(keyId); - var path = PathFor(keyId); + ValidateName(handle); + var path = PathFor(handle); if (File.Exists(path)) File.Delete(path); return Task.CompletedTask; diff --git a/src/AAuth/Crypto/IKeyStore.cs b/src/AAuth/Crypto/IKeyStore.cs index 31d1b07..ec72709 100644 --- a/src/AAuth/Crypto/IKeyStore.cs +++ b/src/AAuth/Crypto/IKeyStore.cs @@ -10,13 +10,13 @@ namespace AAuth.Crypto; public interface IKeyStore { /// Load a key by identifier. Returns null if not found. - Task LoadAsync(string keyId, CancellationToken ct = default); + Task LoadAsync(string handle, CancellationToken ct = default); /// Store a key. Overwrites if already present. - Task StoreAsync(string keyId, IAAuthKey key, CancellationToken ct = default); + Task StoreAsync(string handle, IAAuthKey key, CancellationToken ct = default); /// Delete a key by identifier. - Task DeleteAsync(string keyId, CancellationToken ct = default); + Task DeleteAsync(string handle, CancellationToken ct = default); /// List all stored key identifiers. Task ListAsync(CancellationToken ct = default); diff --git a/src/AAuth/Crypto/InMemoryKeyStore.cs b/src/AAuth/Crypto/InMemoryKeyStore.cs index aaa85d3..6a751f5 100644 --- a/src/AAuth/Crypto/InMemoryKeyStore.cs +++ b/src/AAuth/Crypto/InMemoryKeyStore.cs @@ -15,25 +15,25 @@ public sealed class InMemoryKeyStore : IKeyStore private readonly ConcurrentDictionary _keys = new(); /// - public Task LoadAsync(string keyId, CancellationToken ct = default) + public Task LoadAsync(string handle, CancellationToken ct = default) { - _keys.TryGetValue(keyId, out var key); + _keys.TryGetValue(handle, out var key); return Task.FromResult(key); } /// - public Task StoreAsync(string keyId, IAAuthKey key, CancellationToken ct = default) + public Task StoreAsync(string handle, IAAuthKey key, CancellationToken ct = default) { - ArgumentException.ThrowIfNullOrEmpty(keyId); + ArgumentException.ThrowIfNullOrEmpty(handle); ArgumentNullException.ThrowIfNull(key); - _keys[keyId] = key; + _keys[handle] = key; return Task.CompletedTask; } /// - public Task DeleteAsync(string keyId, CancellationToken ct = default) + public Task DeleteAsync(string handle, CancellationToken ct = default) { - _keys.TryRemove(keyId, out _); + _keys.TryRemove(handle, out _); return Task.CompletedTask; } diff --git a/src/AAuth/HttpSig/AAuthClientBuilder.cs b/src/AAuth/HttpSig/AAuthClientBuilder.cs index 91d9d19..496e892 100644 --- a/src/AAuth/HttpSig/AAuthClientBuilder.cs +++ b/src/AAuth/HttpSig/AAuthClientBuilder.cs @@ -44,6 +44,26 @@ public static BootstrapBuilder Bootstrap(string enrollEndpoint, string agentId) return new BootstrapBuilder(enrollEndpoint, agentId); } + /// + /// Create a builder pre-configured from an . + /// If the enrollment includes a JwksUri and AgentTokenKid, + /// the builder is configured for jwks_uri signing mode. + /// Callers may chain additional methods (e.g. + /// for JWT mode, ) which will + /// override the default signing mode. + /// + /// The enrollment result from . + public static AAuthClientBuilder From(EnrollResult result) + { + ArgumentNullException.ThrowIfNull(result); + var builder = new AAuthClientBuilder(result.Key); + if (result.JwksUri is not null && result.AgentTokenKid is not null) + { + builder.UseJwksUri(result.JwksUri, result.AgentTokenKid); + } + return builder; + } + // Challenge handling state private bool _challengeHandling; private string? _personServer; From c12cd1afca8877131316bef71b59384019d9c370 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Wed, 27 May 2026 17:51:51 +0000 Subject: [PATCH 6/9] Refactor key handling to use localKeyHandle instead of keyId in multiple components; enhance documentation for jkt-jwt signing mode and agent key management. --- docs/advanced/key-management.md | 15 ++++---- docs/getting-started.md | 16 +++++++++ docs/workflows/bootstrap-enrollment.md | 36 +++++++++++++++++-- samples/GuidedTour/CodeSnippets.cs | 13 +++++-- samples/GuidedTour/TourSession.cs | 10 ++++++ .../Components/Pages/CallChain.razor | 2 +- .../SampleApp/Components/Pages/Deferred.razor | 2 +- 7 files changed, 80 insertions(+), 14 deletions(-) diff --git a/docs/advanced/key-management.md b/docs/advanced/key-management.md index dadc158..d639362 100644 --- a/docs/advanced/key-management.md +++ b/docs/advanced/key-management.md @@ -110,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) @@ -123,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/getting-started.md b/docs/getting-started.md index 454710a..ddd17a6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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) diff --git a/docs/workflows/bootstrap-enrollment.md b/docs/workflows/bootstrap-enrollment.md index c8dc08e..07e00a6 100644 --- a/docs/workflows/bootstrap-enrollment.md +++ b/docs/workflows/bootstrap-enrollment.md @@ -122,7 +122,7 @@ var result = await apClient.EnrolAsync( // 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-internal JWT `kid` (opaque; diagnostic only) +// result.AgentTokenKid = AP-published kid for jwks_uri mode (required for UseJwksUri) // result.JwksUri = per-agent JWKS URI (for jwks_uri signing mode) ``` @@ -135,7 +135,7 @@ var result = await apClient.EnrolAsync( - `ps`: Person Server URL (optional, only if agent has a PS) - 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. -- Optionally, an **AP-internal JWT `kid`** (`EnrollResult.AgentTokenKid`) carried inside the issued agent token — opaque to receivers and diagnostic-only for the agent. +- 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 @@ -167,6 +167,35 @@ using var client = new AAuthClientBuilder(key) .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. @@ -192,7 +221,7 @@ The term "key ID" gets overloaded in AP enrollment. There are actually **three d |-----------|----------------|---------------|----------| | **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-internal JWT `kid`** (`EnrollResult.AgentTokenKid`) | the AP picks (opaque) | inside the issued `aa-agent+jwt` header | Opaque to receivers (spec § "Agent Identifier Strategies"); diagnostic-only for the agent — **the agent never sends this back to refresh** | +| **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. | For the second flavour of enrollment, **self-issued** (hosted services), only one identifier matters: @@ -234,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/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs index 80ad29e..126b94f 100644 --- a/samples/GuidedTour/CodeSnippets.cs +++ b/samples/GuidedTour/CodeSnippets.cs @@ -84,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(); diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs index 336b4df..3c65b15 100644 --- a/samples/GuidedTour/TourSession.cs +++ b/samples/GuidedTour/TourSession.cs @@ -342,6 +342,8 @@ 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); @@ -817,6 +819,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 +844,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/SampleApp/Components/Pages/CallChain.razor b/samples/SampleApp/Components/Pages/CallChain.razor index 132f0e2..665b964 100644 --- a/samples/SampleApp/Components/Pages/CallChain.razor +++ b/samples/SampleApp/Components/Pages/CallChain.razor @@ -30,7 +30,7 @@ using var client = new AAuthClientBuilder(key) var ap = new AgentProviderClient( new HttpClient(), keyStore); return await ap.RefreshAsync( - refreshEndpoint, keyId, ct); + refreshEndpoint, localKeyHandle, ct); }) .WithChallengeHandling(personServer) .Build(); diff --git a/samples/SampleApp/Components/Pages/Deferred.razor b/samples/SampleApp/Components/Pages/Deferred.razor index 44333d1..ebc4a73 100644 --- a/samples/SampleApp/Components/Pages/Deferred.razor +++ b/samples/SampleApp/Components/Pages/Deferred.razor @@ -27,7 +27,7 @@ var ap = new AgentProviderClient( new HttpClient(), keyStore); return await ap.RefreshAsync( - refreshEndpoint, keyId, ct); + refreshEndpoint, localKeyHandle, ct); }) .WithChallengeHandling(personServer, opts => { From 7a789b2498f8987cfdb590681cfa0c23c36a1a2c Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Wed, 27 May 2026 17:55:31 +0000 Subject: [PATCH 7/9] Add support for jkt-jwt signing mode in flow picker; enhance description for key rotation --- samples/GuidedTour/Components/Pages/Tour.razor | 5 +++++ 1 file changed, 5 insertions(+) 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; From c81e3c9952680c6b6ccd5823f141dbf0c80f8345 Mon Sep 17 00:00:00 2001 From: Dasith Wijes Date: Wed, 27 May 2026 18:27:57 +0000 Subject: [PATCH 8/9] Implement JKT-JWT support for key rotation; update error messages and enhance navigation --- samples/AgentConsole/Program.cs | 15 +- samples/GuidedTour/TourSession.cs | 16 +- .../SampleApp/Components/Layout/NavMenu.razor | 6 + .../Components/Layout/NavMenu.razor.css | 4 + samples/SampleApp/Components/Pages/Home.razor | 21 +- .../SampleApp/Components/Pages/JktJwt.razor | 229 ++++++++++++++++++ samples/WhoAmI/Program.cs | 30 +++ 7 files changed, 311 insertions(+), 10 deletions(-) create mode 100644 samples/SampleApp/Components/Pages/JktJwt.razor diff --git a/samples/AgentConsole/Program.cs b/samples/AgentConsole/Program.cs index 5dfdd3b..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; } @@ -258,8 +258,9 @@ targetUrl = signingMode switch { "hwk" => new Uri(url, "/hwk"), + "jkt-jwt" => new Uri(url, "/jkt-jwt"), "jwks_uri" => new Uri(url, "/jwks-uri"), - _ => url, // jwt and jkt-jwt use the root path (three-party) + _ => url, // jwt uses the root path (three-party) }; } diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs index 3c65b15..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; @@ -349,8 +352,17 @@ private HttpMessageHandler BuildSigningHandler( _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; 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 @@ + +