feat: signing-key rotation + multi-key JWKS publication (#128) - #158
Conversation
Replaces the v1 singleton-key approach with a full key-set model:
- `getAllPublicKeys()` now enumerates all rows via `table.search({})` and
publishes every key at the JWKS endpoint. Cross-node tokens verify
immediately — the clustered first-boot race is resolved without requiring
`mcp.signingKeyPem` on every node (though pinning still works and is
recommended for zero-race deployments).
- `getSigningKey()` selects the newest key by `created_at` (tie-break: `kid`
descending). The legacy `rs256-default` row is a valid set member; no
migration.
- New `mcp.keyRotationInterval` config (seconds): when set and > 0, a fresh
UUID-kid RS256 keypair is lazily generated at token mint time once the
newest key's age exceeds the interval. Old keys are GC'd once no token they
signed can be valid (`2 × accessTokenTtl` after a newer key's creation).
- `mcp.signingKeyPem` + `mcp.keyRotationInterval` are mutually exclusive: pin
wins; both-set logs a startup warning.
- Extended `Table` interface with `search(query)` returning `AsyncIterable`.
- Updated token.test.js and tokenAuditHook.test.js mock tables with `search`.
- 17 new keyStore tests covering multi-key, rotation, no-rotation default,
pin-wins, GC thresholds, GC error resilience, and legacy compat.
- `withMCPAuth` and `buildJWKS` required no changes; verified they handle
multi-key sets correctly via existing `verifyAccessTokenWithKeySet` kid
selection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Reviewed; no blockers found. The PR successfully implements robust signing-key rotation and multi-key JWKS publication for the MCP OAuth flow. The successor-age based retirement logic correctly handles token overlap windows, and the pinning support ( Suggestions (non-blocking)
|
There was a problem hiding this comment.
Code Review
This pull request implements automatic signing-key rotation and multi-key JWKS support for MCP OAuth, allowing nodes to lazily rotate keys and garbage-collect expired ones. Feedback on these changes highlights that getAllPublicKeys passes a raw error object to the logger, violating codebase conventions, and that safeEnumerate silently swallows errors, which could lead to silent failures or infinite key generation loops.
|
Reviewed; no blockers found. |
…umerate errors (review) Three review findings on #158: - GC now keys each retired key off its IMMEDIATE SUCCESSOR's created_at (Gemini/Claude/Codex blocker): the signer-age rule never fired when keyRotationInterval < 2*accessTokenTtl (signer always young), so retired keys accumulated unbounded. Successor rule ages keys out independently; conservative if a successor was itself GCd. - Generated first-boot keys get a UUID kid (Codex P1): two nodes racing an empty table both wrote rs256-default, one overwriting the other and stranding the loser's tokens with an unverifiable kid. Only the pinned signingKeyPem path keeps the fixed kid (identical content everywhere). - getSigningKey PROPAGATES enumeration errors instead of treating them as an empty table (Gemini high): a transient read error no longer mints spurious keys; JWKS reads stay best-effort ([]). Removed the safeEnumerate indirection that made getAllPublicKeys' catch dead code (Claude nit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
heskew
left a comment
There was a problem hiding this comment.
I found two issues worth fixing or clarifying before merge:
-
src/lib/mcp/keyStore.ts:182-190only consultsmcp.signingKeyPemwhen the keys table is empty. If an unpinned node has already minted a UUID key, adding the pinned PEM later never persists or selectsrs256-default; it just disables rotation while the existing generated key continues signing indefinitely. That contradicts the new "pin wins" / "one canonical key" guidance. Either make the configured PEM become the signer when present, or explicitly document/diagnose thatsigningKeyPemmust be set before the first token/key is minted. -
docs/configuration.mdstill looks stale: the MCP option table does not listmcp.keyRotationInterval, themcp.signingKeyPemrow still warns that cluster nodes can sign with keys the JWKS has not published, and the checklist still says clusters should pinsigningKeyPem. That reference should match the new multi-key JWKS and rotation behavior.
🤖 Posted by Codex (gpt-5.5) on Nathan's behalf
…docs Round 3 of PR #158 (issue #128, signing-key rotation + multi-key JWKS). Pin-wins-always: `mcp.signingKeyPem` now wins as signer regardless of what is already in the table — not just on first boot. `getOrPersistPinnedKey` searches all keys by `public_key_pem` string equality; if the pinned key is absent it persists it under `rs256-default` (when that row is free) or under a deterministic fingerprint kid `pinned-<sha256 first 16 hex>` (when `rs256-default` holds different material). Concurrent clustered puts against the same fingerprint kid are idempotent. Old keys remain in the table for JWKS overlap. setImmediate GC: all `garbageCollect` dispatches are now wrapped in `setImmediate(() => { ... })` so GC never holds the request's transaction context. Tests continue to use a single `await new Promise(r => setImmediate(r))` which is sufficient because Node.js drains the microtask queue between each setImmediate callback. Tests (keyStore.test.js): five new pin-semantics tests covering post-boot pinning, legacy-material match (no new row), fingerprint-kid with conflicting rs256-default, determinism (same PEM → same kid across store instances), and idempotency. Updated the old "pin wins over rotation" test to reflect the new behavior (pinned key persisted alongside old key, table size 2). Docs (configuration.md): added `mcp.keyRotationInterval` option row; updated `mcp.signingKeyPem` description to reflect pin-wins-always semantics and that pinning is recommended (not required) in clusters thanks to multi-key JWKS; updated the withMCPAuth security callout to reflect the same. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Both findings addressed in 18dbed7 (plus the Gemini GC-detach suggestion):
830 tests, 0 failures. 🤖 Posted by Claude on Nathan's behalf |
Gemini suggestion on #158 applied to all three post-write re-enumeration fallbacks (pinned, first-boot, rotate) so transient table read failures are diagnosable in clustered environments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Reviewed via Claude review-queue (full review: oauth-158-8f2ffee.md). Core rotation/overlap semantics are correct — old keys stay published until every token they signed expires, Thundering-herd key generation on the mint path ( GC leaks a superseded, non-signer key permanently (
Minor: the pinned-PEM public-key derivation ( Happy to talk through a single-flight lock pattern for the mint path if there's an existing Harper-wide convention you'd rather use. |
heskew
left a comment
There was a problem hiding this comment.
I found one additional issue beyond the existing review comments:
src/lib/mcp/keyStore.ts:248 returns the full persisted key set, but retirement cleanup is only scheduled from the mint path (getSigningKey / getOrPersistPinnedKey). At rotation time the old key is intentionally retained because its successor is brand new; however, if no later token mint happens after 2 * accessTokenTtl, nothing re-runs GC. withMCPAuth and buildJWKS keep trusting/publishing that retired key via getAllPublicKeys(), so anyone holding the old private key can continue producing JWTs with valid exp/iss/aud and have them accepted by kid selection indefinitely. Please run the same retirement check before returning keys for JWKS/auth verification, or schedule a delayed cleanup when a successor is created, so retired keys actually fall out of the trusted set after the overlap window.
🤖 Posted by Codex (gpt-5.5) on Nathan's behalf
…ache, pin-bump GC fix Round 5 of PR #158 (issue #128). Addresses Kris's review + Nathan's Codex pass. Single-flight guard (item 1): module-level `pendingWrite` promise serializes concurrent first-boot generation, rotation writes, and pin-persist/bump writes per process. `runSingleFlight(condition, fn)` awaits any in-flight write, re- evaluates `condition` against the result (the concurrent write likely satisfied it), and only runs `fn` with a double-checked-locking fresh enumerate when still needed. Cross-node correctness remains convergence-based (multi-key JWKS + re-enumerate after write); the guard is per-process only. Pin created_at bump — GC leak fix (item 2): when the pinned key exists but is not the newest key, `getOrPersistPinnedKey` re-persists it with `created_at = now` (same kid, same material). Without the bump the previously-newer generated key sits permanently at sorted[0] with no entry below it in the GC loop, so `garbageCollect` never reaches it. The bump makes the pin sorted[0]; the generated key now has a successor whose created_at ≈ the moment it stopped signing, and the existing successor-age GC rule cleans it up on schedule. Read-time retirement (item 3 — security-critical): `partitionRetired` helper implements the successor-age rule used by both `getAllPublicKeys` (read path, never writes) and `garbageCollect` (mint path, physical deletion). `getAllPublicKeys` gains an optional `mcpConfig` param and returns only the live set; retired keys are excluded from the JWKS and from `withMCPAuth` token verification so trust expires by time, not by traffic — a rotated-away private key can no longer forge accepted tokens once the window closes even without mint traffic. `wellKnown.ts` passes `mcpConfig` through to `getAllPublicKeys`; `withMCPAuth` passes `cfg`; `KeySource` interface updated to `getAllPublicKeys(mcpConfig?: MCPConfig)`. 5s enumeration cache (item 4): `enumerateKeys()` caches decoded records in a module-level `{ records, fetchedAt }` entry for 5 s. Every `table.put` and `table.delete` inside `MCPKeyStore` calls `invalidateEnumCache()` immediately so a just-minted key verifies on this node without waiting. The retirement filter runs per-call on the cached records (cheap CPU, time-accurate). `resetMCPKeysTableCache` clears the cache. `_setCacheNowMs(fn|null)` injectable for TTL tests (internal). Pinned public-key memoization (item 5): single-entry memo keyed by PEM string avoids re-deriving the public key on every mint when `signingKeyPem` is stable. Tests: single-flight (5 concurrent first-boot, 3 concurrent rotation → exactly 1 key each), pin-bump (seeded older pin + newer generated → bump, signer, GC regression), read-time retirement (old key excluded from JWKS, token signed by it rejected), cache (hit-count, invalidate-on-write, TTL expiry via clock injection), plus existing test updated to use recent timestamps now that retirement filter is active. 839 pass, 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
All five findings from the two reviews addressed in 0927053: @kriszyp's three + minor:
@heskew's Codex finding (trust never expires without mint traffic): retirement is now evaluated at read time — a shared 841 tests, 839 pass, 0 fail (10 tests added, 1 replaced — net +9 this round). 🤖 Posted by Claude on Nathan's behalf |
…ken verify
Root cause of the e2e CI failure: after generateAndPersistFirstKey/rotateTo
persisted a key and invalidated the cache, the post-write re-enumeration could
return [] if Harper's table.search() did not yet reflect the put (timing). The
function returned the local fallback but left enumCache = {records: [], ...}
live. The next getAllPublicKeys call (token verification in withMCPAuth) hit the
stale-empty cache and returned [], triggering "no signing keys available → 401".
Fix: when the post-write re-enumerate returns [], explicitly seed enumCache with
the local record instead of leaving the stale-empty entry. Applied to all four
write paths: generateAndPersistFirstKey, rotateTo, pin-persist, pin-bump.
Additional fixes in the same pass (Gemini review blockers):
- runSingleFlight: `if` → `while` so a waiter that loses the condition check
after N concurrent writes loops back to await the next pendingWrite rather
than racing to start a redundant write.
- getAllPublicKeys: strip private_key_pem from results (returns MCPPublicKeyRecord[]).
Add MCPPublicKeyRecord = Omit<MCPSigningKeyRecord, 'private_key_pem'> type.
Update KeySource interface and verifyAccessTokenWithKeySet signature to match.
Non-blocking improvements:
- enumerateKeys: pre-sort records in cache so partitionRetired/selectNewestKey
skip redundant sorts on the hot path.
- selectNewestKey: throw on empty input (invariant guard).
Unit tests: 840 pass, 0 fail. Added regression test for the stale-empty path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Input is pre-sorted at cache population (enumerateKeys) and by the sorted local fallbacks; document the precondition instead of re-sorting on every verification/JWKS request. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| function pinnedKidFromPem(publicKeyPem: string): string { | ||
| const fingerprint = createHash('sha256').update(publicKeyPem).digest('hex').slice(0, 16); | ||
| return `pinned-${fingerprint}`; | ||
| } |
There was a problem hiding this comment.
💡 Suggestion (non-blocking) — Read-side single-flighting
Consider serializing concurrent reads to enumerateKeys when the cache is cold. This would prevent a stampede of table scans on the JWKS or verification paths during a traffic spike.
Summary
Implements #128 — signing-key rotation + multi-key JWKS publication.
harper_oauth_mcp_keysnow holds a set of keys. Signer = newestcreated_at(kid-desc tie-break). The legacyrs256-defaultrow remains a valid member — no migration; existing tokens keep verifying.mcp.signingKeyPemis now recommended for clusters, no longer required.mcp.keyRotationInterval(seconds; unset/0 = no rotation). Checked lazily at token mint — no timers.signingKeyPempins the key: rotation is skipped with a startup warning when both are set.2 × accessTokenTtl(the last token it signed has expired, with a full TTL of margin for replication lag). Runs detached from minting; errors logged, never blocking.kid(pinned PEM keeps the fixedrs256-defaultkid — identical content on every node makes the shared primary key safe there, and only there).getSigningKeypropagates table-read errors (mint fails loudly) instead of treating them as an empty table and generating spurious keys; the JWKS read path stays best-effort ([]).buildJWKSandwithMCPAuth/verifyAccessTokenWithKeySetneeded no changes — both were already key-set-shaped (selection bykid).Where to focus review
keyStore.tsgarbageCollect): successor-based windows. Round-1 review (Gemini/Claude/Codex all) caught that a signer-age rule never fires whenkeyRotationInterval < 2×accessTokenTtl(accumulation); fixed + regression-tested. Known caveat: the 2× margin assumesaccessTokenTtlwasn't drastically shortened between rotations — a token minted under a much longer previous TTL could theoretically outlive the window of a later, shorter TTL. Documented tradeoff; operators changing TTL sharply downward should expect old-key tokens to be cut off at rotation + 2×newTTL.rs256-default, one overwriting the other — stranding the loser's tokens with an unverifiablekid. Generated keys now use UUID kids so both rows survive and publish.Tests
828 total / 826 pass / 2 pre-existing skips. Covers: multi-key publication + newest-wins + tie-break, rotation on/off/fresh, old-key token verifying against the post-rotation key set (overlap window), pin-wins, GC successor rule + no-accumulation regression + signer never deleted + GC-error tolerance, enumerate-error propagation (mint) vs best-effort (JWKS), legacy
rs256-defaultcompat.Docs:
docs/mcp-oauth.md+ configuration reference updated (keyRotationInterval, multi-key JWKS, revised cluster guidance).Closes #128
🤖 Generated with Claude Code (Opus 4.8)