diff --git a/docs/configuration.md b/docs/configuration.md index 8eec471..754d818 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,7 +57,7 @@ Each provider requires: ### MCP OAuth -Opt-in support for the Model Context Protocol authorization flow ([issue #86](https://github.com/HarperFast/oauth/issues/86)). The plugin serves Dynamic Client Registration at `POST /oauth/mcp/register` (RFC 7591), the discovery documents under `/.well-known/*` (RFCs 8414, 9728) so MCP clients (Claude Desktop, Cursor, `mcp-remote`) can find and register themselves, the authorization endpoint `GET /oauth/mcp/authorize` (OAuth 2.1 + PKCE-S256), the `POST /oauth/mcp/token` exchange (audience-bound RS256 JWTs), and the `withMCPAuth` route guard that verifies those tokens on your MCP endpoint. +Opt-in support for the Model Context Protocol authorization flow ([issue #86](https://github.com/HarperFast/oauth/issues/86)). The plugin serves Dynamic Client Registration at `POST /oauth/mcp/register` (RFC 7591), the discovery documents under `/.well-known/*` (RFCs 8414, 9728) so MCP clients (Claude Desktop, Cursor, `mcp-remote`) can find and register themselves, the authorization endpoint `GET /oauth/mcp/authorize` (OAuth 2.1 + PKCE-S256), the `POST /oauth/mcp/token` exchange (audience-bound signed JWTs — RS256 by default, ES256 opt-in), and the `withMCPAuth` route guard that verifies those tokens on your MCP endpoint. > This section is the configuration reference. For the end-to-end flow, the `withMCPAuth` wrapper (registration models + options), the `onMCPTokenIssued` hook, the production checklist, and troubleshooting, see **[MCP OAuth](./mcp-oauth.md)**. The feature is **experimental and opt-in** (`mcp.enabled`). @@ -84,27 +84,27 @@ Opt-in support for the Model Context Protocol authorization flow ([issue #86](ht - app.example.com ``` -| Option | Type | Default | Description | -| ------------------------------------------------------- | --------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mcp.enabled` | boolean | `false` | Master switch for the MCP OAuth endpoints | -| `mcp.issuer` | string | (none) | Authorization-server URI advertised in AS metadata. **Required when `mcp.enabled`** — startup fails otherwise, to prevent a Host-header-driven `iss`/`aud` | -| `mcp.resource` | string | `/mcp` | Canonical resource URI advertised in PRM (RFC 9728) and validated as `aud` on issued tokens (RFC 8707). Optional override; defaults safely from the pinned `issuer` | -| `mcp.providers` | string[] | (all providers) | Subset of upstream providers eligible for the MCP auth flow. **v1 requires exactly one** resolved provider — set this when more than one provider is configured globally, otherwise `/oauth/mcp/authorize` returns `server_error` | -| `mcp.dynamicClientRegistration.enabled` | boolean | `true` | Enable the `/register` endpoint when `mcp.enabled` is true | -| `mcp.dynamicClientRegistration.initialAccessToken` | string | (none) | If set, registration requires `Authorization: Bearer `. Otherwise open per RFC 7591 | -| `mcp.dynamicClientRegistration.allowedRedirectUriHosts` | string[] | (none) | Allowlist for redirect_uri hosts. Localhost always allowed per RFC 8252 | -| `mcp.clientIdMetadataDocuments.enabled` | boolean | `true` | Enable CIMD resolution for URL-shaped `client_id` values. Disable with `false` to reject all CIMD clients; see [Client ID Metadata Documents](./mcp-oauth.md#client-id-metadata-documents-cimd) | -| `mcp.clientIdMetadataDocuments.allowedHosts` | string[] | (none) | If set, only CIMD `client_id` URLs whose hostname is in this list are resolved. Others are silently rejected (`invalid_client`) without revealing the allowlist | -| `mcp.clientIdMetadataDocuments.fetchTimeoutMs` | number | `5000` | Deadline for CIMD document retrieval covering DNS, connect, and body read (milliseconds). Non-finite or non-positive values fall back to the default | -| `mcp.clientIdMetadataDocuments.maxDocumentBytes` | number | `65536` | Maximum CIMD document size in bytes (64 KB default). Responses exceeding this limit are rejected. Non-finite or non-positive values fall back to the default | -| `mcp.clientCredentials.enabled` | boolean | `false` | Enable the RFC 7523 `client_credentials` grant (`private_key_jwt`, EdDSA) for headless agents. Explicit opt-in; requires a non-empty `mcp.clientIdMetadataDocuments.allowedHosts` allowlist, CIMD enabled, and an `https:` `mcp.issuer` (RFC 6749 §3.2 — the token endpoint must be TLS; `http:` is permitted only for loopback development issuers) — all enforced at startup. See [Headless agents](./mcp-oauth.md#headless-agents-client_credentials) | -| `mcp.clientCredentials.accessTokenTtl` | number | `300` | Access-token lifetime in seconds for the `client_credentials` grant. No refresh token is ever issued — agents re-mint on 401. Non-finite or non-positive values fall back to the default | -| `mcp.clientCredentials.rateLimit` | number \| false | `30` | Issuance rate limit in requests/minute per `client_id` (token bucket, per node, and in fact per worker thread — N threads means an N× effective ceiling; a shared counter table would be a replication hot-write anti-pattern, and the assertion replay guard + 60s window bound cross-node abuse). Over-limit requests get `429` with `error: "slow_down"` and a `Retry-After` header. `false` or `0` disables; non-finite/non-positive values fall back to the default. CIMD metadata fetches are separately limited at a fixed 10 attempts/min per client_id URL (not configurable) | -| `mcp.signingKeyPem` | string | (generated) | PEM-encoded RS256 private key (PKCS#8) used to sign access tokens. When set, this key **always** wins as the signer — it is found in the key set by material match, or persisted on first use (under a deterministic kid so concurrent cluster nodes are idempotent). When unset, a UUID-kid keypair is generated on first boot. Because all persisted keys are published in the JWKS, tokens signed by any node verify everywhere — pinning is **recommended** for clusters but not strictly required | -| `mcp.keyRotationInterval` | number | `0` (disabled) | Signing-key rotation period in seconds. When `> 0`, a fresh UUID-kid keypair is generated at token-mint time once the current signer is older than this interval. Old keys are kept in the JWKS and deleted lazily once `2 × accessTokenTtl` has passed since their immediate successor was created (covering replication lag). Rotation is skipped while `signingKeyPem` is set — setting both emits a startup warning | -| `mcp.signingAlgorithm` | string | `RS256` | JWT signing algorithm. Only `RS256` is supported in v1 (reserved for a future EdDSA option) | -| `mcp.accessTokenTtl` | number | `3600` | Access-token lifetime in seconds (default 1 hour) | -| `mcp.refreshTokenTtl` | number | `2592000` | Refresh-token (family) lifetime in seconds (default 30 days) | +| Option | Type | Default | Description | +| ------------------------------------------------------- | --------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mcp.enabled` | boolean | `false` | Master switch for the MCP OAuth endpoints | +| `mcp.issuer` | string | (none) | Authorization-server URI advertised in AS metadata. **Required when `mcp.enabled`** — startup fails otherwise, to prevent a Host-header-driven `iss`/`aud` | +| `mcp.resource` | string | `/mcp` | Canonical resource URI advertised in PRM (RFC 9728) and validated as `aud` on issued tokens (RFC 8707). Optional override; defaults safely from the pinned `issuer` | +| `mcp.providers` | string[] | (all providers) | Subset of upstream providers eligible for the MCP auth flow. **v1 requires exactly one** resolved provider — set this when more than one provider is configured globally, otherwise `/oauth/mcp/authorize` returns `server_error` | +| `mcp.dynamicClientRegistration.enabled` | boolean | `true` | Enable the `/register` endpoint when `mcp.enabled` is true | +| `mcp.dynamicClientRegistration.initialAccessToken` | string | (none) | If set, registration requires `Authorization: Bearer `. Otherwise open per RFC 7591 | +| `mcp.dynamicClientRegistration.allowedRedirectUriHosts` | string[] | (none) | Allowlist for redirect_uri hosts. Localhost always allowed per RFC 8252 | +| `mcp.clientIdMetadataDocuments.enabled` | boolean | `true` | Enable CIMD resolution for URL-shaped `client_id` values. Disable with `false` to reject all CIMD clients; see [Client ID Metadata Documents](./mcp-oauth.md#client-id-metadata-documents-cimd) | +| `mcp.clientIdMetadataDocuments.allowedHosts` | string[] | (none) | If set, only CIMD `client_id` URLs whose hostname is in this list are resolved. Others are silently rejected (`invalid_client`) without revealing the allowlist | +| `mcp.clientIdMetadataDocuments.fetchTimeoutMs` | number | `5000` | Deadline for CIMD document retrieval covering DNS, connect, and body read (milliseconds). Non-finite or non-positive values fall back to the default | +| `mcp.clientIdMetadataDocuments.maxDocumentBytes` | number | `65536` | Maximum CIMD document size in bytes (64 KB default). Responses exceeding this limit are rejected. Non-finite or non-positive values fall back to the default | +| `mcp.clientCredentials.enabled` | boolean | `false` | Enable the RFC 7523 `client_credentials` grant (`private_key_jwt`, EdDSA) for headless agents. Explicit opt-in; requires a non-empty `mcp.clientIdMetadataDocuments.allowedHosts` allowlist, CIMD enabled, and an `https:` `mcp.issuer` (RFC 6749 §3.2 — the token endpoint must be TLS; `http:` is permitted only for loopback development issuers) — all enforced at startup. See [Headless agents](./mcp-oauth.md#headless-agents-client_credentials) | +| `mcp.clientCredentials.accessTokenTtl` | number | `300` | Access-token lifetime in seconds for the `client_credentials` grant. No refresh token is ever issued — agents re-mint on 401. Non-finite or non-positive values fall back to the default | +| `mcp.clientCredentials.rateLimit` | number \| false | `30` | Issuance rate limit in requests/minute per `client_id` (token bucket, per node, and in fact per worker thread — N threads means an N× effective ceiling; a shared counter table would be a replication hot-write anti-pattern, and the assertion replay guard + 60s window bound cross-node abuse). Over-limit requests get `429` with `error: "slow_down"` and a `Retry-After` header. `false` or `0` disables; non-finite/non-positive values fall back to the default. CIMD metadata fetches are separately limited at a fixed 10 attempts/min per client_id URL (not configurable) | +| `mcp.signingKeyPem` | string | (generated) | PEM-encoded private key (PKCS#8; RSA or EC P-256 — the signing algorithm is derived from the key material, overriding `mcp.signingAlgorithm`) used to sign access tokens. When set, this key **always** wins as the signer — it is found in the key set by material match, or persisted on first use (under a deterministic kid so concurrent cluster nodes are idempotent). When unset, a UUID-kid keypair is generated on first boot. Because all persisted keys are published in the JWKS, tokens signed by any node verify everywhere — pinning is **recommended** for clusters but not strictly required | +| `mcp.keyRotationInterval` | number | `0` (disabled) | Signing-key rotation period in seconds. When `> 0`, a fresh UUID-kid keypair is generated at token-mint time once the current signer is older than this interval. Old keys are kept in the JWKS and deleted lazily once `2 × accessTokenTtl` has passed since their immediate successor was created (covering replication lag). Rotation is skipped while `signingKeyPem` is set — setting both emits a startup warning | +| `mcp.signingAlgorithm` | string | `RS256` | JWT signing algorithm for generated signing keys: `RS256` or `ES256` (EC P-256, [#127](https://github.com/HarperFast/oauth/issues/127)). Changing it on a running deployment rotates in a new key under the new algorithm (once the current key is ≥ 5 minutes old — an age floor so nodes with briefly-disagreeing configs during a rolling rollout don't rotate against each other; update all nodes together); old keys stay in the JWKS until retired, so outstanding tokens keep verifying. Ignored while `signingKeyPem` is set (the pin's key material decides). EdDSA is not supported | +| `mcp.accessTokenTtl` | number | `3600` | Access-token lifetime in seconds (default 1 hour) | +| `mcp.refreshTokenTtl` | number | `2592000` | Refresh-token (family) lifetime in seconds (default 30 days) | Sensitive leaves inside `mcp` support `${ENV_VAR}` expansion (e.g., `initialAccessToken: ${OAUTH_MCP_REGISTRATION_TOKEN}`), the same way provider credentials do. diff --git a/docs/mcp-oauth.md b/docs/mcp-oauth.md index 9eca0f7..8a7326a 100644 --- a/docs/mcp-oauth.md +++ b/docs/mcp-oauth.md @@ -107,7 +107,7 @@ GitHub login, and presents a bearer token on every subsequent call — which │ ◀─────────────────────────────│ │ │ │ │ │ 6. POST /oauth/mcp/token (code + code_verifier) │ - │ ─────────────────────────────▶│ → access_token (RS256 JWT) │ + │ ─────────────────────────────▶│ → access_token (signed JWT) │ │ │ + refresh_token │ │ │ │ │ 7. GET /mcp Authorization: Bearer │ @@ -130,8 +130,8 @@ GitHub login, and presents a bearer token on every subsequent call — which user to the upstream IdP; on return it mints a single-use authorization code and redirects back to the client's `redirect_uri`. 6. **Token exchange.** The client posts the code plus its PKCE `code_verifier` to - `/oauth/mcp/token` and receives an RS256-signed access token (and a refresh - token) bound to the `resource` as its `aud`. + `/oauth/mcp/token` and receives a signed access token (RS256 or ES256, per `mcp.signingAlgorithm`) + and a refresh token, bound to the `resource` as its `aud`. 7. **Authenticated requests.** The client calls your MCP route with `Authorization: Bearer `. `withMCPAuth` verifies the signature, audience, and issuer, attaches the claims as `request.mcp`, and runs your @@ -149,11 +149,11 @@ discovery handlers fall through). ### Discovery (`/.well-known/*`) -| Path | Spec | Returns | -| --------------------------------------------- | -------- | ------------------------------------------------------------------------------------ | -| `GET /.well-known/oauth-protected-resource` | RFC 9728 | `resource`, `authorization_servers`, `bearer_methods_supported` | -| `GET /.well-known/oauth-authorization-server` | RFC 8414 | `issuer`, the endpoint URLs, and supported response/grant/PKCE/auth methods | -| `GET /.well-known/jwks.json` | — | The RS256 public keys for verifying issued tokens (empty until the first token mint) | +| Path | Spec | Returns | +| --------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `GET /.well-known/oauth-protected-resource` | RFC 9728 | `resource`, `authorization_servers`, `bearer_methods_supported` | +| `GET /.well-known/oauth-authorization-server` | RFC 8414 | `issuer`, the endpoint URLs, and supported response/grant/PKCE/auth methods | +| `GET /.well-known/jwks.json` | — | The signing public keys (RSA and/or EC, per-key `alg`) for verifying issued tokens (empty until the first token mint) | All three send `Access-Control-Allow-Origin: *` so browser-based clients and discovery tools can fetch them cross-origin. When `mcp.resource` carries a path @@ -175,7 +175,9 @@ form the `WWW-Authenticate` challenge advertises. ### Issued access tokens -RS256 JWT, signed with key id `rs256-default`, carrying: +A signed JWT — RS256 by default, ES256 when configured (`mcp.signingAlgorithm`). +The `kid` header identifies the signing key; resolve it against the published +JWKS rather than assuming a fixed key id. Claims: | Claim | Value | | ----------- | --------------------------------------------------------------------- | @@ -455,7 +457,7 @@ Point your MCP clients at the same route; they rediscover the endpoints via the ## Signing-key rotation -By default, the plugin generates one RS256 keypair per node on first mint and +By default, the plugin generates one keypair per node (RS256 by default; `mcp.signingAlgorithm: ES256` for EC P-256) on first mint and keeps it indefinitely. The JWKS endpoint publishes **all** persisted keys, so tokens signed by any node's key are always verifiable — the cluster first-boot race is resolved without any manual coordination. @@ -703,7 +705,7 @@ of key possession is the only accepted authentication for this grant. > implies a vantage point (TLS interception, host access) from which the > minted bearer token itself is equally exposed. -The issued token is the same RS256 Bearer JWT as the interactive flow, with two +The issued token is the same signed Bearer JWT as the interactive flow, with two differences: **`sub` is the client identity** (`sub` = `client_id`, RFC 9068 §2.2 — there is no end user in this grant) and **no refresh token is ever issued** — the default TTL is 5 minutes and agents simply re-mint on 401. @@ -747,5 +749,5 @@ These are **not** available and no config or code sample here implies them: - Per-tool / fine-grained scopes (the `scope` claim is passed through, not enforced per tool) - Transitive revocation (revoking the upstream IdP session does not invalidate already-issued MCP tokens) -- Signing algorithms other than RS256 +- Signing algorithms other than RS256 and ES256 (e.g. EdDSA) - A native, composed MCP server (this plugin is the authorization server, not the MCP transport) diff --git a/schema/oauth.graphql b/schema/oauth.graphql index 66f467e..997bb7e 100644 --- a/schema/oauth.graphql +++ b/schema/oauth.graphql @@ -54,12 +54,12 @@ type mcp_auth_codes @table(database: "oauth", expiration: 300) { ## MCP JWT signing keys (Stage 4) ## Persisted here (not on disk) so all replicated Harper nodes share one key ## set — file storage would diverge across nodes. No expiration: keys are -## retained for verification until explicitly rotated. RS256 only in v1 -## (jsonwebtoken cannot emit EdDSA); the public half is published at +## retained for verification until explicitly rotated. Per-row `alg` (RS256 or +## ES256; EdDSA unsupported — jsonwebtoken cannot emit it); the public half is published at ## /.well-known/jwks.json, the private half never leaves the server. type harper_oauth_mcp_keys @table(database: "oauth") { kid: ID @primaryKey - alg: String # "RS256" + alg: String # "RS256" | "ES256" public_key_pem: String private_key_pem: String # Hand-managed (NOT @createdTime): keyStore deliberately mutates created_at diff --git a/src/index.ts b/src/index.ts index 579db24..8c3cf46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { clearOAuthSession } from './lib/handlers.ts'; import { HookManager } from './lib/hookManager.ts'; import { DynamicProviderCache, DEFAULT_DYNAMIC_PROVIDER_CACHE_TTL_SECONDS } from './lib/dynamicProviderCache.ts'; import { registerWellKnownHandlers } from './lib/mcp/wellKnown.ts'; +import { algFromPrivateKeyPem } from './lib/mcp/keyStore.ts'; import { redactSecrets } from './lib/redact.ts'; import type { Scope, OAuthPluginConfig, ProviderRegistry, OAuthHooks } from './types.ts'; @@ -261,6 +262,39 @@ export async function handleApplication(scope: Scope): Promise { 'Remove mcp.keyRotationInterval if you intend to use a fixed key.' ); } + // An unrecognized signingAlgorithm silently resolves to the RS256 default + // (resolveConfiguredAlg) — warn so a typo ("es256") isn't a mystery. + if ( + mcpConfig?.signingAlgorithm && + mcpConfig.signingAlgorithm !== 'RS256' && + mcpConfig.signingAlgorithm !== 'ES256' + ) { + logger?.warn?.( + `MCP: mcp.signingAlgorithm "${mcpConfig.signingAlgorithm}" is not supported (use "RS256" or "ES256"); ` + + 'falling back to RS256 for generated keys.' + ); + } + // A pinned key's algorithm comes from its key material; warn when + // mcp.signingAlgorithm disagrees so the operator isn't surprised the + // config value is ignored. An unparseable/unsupported pin only warns + // here — startup stays up for the human-OAuth flows; the mint path + // throws when the pin is actually used. + if (mcpConfig?.signingKeyPem) { + try { + const pinnedAlg = algFromPrivateKeyPem(mcpConfig.signingKeyPem); + if (mcpConfig.signingAlgorithm && pinnedAlg !== mcpConfig.signingAlgorithm) { + logger?.warn?.( + `MCP: mcp.signingAlgorithm is "${mcpConfig.signingAlgorithm}" but the pinned mcp.signingKeyPem ` + + `is a ${pinnedAlg} key. The pinned key's algorithm (${pinnedAlg}) is used.` + ); + } + } catch (error) { + logger?.warn?.( + 'MCP: mcp.signingKeyPem is not a supported signing key (RSA or EC P-256):', + error instanceof Error ? error.message : String(error) + ); + } + } // Re-initialize providers from new configuration // Clear existing providers and repopulate (don't reassign to preserve closure reference) diff --git a/src/lib/mcp/keyStore.ts b/src/lib/mcp/keyStore.ts index ecc003b..4a976b0 100644 --- a/src/lib/mcp/keyStore.ts +++ b/src/lib/mcp/keyStore.ts @@ -1,7 +1,8 @@ /** * MCP JWT Signing Key Store * - * Persists RS256 signing keypairs in the `harper_oauth_mcp_keys` Harper table, + * Persists signing keypairs (RS256 or ES256, per `mcp.signingAlgorithm` / + * pinned key material) 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 @@ -55,18 +56,68 @@ * (`{ ...raw }`). Always use explicit field access (decodeRecord). */ -import { createHash, createPublicKey, generateKeyPair, randomUUID } from 'node:crypto'; +import { createHash, createPrivateKey, createPublicKey, generateKeyPair, randomUUID } from 'node:crypto'; import { promisify } from 'node:util'; import type { Logger, MCPConfig, MCPPublicKeyRecord, MCPSigningKeyRecord, Table } from '../../types.ts'; +import type { SupportedSigningAlg } from './tokenIssuer.ts'; const generateKeyPairAsync = promisify(generateKeyPair); /** Fixed primary key for the legacy v1 singleton signing key. */ export const SIGNING_KEY_ID = 'rs256-default'; +/** + * The algorithm generated keys should use, from `mcp.signingAlgorithm`. + * Anything other than an explicit 'ES256' resolves to the RS256 default. + */ +export function resolveConfiguredAlg(mcpConfig?: MCPConfig): SupportedSigningAlg { + return mcpConfig?.signingAlgorithm === 'ES256' ? 'ES256' : 'RS256'; +} + +/** + * Derive the signing algorithm from pinned private-key material: RSA → RS256, + * EC P-256 → ES256. Throws on any other key type/curve — a pin the issuer + * cannot sign with must fail loudly at mint time, not emit broken tokens. + */ +export function algFromPrivateKeyPem(privateKeyPem: string): SupportedSigningAlg { + const key = createPrivateKey(privateKeyPem); + if (key.asymmetricKeyType === 'rsa') return 'RS256'; + if (key.asymmetricKeyType === 'ec') { + const curve = key.asymmetricKeyDetails?.namedCurve; + if (curve === 'prime256v1') return 'ES256'; + throw new Error(`MCP: unsupported EC curve for signingKeyPem: ${curve} (only P-256/prime256v1 is supported)`); + } + throw new Error(`MCP: unsupported signingKeyPem key type: ${key.asymmetricKeyType} (only RSA and EC P-256)`); +} + +/** + * The algorithm access tokens are (or will be) signed with, for metadata + * advertisement: the pinned key's derived alg when a pin is configured + * (falling back to the configured alg if the PEM is unparseable — the mint + * path will surface that error), otherwise the configured alg. + */ +export function resolveEffectiveAlg(mcpConfig?: MCPConfig): SupportedSigningAlg { + if (mcpConfig?.signingKeyPem) { + try { + return algFromPrivateKeyPem(mcpConfig.signingKeyPem); + } catch { + return resolveConfiguredAlg(mcpConfig); + } + } + return resolveConfiguredAlg(mcpConfig); +} + const DEFAULT_ACCESS_TOKEN_TTL = 3600; const ENUM_CACHE_TTL_MS = 5_000; +// Minimum age of the newest key before an alg-switch rotation may replace it. +// During a rolling config rollout, nodes briefly disagree on signingAlgorithm; +// without this floor each side would rotate the other's key away on every +// token mint (key generation proportional to mint rate). With it, a mixed +// window costs at most one rotation per direction per interval, and a +// converged config still switches within this bound. +const ALG_SWITCH_MIN_KEY_AGE_SECONDS = 300; + declare const databases: any; let keysTable: Table | undefined; @@ -142,7 +193,19 @@ function decodeRecord(raw: Record): MCPSigningKeyRecord { }; } -async function generateRsaKeyPair(): Promise<{ publicKeyPem: string; privateKeyPem: string }> { +async function generateSigningKeyPair( + alg: SupportedSigningAlg +): Promise<{ publicKeyPem: string; privateKeyPem: string }> { + // Inline literal options per call: promisify's generateKeyPair overloads + // only resolve to string-returning variants when the encodings are literal. + if (alg === 'ES256') { + const { publicKey, privateKey } = await generateKeyPairAsync('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + return { publicKeyPem: publicKey as string, privateKeyPem: privateKey as string }; + } const { publicKey, privateKey } = await generateKeyPairAsync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, @@ -353,6 +416,7 @@ export class MCPKeyStore { return this.getOrPersistPinnedKey(mcpConfig.signingKeyPem, accessTtl); } + const configuredAlg = resolveConfiguredAlg(mcpConfig); const rotationInterval = coerceInterval(mcpConfig?.keyRotationInterval); let allKeys = await this.enumerateKeys(); @@ -360,19 +424,42 @@ export class MCPKeyStore { if (allKeys.length === 0) { allKeys = await this.runSingleFlight( (keys) => keys.length > 0, - (_freshKeys) => this.generateAndPersistFirstKey() + (_freshKeys) => this.generateAndPersistFirstKey(configuredAlg) ); } let signerKey = selectNewestKey(allKeys); - // Rotation when interval > 0 and newest key is stale. - if (rotationInterval > 0) { + // Algorithm switch: when `signingAlgorithm` no longer matches the newest + // key, rotate to a fresh key under the configured alg so the config takes + // effect on a running deployment. Old keys stay in the JWKS until retired, + // so outstanding tokens keep verifying — additive, no migration (#127). + // The age floor bounds churn while a rolling rollout leaves nodes with + // disagreeing configs (see ALG_SWITCH_MIN_KEY_AGE_SECONDS). + if ((signerKey.alg ?? 'RS256') !== configuredAlg) { + const nowSeconds = Math.floor(Date.now() / 1000); + if (nowSeconds - signerKey.created_at > ALG_SWITCH_MIN_KEY_AGE_SECONDS) { + allKeys = await this.runSingleFlight( + (keys) => keys.length > 0 && (selectNewestKey(keys).alg ?? 'RS256') === configuredAlg, + (freshKeys) => this.rotateTo(freshKeys, configuredAlg) + ); + signerKey = selectNewestKey(allKeys); + } + } + + // Rotation when interval > 0 and newest key is stale. Skipped while the + // newest key's alg disagrees with config: that state means the alg-switch + // block above deliberately deferred (age floor), and rotating here to + // `configuredAlg` would bypass the floor whenever the interval is shorter + // — re-introducing mixed-rollout churn at the interval cadence. Once the + // key clears the floor the switch block rotates it; interval rotation + // resumes on the new key. + if (rotationInterval > 0 && (signerKey.alg ?? 'RS256') === configuredAlg) { const nowSeconds = Math.floor(Date.now() / 1000); if (nowSeconds - signerKey.created_at > rotationInterval) { allKeys = await this.runSingleFlight( (keys) => nowSeconds - selectNewestKey(keys).created_at <= rotationInterval, - (freshKeys) => this.rotateTo(freshKeys) + (freshKeys) => this.rotateTo(freshKeys, configuredAlg) ); signerKey = selectNewestKey(allKeys); } @@ -427,6 +514,10 @@ export class MCPKeyStore { * 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 carries a wrong `alg` (legacy rows: pre-ES256 + * code persisted every pin as 'RS256', even EC material): re-persist with + * the material-derived alg so the row becomes usable after upgrade. + * * 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 @@ -436,10 +527,11 @@ export class MCPKeyStore { * it. The bump is idempotent across nodes (same kid, same condition). * * Kid assignment when persisting: - * - `rs256-default` when absent (legacy compat; deterministic across nodes). + * - `rs256-default` for an RSA pin when absent (legacy compat; deterministic + * across nodes). * - `pinned-` when `rs256-default` holds different - * material — also deterministic, so concurrent puts from clustered nodes are - * idempotent. + * material, or for a non-RSA pin — 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. @@ -453,12 +545,18 @@ export class MCPKeyStore { }; } const pinnedPublicKeyPem = pinnedPublicKeyMemo.publicPem; + // The pin's true algorithm, from its key material (throws on unsupported + // types). Derived up front so a matching row persisted with a wrong alg — + // pre-ES256 code stored EVERY pin as 'RS256', even EC ones — is repaired + // rather than returned as-is (it would fail at jwt.sign forever). + const pinnedAlg = algFromPrivateKeyPem(signingKeyPem); let allKeys = await this.enumerateKeys(); const existing = allKeys.find((k) => k.public_key_pem === pinnedPublicKeyPem); - // Fast path: pin is already in the table AND is already the newest key. - if (existing && existing.kid === selectNewestKey(allKeys).kid) { + // Fast path: pin is in the table with the correct alg AND is already the + // newest key. A wrong-alg legacy row falls through for repair. + if (existing && existing.alg === pinnedAlg && existing.kid === selectNewestKey(allKeys).kid) { setImmediate(() => { this.garbageCollect(allKeys, existing, accessTtl).catch((err) => { this.logger?.error?.('MCP key GC failed (non-fatal):', err instanceof Error ? err.message : String(err)); @@ -467,23 +565,35 @@ export class MCPKeyStore { return existing; } - // Write path — single-flight (handles both pin-persist and created_at bump). + // Write path — single-flight (pin-persist, created_at bump, or alg repair). const pinnedIsNewest = (keys: MCPSigningKeyRecord[]) => { const ex = keys.find((k) => k.public_key_pem === pinnedPublicKeyPem); - return ex != null && ex.kid === selectNewestKey(keys).kid; + return ex != null && ex.alg === pinnedAlg && ex.kid === selectNewestKey(keys).kid; }; allKeys = await this.runSingleFlight(pinnedIsNewest, async (freshKeys) => { const freshExisting = freshKeys.find((k) => k.public_key_pem === pinnedPublicKeyPem); if (freshExisting) { - // Pin is in the table but not the newest — bump created_at. - const bumped: MCPSigningKeyRecord = { ...freshExisting, created_at: Math.floor(Date.now() / 1000) }; + // Pin is in the table but not the newest (bump created_at) and/or + // carries a wrong legacy alg (repair from key material). + const bumped: MCPSigningKeyRecord = { + ...freshExisting, + alg: pinnedAlg, + created_at: Math.floor(Date.now() / 1000), + }; const table = getKeysTable(); try { await table.put(encodeRecord(bumped)); invalidateEnumCache(); - this.logger?.info?.('MCP: bumped pinned key created_at so it leads GC order, kid:', bumped.kid); + if (freshExisting.alg !== pinnedAlg) { + this.logger?.info?.( + `MCP: repaired pinned key alg ${freshExisting.alg} -> ${pinnedAlg} (legacy row), kid:`, + bumped.kid + ); + } else { + this.logger?.info?.('MCP: bumped pinned key created_at so it leads GC order, kid:', bumped.kid); + } } catch (error) { this.logger?.error?.('Failed to bump pinned MCP signing key created_at:', error); return freshKeys; // Non-fatal: return without bump. @@ -501,12 +611,15 @@ export class MCPKeyStore { return afterBump; } - // Pin not in table — choose kid and persist. + // Pin not in table — choose kid and persist. Only an RSA pin may + // claim the legacy `rs256-default` kid; EC pins always get the + // deterministic fingerprint kid. + const alg = pinnedAlg; const existingDefault = freshKeys.find((k) => k.kid === SIGNING_KEY_ID); - const kid = existingDefault ? pinnedKidFromPem(pinnedPublicKeyPem) : SIGNING_KEY_ID; + const kid = existingDefault || alg !== 'RS256' ? pinnedKidFromPem(pinnedPublicKeyPem) : SIGNING_KEY_ID; const record: MCPSigningKeyRecord = { kid, - alg: 'RS256', + alg, public_key_pem: pinnedPublicKeyPem, private_key_pem: signingKeyPem, created_at: Math.floor(Date.now() / 1000), @@ -515,7 +628,7 @@ export class MCPKeyStore { try { await table.put(encodeRecord(record)); invalidateEnumCache(); - this.logger?.info?.('MCP: persisted pinned RS256 signing key as signer, kid:', kid); + this.logger?.info?.(`MCP: persisted pinned ${alg} signing key as signer, kid:`, kid); } catch (error) { this.logger?.error?.('Failed to persist pinned MCP signing key:', error); throw error; @@ -554,15 +667,15 @@ export class MCPKeyStore { * first-boot path). Re-enumerates after persisting to adopt the converged * winner under concurrent first-boot races from other processes. */ - private async generateAndPersistFirstKey(): Promise { - const { publicKeyPem, privateKeyPem } = await generateRsaKeyPair(); + private async generateAndPersistFirstKey(alg: SupportedSigningAlg): Promise { + const { publicKeyPem, privateKeyPem } = await generateSigningKeyPair(alg); // UUID kid: two nodes racing first boot generate different keypairs; a // shared kid would let one overwrite the other, stranding the loser's // already-signed tokens with a kid whose JWKS entry is a different key. const record: MCPSigningKeyRecord = { kid: randomUUID(), - alg: 'RS256', + alg, public_key_pem: publicKeyPem, private_key_pem: privateKeyPem, created_at: Math.floor(Date.now() / 1000), @@ -576,7 +689,7 @@ export class MCPKeyStore { this.logger?.error?.('Failed to persist MCP signing key:', error); throw error; } - this.logger?.info?.('MCP: generated and persisted RS256 signing key, kid:', record.kid); + this.logger?.info?.(`MCP: generated and persisted ${alg} signing key, kid:`, record.kid); // Re-enumerate to adopt the persisted state (convergence under cross-process races). let afterWrite: MCPSigningKeyRecord[] = []; @@ -602,11 +715,11 @@ export class MCPKeyStore { * Generate a new keypair under a UUID kid, persist, and re-enumerate. * Returns the updated key set. */ - private async rotateTo(currentKeys: MCPSigningKeyRecord[]): Promise { - const { publicKeyPem, privateKeyPem } = await generateRsaKeyPair(); + private async rotateTo(currentKeys: MCPSigningKeyRecord[], alg: SupportedSigningAlg): Promise { + const { publicKeyPem, privateKeyPem } = await generateSigningKeyPair(alg); const newKey: MCPSigningKeyRecord = { kid: randomUUID(), - alg: 'RS256', + alg, public_key_pem: publicKeyPem, private_key_pem: privateKeyPem, created_at: Math.floor(Date.now() / 1000), diff --git a/src/lib/mcp/token.ts b/src/lib/mcp/token.ts index c60125e..81f0f16 100644 --- a/src/lib/mcp/token.ts +++ b/src/lib/mcp/token.ts @@ -2,7 +2,7 @@ * MCP Token Endpoint (POST /oauth/mcp/token) * * Exchanges an authorization code (PKCE-verified) or a refresh token for an - * audience-bound RS256 JWT access token. Mirrors DCR's JSON request/response + * audience-bound signed JWT access token. Mirrors DCR's JSON request/response * shape (`{ status, body }` with OAuth 2.0 error objects). * * Access tokens are stateless (no token-table round trip); refresh tokens use diff --git a/src/lib/mcp/tokenIssuer.ts b/src/lib/mcp/tokenIssuer.ts index 21508ad..a8709d1 100644 --- a/src/lib/mcp/tokenIssuer.ts +++ b/src/lib/mcp/tokenIssuer.ts @@ -1,20 +1,37 @@ /** * MCP Access-Token Issuer * - * Mints and verifies RS256 JWT access tokens, and serializes public keys to - * JWK form for the JWKS endpoint. Tokens are stateless and audience-bound - * (RFC 8707 / RFC 9068-aligned): the `aud` claim carries the canonical MCP - * resource URI, so Stage 5's `withMCPAuth` can reject tokens minted for a - * different resource. No upstream IdP token is ever embedded. + * Mints and verifies JWT access tokens (RS256 or ES256, per the signing key's + * `alg`), and serializes public keys to JWK form for the JWKS endpoint. Tokens + * are stateless and audience-bound (RFC 8707 / RFC 9068-aligned): the `aud` + * claim carries the canonical MCP resource URI, so Stage 5's `withMCPAuth` can + * reject tokens minted for a different resource. No upstream IdP token is ever + * embedded. * - * RS256 only in v1 — `jsonwebtoken` cannot emit EdDSA. EdDSA would require a - * JOSE implementation and is deferred (see #86 follow-up). + * EdDSA is not supported — `jsonwebtoken` cannot emit it; it would require a + * JOSE implementation and is deferred (see #127). */ import { createPublicKey, randomUUID } from 'node:crypto'; import jwt from 'jsonwebtoken'; import type { MCPPublicKeyRecord, MCPSigningKeyRecord } from '../../types.ts'; +/** Signing algorithms the issuer can mint and verify. */ +export const SUPPORTED_SIGNING_ALGS = ['RS256', 'ES256'] as const; +export type SupportedSigningAlg = (typeof SUPPORTED_SIGNING_ALGS)[number]; + +/** + * The signing key's declared algorithm, validated against the supported set. + * Absent `alg` (pre-`alg`-column legacy rows) defaults to RS256. + */ +function keyAlg(key: { alg?: string }): SupportedSigningAlg { + const alg = key.alg ?? 'RS256'; + if (!SUPPORTED_SIGNING_ALGS.includes(alg as SupportedSigningAlg)) { + throw new Error(`unsupported signing algorithm: ${alg}`); + } + return alg as SupportedSigningAlg; +} + export interface MintAccessTokenParams { issuer: string; subject: string; @@ -49,7 +66,7 @@ export function signAccessToken( const payload: Record = { client_id: params.clientId }; if (params.scope) payload.scope = params.scope; const token = jwt.sign(payload, key.private_key_pem, { - algorithm: 'RS256', + algorithm: keyAlg(key), keyid: key.kid, issuer: params.issuer, audience: params.audience, @@ -72,7 +89,7 @@ export interface VerifyOptions { */ export function verifyAccessToken(token: string, publicKeyPem: string, options?: VerifyOptions): jwt.JwtPayload { return jwt.verify(token, publicKeyPem, { - algorithms: ['RS256'], + algorithms: [...SUPPORTED_SIGNING_ALGS], audience: options?.audience, issuer: options?.issuer, }) as jwt.JwtPayload; @@ -95,8 +112,9 @@ export interface VerifyWithKeySetOptions { * - `kid` absent → the sole key is used. Ambiguous (the set has >1 key) or an * empty set throws. * - * Signature is verified RS256-only (pinning `algorithms` blocks `alg: none` - * and RS/HS confusion), and `audience` + `issuer` are enforced. Throws on any + * Signature verification pins `algorithms` to the selected key's declared + * `alg` (blocking `alg: none`, RS/HS confusion, and cross-alg substitution), + * and `audience` + `issuer` are enforced. Throws on any * failure so callers (withMCPAuth) can fail closed. This is the production * counterpart to {@link verifyAccessToken} and keeps all `jsonwebtoken` usage * inside this module. @@ -128,24 +146,22 @@ export function verifyAccessTokenWithKeySet( } return jwt.verify(token, key.public_key_pem, { - algorithms: ['RS256'], + algorithms: [keyAlg(key)], audience: options.audience, issuer: options.issuer, }) as jwt.JwtPayload; } /** - * Serialize an RSA public key (PEM) to a JWK for publication at the JWKS - * endpoint. Includes `use`, `alg`, and `kid` so verifiers can select the key. + * Serialize a public key (PEM) to a JWK for publication at the JWKS endpoint. + * Branches on the key type Node exports: RSA → `n`/`e`, EC → `crv`/`x`/`y`. + * Includes `use`, `alg`, and `kid` so verifiers can select the key. */ export function publicKeyToJwk(publicKeyPem: string, kid: string, alg = 'RS256'): Record { - const jwk = createPublicKey(publicKeyPem).export({ format: 'jwk' }) as { n: string; e: string }; - return { - kty: 'RSA', - n: jwk.n, - e: jwk.e, - alg, - use: 'sig', - kid, - }; + const jwk = createPublicKey(publicKeyPem).export({ format: 'jwk' }) as Record; + const base = { alg, use: 'sig', kid }; + if (jwk.kty === 'EC') { + return { kty: 'EC', crv: jwk.crv, x: jwk.x, y: jwk.y, ...base }; + } + return { kty: 'RSA', n: jwk.n, e: jwk.e, ...base }; } diff --git a/src/lib/mcp/wellKnown.ts b/src/lib/mcp/wellKnown.ts index d564902..71a1fdb 100644 --- a/src/lib/mcp/wellKnown.ts +++ b/src/lib/mcp/wellKnown.ts @@ -24,7 +24,7 @@ import type { Logger, MCPConfig } from '../../types.ts'; import { dcrEnabled } from './dcr.ts'; -import { MCPKeyStore } from './keyStore.ts'; +import { MCPKeyStore, resolveEffectiveAlg } from './keyStore.ts'; import { publicKeyToJwk } from './tokenIssuer.ts'; interface HarperRequest { @@ -147,11 +147,23 @@ export function buildProtectedResourceMetadata(request: HarperRequest, mcpConfig * RFC 8414 Authorization Server Metadata document. * Advertises the spec-required fields for the MCP authorization spec 2025-06-18. */ -export function buildAuthorizationServerMetadata( +export async function buildAuthorizationServerMetadata( request: HarperRequest, mcpConfig: MCPConfig -): Record { +): Promise> { const issuer = resolveIssuer(request, mcpConfig); + // Advertised signing algs: what will sign next (config/pin-derived) UNIONED + // with the algs of keys still live in the JWKS. During an alg switch — and + // especially its rolling-deploy damper window, where an old-alg key keeps + // signing for up to ALG_SWITCH_MIN_KEY_AGE_SECONDS — tokens of both algs are + // in circulation, and advertising only the config-derived value would + // misdescribe the tokens actually being minted. Read-only (5s-cached + // enumeration); [] before the first mint leaves just the effective alg. + const effectiveAlg = resolveEffectiveAlg(mcpConfig); + const liveKeys = await new MCPKeyStore().getAllPublicKeys(mcpConfig); + const advertisedAlgs = [effectiveAlg, ...liveKeys.map((k) => k.alg ?? 'RS256')].filter( + (alg, index, all) => all.indexOf(alg) === index + ); // CIMD is enabled by default when mcp.enabled; disabled by explicit enabled: false. const cimdEnabled = mcpConfig.clientIdMetadataDocuments?.enabled !== false; // client_credentials is explicit opt-in (#162); advertised only when enabled. @@ -179,9 +191,10 @@ export function buildAuthorizationServerMetadata( ], // EdDSA is the only assertion alg the client_credentials grant verifies. ...(clientCredentialsEnabled ? { token_endpoint_auth_signing_alg_values_supported: ['EdDSA'] } : {}), - // RS256 only in v1 — jsonwebtoken cannot emit EdDSA. Matches the key - // served at the JWKS endpoint; EdDSA is deferred (would need a JOSE lib). - id_token_signing_alg_values_supported: ['RS256'], + // Effective alg first, then any other alg still live in the key set (see + // advertisedAlgs above). Per-key algs are published in the JWKS; EdDSA is + // deferred (#127). + id_token_signing_alg_values_supported: advertisedAlgs, // RFC 8707 §2: server understands the `resource` parameter. resource_parameter_supported: true, // RFC 9207: server emits `iss` on every authorization response redirect. diff --git a/src/lib/mcp/withMCPAuth.ts b/src/lib/mcp/withMCPAuth.ts index 6d956ec..fbf714d 100644 --- a/src/lib/mcp/withMCPAuth.ts +++ b/src/lib/mcp/withMCPAuth.ts @@ -2,7 +2,7 @@ * MCP Bearer-Token Guard (`withMCPAuth`) * * Wraps an app-owned MCP route handler so every request must present a valid - * RS256 access token minted by this plugin's Stage 4 issuer before the handler + * signed access token minted by this plugin's Stage 4 issuer before the handler * runs. On any failure it returns the spec-mandated * `401 + WWW-Authenticate: Bearer resource_metadata="..."` (RFC 9728 §5.1) that * closes the MCP discovery loop, pointing clients at the Protected Resource diff --git a/src/types.ts b/src/types.ts index 53e7793..c5aec2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,13 +75,21 @@ export interface MCPConfig { */ dynamicClientRegistration?: MCPDynamicClientRegistrationConfig; /** - * JWT signing algorithm for issued access tokens. Only "RS256" is supported - * in v1 — jsonwebtoken cannot emit EdDSA. Reserved for a future EdDSA option. - * Default: "RS256". - */ - signingAlgorithm?: 'RS256'; - /** - * PEM-encoded RS256 private key (PKCS#8) to sign access tokens with. When + * JWT signing algorithm for generated access-token signing keys: "RS256" + * (default, universally interoperable) or "ES256" (EC P-256). Changing this + * on a running deployment rotates in a new key under the new algorithm + * (once the current key is at least 5 minutes old — an age floor that + * keeps nodes with briefly-disagreeing configs during a rolling rollout + * from rotating against each other; prefer updating all nodes together); + * previous keys stay in the JWKS until retired, so outstanding tokens keep + * verifying. Ignored when `signingKeyPem` is set — a pinned key's algorithm + * is derived from its key material. EdDSA is not supported (jsonwebtoken + * cannot emit it; see #127). Default: "RS256". + */ + signingAlgorithm?: 'RS256' | 'ES256'; + /** + * PEM-encoded private key (PKCS#8; RSA or EC P-256) to sign access tokens + * with — the signing algorithm is derived from the key type. When * set, it is persisted to the keys table on first use instead of generating * one — operators provide the same key on every node for deterministic, * race-free key material. When unset, a keypair is generated on first boot. @@ -91,7 +99,7 @@ export interface MCPConfig { */ signingKeyPem?: string; /** - * Signing-key rotation interval in seconds. When set and > 0, a new RS256 + * Signing-key rotation interval in seconds. When set and > 0, a new * keypair is lazily generated (at token mint time) whenever the newest key's * age exceeds this value. All public keys remain in the JWKS until their * access tokens can no longer be valid (2× accessTokenTtl after a newer key diff --git a/test/lib/mcp/keyStore.test.js b/test/lib/mcp/keyStore.test.js index f88fa3d..ac54ed4 100644 --- a/test/lib/mcp/keyStore.test.js +++ b/test/lib/mcp/keyStore.test.js @@ -6,7 +6,14 @@ import { describe, it, before, after, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { createPublicKey, generateKeyPairSync } from 'node:crypto'; -import { MCPKeyStore, resetMCPKeysTableCache, SIGNING_KEY_ID, _setCacheNowMs } from '../../../dist/lib/mcp/keyStore.js'; +import { + MCPKeyStore, + resetMCPKeysTableCache, + SIGNING_KEY_ID, + _setCacheNowMs, + algFromPrivateKeyPem, + resolveEffectiveAlg, +} from '../../../dist/lib/mcp/keyStore.js'; import { verifyAccessTokenWithKeySet, signAccessToken } from '../../../dist/lib/mcp/tokenIssuer.js'; // ---- Helpers ---- @@ -873,3 +880,187 @@ describe('MCPKeyStore', () => { } }); }); + +// ---- ES256 (#127) ---- + +describe('MCPKeyStore ES256', () => { + let store; + let originalDatabases; + let stored; + + before(() => { + originalDatabases = global.databases; + }); + after(() => { + global.databases = originalDatabases; + }); + + beforeEach(() => { + resetMCPKeysTableCache(); + store = new MCPKeyStore(); + stored = new Map(); + global.databases = { + oauth: { + harper_oauth_mcp_keys: makeKeysTable(stored), + }, + }; + }); + + function makeEcPems() { + return generateKeyPairSync('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + } + + it('generates an ES256 (EC P-256) keypair on first boot when configured', async () => { + const key = await store.getSigningKey({ signingAlgorithm: 'ES256' }); + assert.equal(key.alg, 'ES256'); + const pub = createPublicKey(key.public_key_pem); + assert.equal(pub.asymmetricKeyType, 'ec'); + assert.equal(pub.asymmetricKeyDetails.namedCurve, 'prime256v1'); + assert.equal(stored.size, 1); + }); + + it('rotates to an ES256 key when config switches from RS256, keeping the old key live', async () => { + // Old enough to clear the alg-switch age floor (rolling-deploy damper). + const rsaKey = seedKey(stored, { kid: 'rsa-old', created_at: Math.floor(Date.now() / 1000) - 600 }); + const key = await store.getSigningKey({ signingAlgorithm: 'ES256' }); + assert.equal(key.alg, 'ES256'); + assert.notEqual(key.kid, rsaKey.kid); + assert.equal(stored.size, 2, 'old RS256 key is not deleted by the switch'); + const publicKeys = await store.getAllPublicKeys(); + const algs = publicKeys.map((k) => k.alg).sort(); + assert.deepEqual(algs, ['ES256', 'RS256'], 'both keys stay in the JWKS during the transition'); + }); + + it('defers the alg switch while the newest key is younger than the age floor (rolling-deploy damper)', async () => { + const rsaKey = seedKey(stored, { kid: 'rsa-fresh' }); // created_at: now + const key = await store.getSigningKey({ signingAlgorithm: 'ES256' }); + assert.equal(key.kid, rsaKey.kid, 'young RS256 key keeps signing for now'); + assert.equal(stored.size, 1, 'no new key generated yet'); + }); + + it('interval rotation does not bypass the alg-switch age floor (P2, #191 review)', async () => { + // 120s-old RS256 key, ES256 config, 60s rotation interval: the switch + // block defers (age <= floor) and interval rotation must not rotate to + // ES256 in its place — that would re-open mixed-rollout churn at the + // interval cadence. + const rsaKey = seedKey(stored, { kid: 'rsa-mid', created_at: Math.floor(Date.now() / 1000) - 120 }); + const key = await store.getSigningKey({ signingAlgorithm: 'ES256', keyRotationInterval: 60 }); + assert.equal(key.kid, rsaKey.kid, 'deferred switch is not bypassed by interval rotation'); + assert.equal(stored.size, 1, 'no key generated'); + }); + + it('repairs a legacy EC pin row persisted as RS256 by pre-ES256 code (P3, #191 review)', async () => { + // Old getOrPersistPinnedKey accepted any PEM and hardcoded alg RS256 — + // an EC pin left a durable rs256-default row that could never sign. + const { publicKey, privateKey } = makeEcPems(); + stored.set(SIGNING_KEY_ID, { + kid: SIGNING_KEY_ID, + alg: 'RS256', + public_key_pem: publicKey, + private_key_pem: privateKey, + created_at: Math.floor(Date.now() / 1000) - 1000, + }); + const key = await store.getSigningKey({ signingKeyPem: privateKey }); + assert.equal(key.alg, 'ES256', 'alg re-derived from key material'); + assert.equal(key.kid, SIGNING_KEY_ID, 'legacy row repaired in place, kid preserved'); + assert.equal(stored.get(SIGNING_KEY_ID).alg, 'ES256', 'repair persisted'); + const { token } = signAccessToken( + { + issuer: 'https://as.example.com', + subject: 'alice@example.com', + audience: 'https://app.example.com/mcp', + clientId: 'client-123', + ttlSeconds: 3600, + }, + key + ); + const claims = verifyAccessTokenWithKeySet(token, await store.getAllPublicKeys(), { + audience: 'https://app.example.com/mcp', + issuer: 'https://as.example.com', + }); + assert.equal(claims.sub, 'alice@example.com', 'previously-broken pin now signs end-to-end'); + }); + + it('does not rotate when the newest key already matches the configured alg', async () => { + const first = await store.getSigningKey({ signingAlgorithm: 'ES256' }); + resetMCPKeysTableCache(); + const second = await new MCPKeyStore().getSigningKey({ signingAlgorithm: 'ES256' }); + assert.equal(second.kid, first.kid, 'same ES256 key reused'); + assert.equal(stored.size, 1); + }); + + it('treats a legacy record without alg as RS256 (no spurious rotation under default config)', async () => { + seedKey(stored, { kid: 'legacy', alg: undefined }); + const key = await store.getSigningKey(); + assert.equal(key.kid, 'legacy', 'legacy no-alg key keeps signing under the RS256 default'); + assert.equal(stored.size, 1); + }); + + it('derives ES256 from a pinned EC key and never claims the legacy rs256-default kid', async () => { + const { privateKey } = makeEcPems(); + const key = await store.getSigningKey({ signingKeyPem: privateKey }); + assert.equal(key.alg, 'ES256'); + assert.notEqual(key.kid, SIGNING_KEY_ID, 'EC pin gets a fingerprint kid, not rs256-default'); + assert.match(key.kid, /^pinned-[0-9a-f]{16}$/); + }); + + it('signs and verifies end-to-end with a generated ES256 key', async () => { + const key = await store.getSigningKey({ signingAlgorithm: 'ES256' }); + const { token } = signAccessToken( + { + issuer: 'https://as.example.com', + subject: 'alice@example.com', + audience: 'https://app.example.com/mcp', + clientId: 'client-123', + ttlSeconds: 3600, + }, + key + ); + const publicKeys = await store.getAllPublicKeys(); + const claims = verifyAccessTokenWithKeySet(token, publicKeys, { + audience: 'https://app.example.com/mcp', + issuer: 'https://as.example.com', + }); + assert.equal(claims.sub, 'alice@example.com'); + }); +}); + +describe('signing-alg helpers', () => { + function makePems(type, options) { + return generateKeyPairSync(type, { + ...options, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + } + + it('algFromPrivateKeyPem: RSA → RS256, EC P-256 → ES256', () => { + assert.equal(algFromPrivateKeyPem(makePems('rsa', { modulusLength: 2048 }).privateKey), 'RS256'); + assert.equal(algFromPrivateKeyPem(makePems('ec', { namedCurve: 'P-256' }).privateKey), 'ES256'); + }); + + it('algFromPrivateKeyPem: rejects unsupported curves and key types', () => { + assert.throws( + () => algFromPrivateKeyPem(makePems('ec', { namedCurve: 'secp384r1' }).privateKey), + /unsupported EC curve/ + ); + assert.throws(() => algFromPrivateKeyPem(makePems('ed25519', {}).privateKey), /unsupported signingKeyPem key type/); + }); + + it('resolveEffectiveAlg: configured alg without a pin, pin-derived alg with one', () => { + assert.equal(resolveEffectiveAlg(), 'RS256'); + assert.equal(resolveEffectiveAlg({ signingAlgorithm: 'ES256' }), 'ES256'); + const ecPin = makePems('ec', { namedCurve: 'P-256' }).privateKey; + assert.equal(resolveEffectiveAlg({ signingKeyPem: ecPin }), 'ES256'); + const rsaPin = makePems('rsa', { modulusLength: 2048 }).privateKey; + assert.equal( + resolveEffectiveAlg({ signingKeyPem: rsaPin, signingAlgorithm: 'ES256' }), + 'RS256', + 'pin wins over config' + ); + }); +}); diff --git a/test/lib/mcp/tokenIssuer.test.js b/test/lib/mcp/tokenIssuer.test.js index 9135f3a..fbef03f 100644 --- a/test/lib/mcp/tokenIssuer.test.js +++ b/test/lib/mcp/tokenIssuer.test.js @@ -179,3 +179,72 @@ describe('verifyAccessTokenWithKeySet', () => { ); }); }); + +function makeEcKey(kid) { + const { publicKey, privateKey } = generateKeyPairSync('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + return { kid, alg: 'ES256', public_key_pem: publicKey, private_key_pem: privateKey, created_at: 1700000000 }; +} + +describe('tokenIssuer ES256', () => { + const ecKey = makeEcKey('ec-key-1'); + + it('signs and verifies an ES256 access token with the expected claims', () => { + const { token, jti } = signAccessToken(baseParams, ecKey); + const claims = verifyAccessToken(token, ecKey.public_key_pem, { + audience: baseParams.audience, + issuer: baseParams.issuer, + }); + assert.equal(claims.sub, baseParams.subject); + assert.equal(claims.client_id, baseParams.clientId); + assert.equal(claims.jti, jti); + }); + + it('puts the kid and ES256 alg in the JWT header', () => { + const { token } = signAccessToken(baseParams, ecKey); + const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8')); + assert.equal(header.kid, 'ec-key-1'); + assert.equal(header.alg, 'ES256'); + }); + + it('throws on a key record with an unsupported alg', () => { + assert.throws(() => signAccessToken(baseParams, { ...ecKey, alg: 'EdDSA' }), /unsupported signing algorithm/); + }); + + it('serializes an EC public key to a JWK with crv/x/y and no private material', () => { + const jwk = publicKeyToJwk(ecKey.public_key_pem, ecKey.kid, ecKey.alg); + assert.equal(jwk.kty, 'EC'); + assert.equal(jwk.crv, 'P-256'); + assert.ok(jwk.x, 'x coordinate present'); + assert.ok(jwk.y, 'y coordinate present'); + assert.equal(jwk.n, undefined, 'no RSA modulus on an EC JWK'); + assert.equal(jwk.alg, 'ES256'); + assert.equal(jwk.use, 'sig'); + assert.equal(jwk.kid, 'ec-key-1'); + assert.equal(jwk.d, undefined, 'private scalar must NOT be present'); + }); + + it('verifies against a mixed RS256/ES256 key set by kid', () => { + const { token } = signAccessToken(baseParams, ecKey); + const claims = verifyAccessTokenWithKeySet(token, [key, makeRsaKey('other-rsa'), ecKey], { + audience: baseParams.audience, + issuer: baseParams.issuer, + }); + assert.equal(claims.sub, baseParams.subject); + }); + + it("rejects a token whose header alg does not match the selected key's declared alg", () => { + // Signed ES256, but the key record in the set (same kid) claims RS256 — + // verification must pin to the declared alg and fail. + const { token } = signAccessToken(baseParams, ecKey); + assert.throws(() => + verifyAccessTokenWithKeySet(token, [{ ...ecKey, alg: 'RS256' }], { + audience: baseParams.audience, + issuer: baseParams.issuer, + }) + ); + }); +}); diff --git a/test/lib/mcp/wellKnown.test.js b/test/lib/mcp/wellKnown.test.js index 7685c0d..447edbf 100644 --- a/test/lib/mcp/wellKnown.test.js +++ b/test/lib/mcp/wellKnown.test.js @@ -13,6 +13,7 @@ import { resolveResource, } from '../../../dist/lib/mcp/wellKnown.js'; import { resetMCPKeysTableCache } from '../../../dist/lib/mcp/keyStore.js'; +import { generateKeyPairSync } from 'node:crypto'; // Bun runs every test file in ONE shared process (Node isolates per file). The // JWKS tests below assert an EMPTY key set, which relies on the MCPKeyStore @@ -32,34 +33,34 @@ function makeRequest(overrides = {}) { } describe('MCP well-known: URI resolution', () => { - it('resolveIssuer uses configured value when set', () => { + it('resolveIssuer uses configured value when set', async () => { const req = makeRequest(); const issuer = resolveIssuer(req, { issuer: 'https://canonical.example.com' }); assert.equal(issuer, 'https://canonical.example.com'); }); - it('resolveIssuer derives from request scheme + host when unset', () => { + it('resolveIssuer derives from request scheme + host when unset', async () => { const req = makeRequest({ protocol: 'https', host: 'auto.example.com' }); assert.equal(resolveIssuer(req, {}), 'https://auto.example.com'); }); - it('resolveIssuer takes the first value when the Host header is an array', () => { + it('resolveIssuer takes the first value when the Host header is an array', async () => { const req = makeRequest({ protocol: 'https', host: undefined, headers: { host: ['first.example.com', 'second'] } }); assert.equal(resolveIssuer(req, {}), 'https://first.example.com'); }); - it('resolveResource uses configured value when set', () => { + it('resolveResource uses configured value when set', async () => { const req = makeRequest(); const resource = resolveResource(req, { resource: 'https://canonical.example.com/mcp-v2' }); assert.equal(resource, 'https://canonical.example.com/mcp-v2'); }); - it('resolveResource derives /mcp when unset', () => { + it('resolveResource derives /mcp when unset', async () => { const req = makeRequest({ protocol: 'https', host: 'derived.example.com' }); assert.equal(resolveResource(req, {}), 'https://derived.example.com/mcp'); }); - it('resolveResource respects configured issuer when resource is unset', () => { + it('resolveResource respects configured issuer when resource is unset', async () => { const req = makeRequest(); const resource = resolveResource(req, { issuer: 'https://forced.example.com' }); assert.equal(resource, 'https://forced.example.com/mcp'); @@ -67,18 +68,18 @@ describe('MCP well-known: URI resolution', () => { }); describe('MCP well-known: PRM document (RFC 9728)', () => { - it('includes required fields: resource + authorization_servers', () => { + it('includes required fields: resource + authorization_servers', async () => { const doc = buildProtectedResourceMetadata(makeRequest(), { enabled: true }); assert.equal(doc.resource, 'https://app.example.com/mcp'); assert.deepEqual(doc.authorization_servers, ['https://app.example.com']); }); - it('advertises header-based bearer method only (no query-string)', () => { + it('advertises header-based bearer method only (no query-string)', async () => { const doc = buildProtectedResourceMetadata(makeRequest(), { enabled: true }); assert.deepEqual(doc.bearer_methods_supported, ['header']); }); - it('reflects configured canonical resource URI verbatim', () => { + it('reflects configured canonical resource URI verbatim', async () => { const doc = buildProtectedResourceMetadata(makeRequest(), { enabled: true, resource: 'https://my-app.example.com/mcp', @@ -88,53 +89,53 @@ describe('MCP well-known: PRM document (RFC 9728)', () => { }); describe('MCP well-known: AS metadata document (RFC 8414)', () => { - it('includes spec-required endpoints', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + it('includes spec-required endpoints', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); assert.equal(doc.issuer, 'https://app.example.com'); assert.equal(doc.authorization_endpoint, 'https://app.example.com/oauth/mcp/authorize'); assert.equal(doc.token_endpoint, 'https://app.example.com/oauth/mcp/token'); assert.equal(doc.jwks_uri, 'https://app.example.com/.well-known/jwks.json'); }); - it('omits registration_endpoint when the DCR block is absent (default disabled, #182)', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + it('omits registration_endpoint when the DCR block is absent (default disabled, #182)', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); assert.equal(doc.registration_endpoint, undefined); }); - it('advertises registration_endpoint under the same predicate the handler enforces', () => { - const withBlock = buildAuthorizationServerMetadata(makeRequest(), { + it('advertises registration_endpoint under the same predicate the handler enforces', async () => { + const withBlock = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true, dynamicClientRegistration: {}, }); assert.equal(withBlock.registration_endpoint, 'https://app.example.com/oauth/mcp/register'); - const explicitlyDisabled = buildAuthorizationServerMetadata(makeRequest(), { + const explicitlyDisabled = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true, dynamicClientRegistration: { enabled: false }, }); assert.equal(explicitlyDisabled.registration_endpoint, undefined); }); - it('omits registration_endpoint for a bare null DCR block (YAML empty key)', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { + it('omits registration_endpoint for a bare null DCR block (YAML empty key)', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true, dynamicClientRegistration: null, }); assert.equal(doc.registration_endpoint, undefined); }); - it('advertises PKCE S256 only (no plain)', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + it('advertises PKCE S256 only (no plain)', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); assert.deepEqual(doc.code_challenge_methods_supported, ['S256']); }); - it('advertises only authorization_code + refresh_token grants', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + it('advertises only authorization_code + refresh_token grants', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); assert.deepEqual(doc.grant_types_supported, ['authorization_code', 'refresh_token']); }); - it('advertises client_credentials + private_key_jwt + EdDSA only when the grant is enabled', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { + it('advertises client_credentials + private_key_jwt + EdDSA only when the grant is enabled', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true, clientCredentials: { enabled: true }, }); @@ -143,50 +144,97 @@ describe('MCP well-known: AS metadata document (RFC 8414)', () => { assert.deepEqual(doc.token_endpoint_auth_signing_alg_values_supported, ['EdDSA']); }); - it('omits client_credentials discovery when the grant is disabled or unset', () => { + it('omits client_credentials discovery when the grant is disabled or unset', async () => { for (const mcpConfig of [{ enabled: true }, { enabled: true, clientCredentials: { enabled: false } }]) { - const doc = buildAuthorizationServerMetadata(makeRequest(), mcpConfig); + const doc = await buildAuthorizationServerMetadata(makeRequest(), mcpConfig); assert.ok(!doc.grant_types_supported.includes('client_credentials')); assert.ok(!doc.token_endpoint_auth_methods_supported.includes('private_key_jwt')); assert.equal(doc.token_endpoint_auth_signing_alg_values_supported, undefined); } }); - it('advertises only `code` response type', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + it('advertises only `code` response type', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); assert.deepEqual(doc.response_types_supported, ['code']); }); - it('advertises token-endpoint auth methods including the public-client default', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + it('advertises token-endpoint auth methods including the public-client default', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); const methods = doc.token_endpoint_auth_methods_supported; assert.ok(methods.includes('none'), 'public clients (none) must be advertised'); assert.ok(methods.includes('client_secret_basic')); assert.ok(methods.includes('client_secret_post')); }); - it('advertises RS256 as the only signing algorithm (EdDSA deferred)', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + it('advertises RS256 as the signing algorithm by default (EdDSA deferred)', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); assert.deepEqual(doc.id_token_signing_alg_values_supported, ['RS256']); }); - it('signals RFC 8707 resource-parameter support', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + it('advertises ES256 when mcp.signingAlgorithm is ES256 (#127)', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true, signingAlgorithm: 'ES256' }); + assert.deepEqual(doc.id_token_signing_alg_values_supported, ['ES256']); + }); + + it('advertises both algs while an old-alg key is still live in the key set (P1, #191 review)', async () => { + // During the alg-switch damper window an RS256 key keeps signing while + // config says ES256 — metadata must reflect both, not just the config. + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const originalDatabases = global.databases; + global.databases = { + oauth: { + harper_oauth_mcp_keys: { + search: async function* () { + yield { + kid: 'rsa-live', + alg: 'RS256', + public_key_pem: publicKey, + private_key_pem: privateKey, + created_at: Math.floor(Date.now() / 1000), + }; + }, + }, + }, + }; + try { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true, signingAlgorithm: 'ES256' }); + assert.deepEqual(doc.id_token_signing_alg_values_supported, ['ES256', 'RS256']); + } finally { + global.databases = originalDatabases; + } + }); + + it("advertises the pinned key's algorithm over mcp.signingAlgorithm (pin wins)", async () => { + const { privateKey } = generateKeyPairSync('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true, signingKeyPem: privateKey }); + assert.deepEqual(doc.id_token_signing_alg_values_supported, ['ES256']); + }); + + it('signals RFC 8707 resource-parameter support', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); assert.equal(doc.resource_parameter_supported, true); }); - it('signals RFC 9207 authorization_response_iss_parameter_supported', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + it('signals RFC 9207 authorization_response_iss_parameter_supported', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); assert.equal(doc.authorization_response_iss_parameter_supported, true); }); - it('advertises client_id_metadata_document_supported when CIMD is enabled (default)', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + it('advertises client_id_metadata_document_supported when CIMD is enabled (default)', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); assert.equal(doc.client_id_metadata_document_supported, true); }); - it('does not advertise client_id_metadata_document_supported when CIMD is explicitly disabled', () => { - const doc = buildAuthorizationServerMetadata(makeRequest(), { + it('does not advertise client_id_metadata_document_supported when CIMD is explicitly disabled', async () => { + const doc = await buildAuthorizationServerMetadata(makeRequest(), { enabled: true, clientIdMetadataDocuments: { enabled: false }, }); @@ -221,7 +269,7 @@ describe('MCP well-known: handler registration', () => { getConfig = () => currentConfig; }); - it('registers three handlers with the expected urlPaths', () => { + it('registers three handlers with the expected urlPaths', async () => { registerWellKnownHandlers(mockServer, getConfig); assert.equal(registrations.length, 3); const paths = registrations.map((r) => r.options.urlPath).sort(); @@ -232,7 +280,7 @@ describe('MCP well-known: handler registration', () => { ]); }); - it('logs and skips when server.http() is not available', () => { + it('logs and skips when server.http() is not available', async () => { const warnings = []; const logger = { warn: (msg) => warnings.push(msg) }; registerWellKnownHandlers({}, getConfig, logger); @@ -263,7 +311,7 @@ describe('MCP well-known: handler registration', () => { assert.equal(result, 'fallthrough'); }); - it('falls through to next when config is undefined', () => { + it('falls through to next when config is undefined', async () => { currentConfig = undefined; const handler = findHandler('/.well-known/oauth-authorization-server'); let nextCalled = false; @@ -275,7 +323,7 @@ describe('MCP well-known: handler registration', () => { assert.equal(nextCalled, true); }); - it('falls through to next on sub-paths (urlPath is prefix-matched)', () => { + it('falls through to next on sub-paths (urlPath is prefix-matched)', async () => { currentConfig = { enabled: true }; const handler = findHandler('/.well-known/oauth-protected-resource'); let nextCalled = false; @@ -297,7 +345,7 @@ describe('MCP well-known: handler registration', () => { assert.equal(response.status, 200, 'relative "/" exact match should be served'); }); - it('falls through on a relative sub-path ("/extra")', () => { + it('falls through on a relative sub-path ("/extra")', async () => { currentConfig = { enabled: true }; const handler = findHandler('/.well-known/oauth-protected-resource'); let nextCalled = false; @@ -361,7 +409,7 @@ describe('MCP well-known: handler registration', () => { assert.equal(alsoServed.status, 200, 'slash-less form of the same resource is served'); }); - it('only the PRM is resource-path-aware (AS metadata sub-path 404s)', () => { + it('only the PRM is resource-path-aware (AS metadata sub-path 404s)', async () => { currentConfig = { enabled: true }; const handler = findHandler('/.well-known/oauth-authorization-server'); let nextCalled = false;