/** * MCP JWT Signing Key Store * * Persists RS256 signing keypairs in the `harper_oauth_mcp_keys` Harper table, * keyed by random UUID `kid` (the legacy `rs256-default` row stays valid — no * migration). Every node's key is published at the JWKS endpoint so cross-node * tokens verify regardless of which node signed them — eliminating the * clustered first-boot race from v1. * * Signer selection: the key with the newest `created_at` wins. Tie-break on * `kid` descending (lexicographic) for determinism. * * Single-flight guard: an in-process `pendingWrite` promise serializes * concurrent key writes (first-boot, rotation, pin-persist) so N concurrent * mints don't each generate a redundant keypair. Cross-node races are handled * by convergence — multi-key JWKS + double-checked locking inside the write * lambda; the guard is per-process only. * * Rotation (opt-in via `mcp.keyRotationInterval`): lazy check at token mint — * if the newest key is older than the interval a fresh UUID-kid keypair is * generated and persisted. Old keys are GC'd once no token they signed can * still be valid (2× accessTokenTtl after their immediate successor's * created_at). GC runs detached via setImmediate so it never holds the * request's transaction context. * * Read-time retirement: `getAllPublicKeys` applies the same successor-age rule * to EXCLUDE retired keys from the JWKS and from `withMCPAuth` verification — * trust expires by time, not by traffic. Physical deletion (garbageCollect) * still only runs on the mint path; the read path never writes. * * Enumeration cache: raw key records are cached for 5 s to avoid a full-table * scan on every auth-hot-path JWKS or verification call. Any local `put` or * `delete` through `MCPKeyStore` invalidates the cache immediately so a * just-minted key verifies on this node without waiting for TTL expiry. The * retirement filter runs per-call on the cached records (cheap CPU, accurate * time). * * `mcp.signingKeyPem` (pin wins — ALWAYS): when a pinned key is configured, * getSigningKey looks for an existing record whose public_key_pem matches the * configured PEM. If found and already the newest key → sign directly. If * found but NOT the newest → bump created_at (same kid, same material) so the * pin leads the sorted order and the successor-based GC loop can clean up keys * that post-dated the pin. If not found at all → persist under `rs256-default` * (when free) or a deterministic fingerprint kid (when rs256-default holds * different material). Rotation is skipped while a pin is active. * * Fingerprint kid: `pinned-`. * Every node derives the same kid from the same PEM, so concurrent writes are * safe (idempotent put under the same primary key). * * The private half never leaves the server; only the public half is published * at /.well-known/jwks.json. * * IMPORTANT: Harper tracked-object Proxies return empty own-keys — NO spread * (`{ ...raw }`). Always use explicit field access (decodeRecord). */ import type { Logger, MCPConfig, MCPPublicKeyRecord, MCPSigningKeyRecord } from '../../types.ts'; /** Fixed primary key for the legacy v1 singleton signing key. */ export declare const SIGNING_KEY_ID = "rs256-default"; /** * Override the cache clock for testing. Pass null to restore the default. * Not part of the public API. * @internal */ export declare function _setCacheNowMs(fn: (() => number) | null): void; /** * Reset all module-level state (for testing only). * @internal */ export declare function resetMCPKeysTableCache(): void; export declare class MCPKeyStore { private logger?; constructor(logger?: Logger); /** * Enumerate all rows in the keys table. * * Results are cached for `ENUM_CACHE_TTL_MS` (5 s). Any local write * (put/delete) invalidates the cache immediately via `invalidateEnumCache()`. * * @param bypassCache - Skip the cache and read from the table (used inside * write lambdas for double-checked locking). The fresh result still refills * the cache for subsequent callers. * * PROPAGATES errors — callers on the mint path (getSigningKey) let them * surface; callers on the read path (getAllPublicKeys) catch and return []. */ private enumerateKeys; /** * Run `fn` inside the in-process single-flight guard. * * If another write is in flight, await it and re-evaluate `condition` against * its result — the concurrent write likely satisfied our trigger (e.g. another * request already generated the first-boot key or rotated). If the condition * IS satisfied we return early without running `fn`. If NOT satisfied (e.g. a * concurrent rotation write finished but we need to persist a pinned key), we * proceed to our own write under a new `pendingWrite`. * * Double-checked locking inside `fn`: `fn` receives the result of a fresh * `enumerateKeys(true)` call and can short-circuit if the condition is now * satisfied (another process may have written between the outer check and now). */ private runSingleFlight; /** Read the persisted signing key by its fixed legacy id, or null if absent. */ get(): Promise; /** * Resolve the active signing key: * * **When `mcpConfig.signingKeyPem` is set (pin wins — always):** * 1. Derive the pinned public key from the PEM (memoized per PEM string). * 2. Search the key set for a record whose `public_key_pem` matches. * - Found AND already the newest → sign directly, no write. * - Found but NOT newest → bump `created_at` (single-flight write) so the * successor-based GC loop starts from the pin. Without the bump the * previously-newer generated key sits permanently at sorted[0] with no * older successor, and garbageCollect never visits it. * - Not found → persist (single-flight write) under `rs256-default` when * absent, otherwise under the deterministic fingerprint kid. * 3. Rotation is skipped. Old keys remain for JWKS overlap; GC fires as usual. * * **Otherwise (rotation/generation path):** * 1. Enumerate all persisted keys. * 2. If empty → first-boot: single-flight write under a UUID kid. * 3. Select newest by `created_at` (tie-break: `kid` desc). * 4. If `keyRotationInterval > 0` and newest key is older than the interval * → single-flight rotation write. * 5. GC fires detached (setImmediate) to clean up retired keys. * * Enumeration errors PROPAGATE on the mint path — a transient read failure * must not trigger spurious key generation. */ getSigningKey(mcpConfig?: MCPConfig): Promise; /** * Public keys to publish at the JWKS endpoint and use for token verification. * * Returns only the LIVE key set — retired keys (whose immediate successor was * created more than `2 × accessTokenTtl` seconds ago) are excluded so trust * expires by time even when no mint traffic triggers physical GC deletion. * * Results are served from the enumeration cache (5 s TTL); the retirement * filter is applied per-call on the cached records (cheap, time-accurate). * * Read-only: never triggers key generation. Returns [] on table errors so the * JWKS endpoint doesn't 500 before the first key is minted. */ getAllPublicKeys(mcpConfig?: MCPConfig): Promise; /** * Find or persist/bump the key for a configured `signingKeyPem`. Pin-wins * path: the configured PEM always signs, regardless of other keys in the table. * * Match by `public_key_pem` string equality (both sides exported via the same * Node.js canonical SPKI path — stable). * * When the pin is found but is NOT the newest key: re-persist with * `created_at = now` (same kid, same material, nothing stranding). This makes * the pin `sorted[0]` in `garbageCollect`, giving the previously-newer key a * fresh successor timestamp so the existing successor-based GC loop cleans it * up on schedule. Without the bump, that key leaks permanently — `sorted[0]` * has no older successor entry, so `garbageCollect`'s `i=1` loop never reaches * it. The bump is idempotent across nodes (same kid, same condition). * * Kid assignment when persisting: * - `rs256-default` when absent (legacy compat; deterministic across nodes). * - `pinned-` when `rs256-default` holds different * material — also deterministic, so concurrent puts from clustered nodes are * idempotent. * * Old keys are NOT removed — they stay for JWKS overlap so tokens they signed * keep verifying until the retirement window closes. */ private getOrPersistPinnedKey; /** * Generate and persist the first signing key under a UUID kid (no-pin * first-boot path). Re-enumerates after persisting to adopt the converged * winner under concurrent first-boot races from other processes. */ private generateAndPersistFirstKey; /** * Generate a new keypair under a UUID kid, persist, and re-enumerate. * Returns the updated key set. */ private rotateTo; /** * Delete retired signing keys (physical cleanup; mint-path only). * * Delegates to `partitionRetired` for the same successor-age rule used by * `getAllPublicKeys`. The current signer is additionally protected by a * kid-check (defense-in-depth in case the pin path hands an unexpected signer). * Errors are caught by the caller's `setImmediate .catch()`. */ private garbageCollect; }