diff --git a/docs/configuration.md b/docs/configuration.md index 6ef7bbb..e9228e5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,6 +97,8 @@ Opt-in support for the Model Context Protocol authorization flow ([issue #86](ht | `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.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) | diff --git a/docs/lifecycle-hooks.md b/docs/lifecycle-hooks.md index faae9eb..3a0fe9a 100644 --- a/docs/lifecycle-hooks.md +++ b/docs/lifecycle-hooks.md @@ -342,14 +342,21 @@ Called after an MCP access or refresh token is minted. Because it runs detached ```typescript async function onMCPTokenIssued( - event: { type: 'access' | 'refresh'; client_id: string; sub: string; aud: string; scope?: string; jti: string }, + event: { + type: 'access' | 'refresh' | 'client_credentials'; + client_id: string; + sub: string; + aud: string; + scope?: string; + jti: string; + }, request: Request ): Promise; ``` **Parameters:** -- `event` - Identifies the token issued: `type` (`access` for the authorization-code grant, `refresh` for a rotation), `client_id`, `sub`, `aud`, `scope` (optional), and `jti` (the token id) +- `event` - Identifies the token issued: `type` (`access` for the authorization-code grant, `refresh` for a rotation, `client_credentials` for the headless-agent grant — where `sub` is the client, not a user), `client_id`, `sub`, `aud`, `scope` (optional), and `jti` (the token id) - `request` - The HTTP request that triggered issuance **Returns:** void. Fire-and-forget — the hook is **not awaited** (it runs detached, so it never delays or blocks token issuance); a throwing hook is caught and logged, never surfaced. diff --git a/docs/mcp-oauth.md b/docs/mcp-oauth.md index 16f1c72..25fae99 100644 --- a/docs/mcp-oauth.md +++ b/docs/mcp-oauth.md @@ -168,7 +168,7 @@ form the `WWW-Authenticate` challenge advertises. | ---------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | `/oauth/mcp/register` | POST | RFC 7591 Dynamic Client Registration. Open by default; gate with `initialAccessToken`. Returns `201`. | | `/oauth/mcp/authorize` | GET | OAuth 2.1 + PKCE. Requires `client_id`, `redirect_uri`, `response_type=code`, `code_challenge`, `code_challenge_method=S256`, `resource`. | -| `/oauth/mcp/token` | POST | Grants: `authorization_code`, `refresh_token`. Returns the token pair with `Cache-Control: no-store`. | +| `/oauth/mcp/token` | POST | Grants: `authorization_code`, `refresh_token`, and (opt-in) `client_credentials`. Returns the token pair with `Cache-Control: no-store`. | > `mcp` is a reserved provider name — the plugin refuses to start if you configure > a provider called `mcp`, because it would collide with `/oauth/mcp/*`. @@ -608,9 +608,9 @@ hostname string is accepted and normalized to a one-element list. Omitting gate still applies. > **v1 limitation:** only `token_endpoint_auth_method: none` (public clients) is -> supported for CIMD clients. `private_key_jwt` authentication will be activated -> by issue [#159](https://github.com/HarperFast/oauth/issues/159). Other auth -> methods are rejected with `invalid_client`. +> supported for **interactive** CIMD clients. `private_key_jwt` is accepted only +> in the [headless-agent document shape](#headless-agents-client_credentials) — +> any other combination is rejected with `invalid_client`. ### Stored/DCR clients are unchanged @@ -619,6 +619,102 @@ not parse as an HTTPS URL with a non-root path goes directly to the DCR store as before. CIMD clients and DCR clients can coexist; existing DCR registrations are not affected. +## Headless agents (client_credentials) + +Autonomous agents — no browser, no human at request time — authenticate **as +themselves** with the RFC 7523 `client_credentials` grant (`private_key_jwt`, +EdDSA/Ed25519). The grant is **explicit opt-in** and gated on a pinned CIMD +allowlist: + +```yaml +mcp: + enabled: true + issuer: https://as.example.com + clientIdMetadataDocuments: + allowedHosts: + - agents.example.com # REQUIRED for client_credentials — startup error without it + clientCredentials: + enabled: true + accessTokenTtl: 300 # default; agents re-mint on 401 +``` + +Agents don't register. Each agent's `client_id` is an HTTPS URL to a CIMD +document carrying its public Ed25519 key set: + +```json +{ + "client_id": "https://agents.example.com/fleet/agent-1.json", + "client_name": "Fleet Agent 1", + "grant_types": ["client_credentials"], + "token_endpoint_auth_method": "private_key_jwt", + "jwks": { "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": "…", "kid": "agent-key-1" }] } +} +``` + +Document rules (all rejections are `invalid_client`): + +- `grant_types` must be exactly `["client_credentials"]` — no mixing with + redirect-based grants or `refresh_token`. +- `token_endpoint_auth_method` must be `private_key_jwt`. +- `jwks` is required inline: 1–8 **public** OKP/Ed25519 keys. Any key carrying + private material (`d`) rejects the whole document. `jwks_uri` is rejected — + the document itself is the hosted-key story, and a second SSRF-fetch surface + isn't worth an indirection. +- `redirect_uris` / `response_types` must be **absent**. This deviates from the + CIMD draft's required-fields list deliberately: RFC 7591 §2 requires + `redirect_uris` only for redirect-based grant types, and a + `client_credentials`-only client has no redirect surface by construction. + (The MCP [OAuth Client Credentials extension](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials) + doesn't profile the document shape; if it later does, revisit.) +- The document's host must be in `clientIdMetadataDocuments.allowedHosts`. The + grant refuses to start without a non-empty allowlist (startup error) and + refuses credentials documents at resolution without it — hosting a reachable + document must never suffice to mint tokens. + +The token request (RFC 7523 §2.2 client authentication): + +``` +POST /oauth/mcp/token +grant_type=client_credentials +client_id=https://agents.example.com/fleet/agent-1.json +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= +resource=https://app.example.com/mcp (optional; must exactly match when present) +``` + +Assertion requirements: `alg: EdDSA`; `iss` = `sub` = the `client_id`; `aud` = +the token endpoint URL exactly; `exp` within 60 s of now; `jti` required and +single-use (a replay is rejected via the shared `mcp_assertion_jtis` table). +A `Basic` header or `client_secret` alongside the assertion is rejected — proof +of key possession is the only accepted authentication for this grant. + +> **Replay-guard bound:** `jti` single-use is enforced best-effort under +> concurrency — Harper's `Table.create()` existence check is not atomic across +> simultaneous in-flight requests ([harper#1745](https://github.com/HarperFast/harper/issues/1745) +> tracks the atomic-reserve contract), so concurrent presentations of the same +> assertion can race; anything after the first row lands is rejected. The +> residual is deliberately narrow: assertions live ≤ 60 s, the grant requires +> an `https:` issuer, and capturing a live assertion in transit therefore +> 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 +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. +`onMCPTokenIssued` fires with `type: 'client_credentials'`. The token's scope +is the document-declared `scope`; a `scope` parameter on the token request is +not honored (a client can never escalate past its registered scope, and +RFC 6749 §3.3 downscoping-on-request is future work). + +Key rotation / revocation semantics: the fleet rotates a key by updating the +agent's metadata document. The change takes effect within the CIMD cache TTL +(up to 24 h, typically 1 h — bound it with `Cache-Control: max-age` on the +document), further bounded by the ≤60 s assertion window and the short access +token TTL. Removing the document (or the host from `allowedHosts`) revokes the +agent on the same schedule; a dropped allowlist takes effect immediately, even +for cached documents. + --- ## Not yet supported (v1.1+) diff --git a/src/index.ts b/src/index.ts index 4b3ecdf..45ff425 100644 --- a/src/index.ts +++ b/src/index.ts @@ -207,6 +207,43 @@ export async function handleApplication(scope: Scope): Promise { ); } } + // client_credentials mints tokens for headless agents with no human in + // the loop, so its prerequisites are startup errors, not runtime 4xxs: + // the CIMD allowlist must be pinned (hosting a reachable metadata + // document must never suffice to mint tokens — #159 design update) and + // CIMD resolution must be on (DCR never registers private_key_jwt + // clients, so without CIMD the grant could authenticate no one). + if (mcpConfig?.enabled && mcpConfig.clientCredentials?.enabled === true) { + const allowedHosts = mcpConfig.clientIdMetadataDocuments?.allowedHosts; + if (!Array.isArray(allowedHosts) || allowedHosts.length === 0) { + throw new Error( + 'mcp.clientCredentials.enabled requires a non-empty mcp.clientIdMetadataDocuments.allowedHosts ' + + 'allowlist — pin the hosts that may serve agent metadata documents.' + ); + } + if (mcpConfig.clientIdMetadataDocuments?.enabled === false) { + throw new Error( + 'mcp.clientCredentials.enabled requires CIMD resolution ' + + '(mcp.clientIdMetadataDocuments.enabled must not be false).' + ); + } + // The token endpoint carries signed assertions in and bearer tokens + // out — RFC 6749 §3.2 requires TLS. An http: issuer is tolerated for + // the interactive flows (the __Host- consent cookie fails safe there), + // but this grant has no such self-protection, so a cleartext remote + // AS is a startup error. Loopback stays allowed for local development. + // (mcp.issuer is guaranteed present and origin-validated by the + // mcp.enabled checks above, which throw before this block runs.) + const issuerUrl = new URL(mcpConfig.issuer!); + const loopback = + issuerUrl.hostname === 'localhost' || issuerUrl.hostname === '127.0.0.1' || issuerUrl.hostname === '[::1]'; + if (issuerUrl.protocol !== 'https:' && !loopback) { + throw new Error( + 'mcp.clientCredentials.enabled requires an https: mcp.issuer (the token endpoint must be TLS ' + + 'per RFC 6749 §3.2); http: is only permitted for loopback development issuers.' + ); + } + } // Warn when the operator sets both a pinned key and a rotation interval — // pin wins and rotation is silently skipped, which could surprise them. if (mcpConfig?.signingKeyPem && mcpConfig?.keyRotationInterval) { diff --git a/src/lib/config.ts b/src/lib/config.ts index bf289bc..b7d395d 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -83,11 +83,20 @@ export function coerceConfigBoolean(value: unknown): boolean | undefined { * `String.includes` would turn into substring matching) is wrapped into a * single-element array; anything that isn't a string or array of strings is * rejected rather than treated as "no restriction". + * - `mcp.clientCredentials.enabled` is coerced the same way — this flag mints + * tokens for headless agents, so a stray truthy string must not enable it + * (it is explicit opt-in, default OFF). */ export function normalizeMcpSecurityConfig(mcpConfig: Record): void { const enabled = coerceConfigBoolean(mcpConfig.enabled); if (enabled !== undefined) mcpConfig.enabled = enabled; + const clientCredentials = mcpConfig.clientCredentials; + if (clientCredentials && typeof clientCredentials === 'object') { + const ccEnabled = coerceConfigBoolean(clientCredentials.enabled); + if (ccEnabled !== undefined) clientCredentials.enabled = ccEnabled; + } + const cimd = mcpConfig.clientIdMetadataDocuments; if (cimd && typeof cimd === 'object') { const cimdEnabled = coerceConfigBoolean(cimd.enabled); diff --git a/src/lib/hookManager.ts b/src/lib/hookManager.ts index be4bc1a..4890188 100644 --- a/src/lib/hookManager.ts +++ b/src/lib/hookManager.ts @@ -79,7 +79,14 @@ export class HookManager { * never surfaced to the caller. */ callOnMCPTokenIssued( - event: { type: 'access' | 'refresh'; client_id: string; sub: string; aud: string; scope?: string; jti: string }, + event: { + type: 'access' | 'refresh' | 'client_credentials'; + client_id: string; + sub: string; + aud: string; + scope?: string; + jti: string; + }, request: any ): void { const hook = this.hooks.onMCPTokenIssued; diff --git a/src/lib/mcp/authorize.ts b/src/lib/mcp/authorize.ts index ac6972e..fc88b5a 100644 --- a/src/lib/mcp/authorize.ts +++ b/src/lib/mcp/authorize.ts @@ -403,7 +403,7 @@ export async function handleAuthorize( }; } - if (!query.redirect_uri || !redirectUriMatches(query.redirect_uri, client.redirect_uris)) { + if (!query.redirect_uri || !redirectUriMatches(query.redirect_uri, client.redirect_uris ?? [])) { return { status: 400, body: { diff --git a/src/lib/mcp/cimd.ts b/src/lib/mcp/cimd.ts index 67b52c4..ea4a639 100644 --- a/src/lib/mcp/cimd.ts +++ b/src/lib/mcp/cimd.ts @@ -60,15 +60,15 @@ * every hit, so tightening `allowedRedirectUriHosts` takes effect * immediately instead of after cache expiry (up to 24 h). * - * Token auth method: - * - Only `token_endpoint_auth_method: none` (public clients) is supported - * in v1. `private_key_jwt` will be activated by issue #159. Other values - * are rejected with a clear `invalid_client` error. - * - * #159 integration point: - * - `jwks` / `jwks_uri` are deliberately NOT carried through in v1 — they - * have no consumer or validation until private_key_jwt assertion - * verification (#159), which adds the plumbing alongside both. + * Document shapes: + * - Interactive (redirect-based) documents: `token_endpoint_auth_method: + * none` (public clients + PKCE), redirect_uris required. + * - Headless client_credentials documents (#161): grant_types exactly + * ["client_credentials"], `token_endpoint_auth_method: private_key_jwt`, + * an inline public Ed25519 JWK Set (`jwks_uri` rejected — no second SSRF + * surface), NO redirect_uris/response_types — and only accepted when the + * operator has pinned `clientIdMetadataDocuments.allowedHosts` (hosting a + * reachable document must never suffice to mint tokens). */ import { lookup } from 'node:dns/promises'; @@ -549,7 +549,8 @@ function withAbort(promise: Promise, signal: AbortSignal, message: string) function validateCimdDocument( doc: unknown, clientId: string, - allowedRedirectUriHosts: string[] | undefined + allowedRedirectUriHosts: string[] | undefined, + allowedHostsConfigured: boolean ): MCPClientRecord { if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { throw new CimdClientError('invalid_client', 'CIMD document must be a JSON object'); @@ -568,6 +569,26 @@ function validateCimdDocument( if (typeof d.client_name !== 'string' || !d.client_name) { throw new CimdClientError('invalid_client', 'CIMD document missing required field: client_name'); } + + // Optional array fields — validated before branching, because the + // credentials branch below is selected on grant_types, which must be a + // clean string array first. + for (const [field, value] of Object.entries({ + contacts: d.contacts, + grant_types: d.grant_types, + response_types: d.response_types, + })) { + const err = validateStringArray(value, field); + if (err) throw new CimdClientError('invalid_client', `CIMD document: ${err}`); + } + + // client_credentials documents (headless agents, #161) have a distinct + // shape: no redirect surface, private_key_jwt + an inline Ed25519 JWK Set. + if (Array.isArray(d.grant_types) && (d.grant_types as string[]).includes('client_credentials')) { + return validateCredentialsDocument(d, clientId, allowedHostsConfigured); + } + + // --- Interactive (redirect-based) document shape --- if (!Array.isArray(d.redirect_uris) || d.redirect_uris.length === 0) { throw new CimdClientError('invalid_client', 'CIMD document missing required field: redirect_uris'); } @@ -582,16 +603,6 @@ function validateCimdDocument( if (err) throw new CimdClientError('invalid_client', `CIMD document: ${err}`); } - // Optional array fields. - for (const [field, value] of Object.entries({ - contacts: d.contacts, - grant_types: d.grant_types, - response_types: d.response_types, - })) { - const err = validateStringArray(value, field); - if (err) throw new CimdClientError('invalid_client', `CIMD document: ${err}`); - } - // Grant types. const grantTypes: string[] = Array.isArray(d.grant_types) ? (d.grant_types as string[]) @@ -604,12 +615,13 @@ function validateCimdDocument( const responseErr = validateResponseTypes(responseTypes); if (responseErr) throw new CimdClientError('invalid_client', `CIMD document: ${responseErr}`); - // token_endpoint_auth_method: only 'none' supported in v1 for CIMD clients. + // token_endpoint_auth_method: interactive CIMD clients are public clients + // ('none' + PKCE); 'private_key_jwt' exists only in the credentials shape. const authMethod = typeof d.token_endpoint_auth_method === 'string' ? d.token_endpoint_auth_method : 'none'; if (authMethod !== 'none') { throw new CimdClientError( 'invalid_client', - `token_endpoint_auth_method '${authMethod}' is not yet supported for CIMD clients (awaiting #159); use 'none'` + `token_endpoint_auth_method '${authMethod}' is not supported for interactive CIMD clients; use 'none'` ); } @@ -632,6 +644,131 @@ function validateCimdDocument( }; } +/** Cap on registered assertion keys per client — bounds per-assertion verify + * work and the jti-store keyspace a single document can claim. */ +const MAX_CREDENTIALS_JWKS_KEYS = 8; + +/** + * Validate the client_credentials (headless agent) document shape (#161): + * grant_types exactly ["client_credentials"], token_endpoint_auth_method + * 'private_key_jwt', an inline JWK Set of 1..MAX_CREDENTIALS_JWKS_KEYS PUBLIC + * Ed25519 keys, and no redirect surface — redirect_uris / response_types must + * be ABSENT (RFC 7591 requires redirect_uris only for redirect-based grants, + * and declaring one here would imply a flow this shape can never perform). + * `jwks_uri` is rejected outright: the document itself is the hosted-key + * story, and a second SSRF-fetch surface is not worth an indirection (#164). + * + * These documents only materialize when the operator has pinned + * `clientIdMetadataDocuments.allowedHosts` — hosting a reachable document + * must never suffice to mint tokens (#159 design update). + */ +function validateCredentialsDocument( + d: Record, + clientId: string, + allowedHostsConfigured: boolean +): MCPClientRecord { + if (!allowedHostsConfigured) { + throw new CimdClientError( + 'invalid_client', + 'client_credentials CIMD clients require the server to pin clientIdMetadataDocuments.allowedHosts' + ); + } + if ((d.grant_types as string[]).length !== 1) { + throw new CimdClientError( + 'invalid_client', + 'CIMD document: client_credentials must not be combined with other grant types' + ); + } + if (d.redirect_uris !== undefined) { + throw new CimdClientError( + 'invalid_client', + 'CIMD document: client_credentials clients must not declare redirect_uris' + ); + } + if (d.response_types !== undefined) { + throw new CimdClientError( + 'invalid_client', + 'CIMD document: client_credentials clients must not declare response_types' + ); + } + if (d.token_endpoint_auth_method !== 'private_key_jwt') { + throw new CimdClientError( + 'invalid_client', + "CIMD document: client_credentials clients must use token_endpoint_auth_method 'private_key_jwt'" + ); + } + if (d.jwks_uri !== undefined) { + throw new CimdClientError( + 'invalid_client', + 'CIMD document: jwks_uri is not supported; declare the public keys inline in jwks' + ); + } + const jwks = d.jwks; + if (!jwks || typeof jwks !== 'object' || Array.isArray(jwks) || !Array.isArray((jwks as { keys?: unknown }).keys)) { + throw new CimdClientError('invalid_client', 'CIMD document: client_credentials clients require a jwks JWK Set'); + } + const keys = (jwks as { keys: unknown[] }).keys; + if (keys.length === 0 || keys.length > MAX_CREDENTIALS_JWKS_KEYS) { + throw new CimdClientError( + 'invalid_client', + `CIMD document: jwks.keys must hold between 1 and ${MAX_CREDENTIALS_JWKS_KEYS} keys` + ); + } + // Beyond the security checks (public-only, Ed25519-only), enforce the shape + // `selectKey` needs at verify time: verification fails CLOSED on missing or + // duplicate kids anyway, but rejecting here surfaces a clear error when the + // document is resolved instead of a confusing one on every assertion. + const seenKids = new Set(); + for (const key of keys) { + if (!key || typeof key !== 'object' || Array.isArray(key)) { + throw new CimdClientError('invalid_client', 'CIMD document: every jwks key must be a JWK object'); + } + const k = key as Record; + if ('d' in k) { + throw new CimdClientError( + 'invalid_client', + 'CIMD document: jwks must contain only PUBLIC keys (found private key material)' + ); + } + // Ed25519 public keys are exactly 32 bytes → 43 base64url chars; a precise + // shape check keeps malformed keys out of the cache. + if (k.kty !== 'OKP' || k.crv !== 'Ed25519' || typeof k.x !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(k.x)) { + throw new CimdClientError('invalid_client', 'CIMD document: jwks keys must be public OKP/Ed25519 JWKs'); + } + if (keys.length > 1 && k.kid === undefined) { + throw new CimdClientError( + 'invalid_client', + 'CIMD document: jwks keys must each have a kid when multiple keys are present' + ); + } + if (k.kid !== undefined) { + if (typeof k.kid !== 'string' || k.kid.length === 0) { + throw new CimdClientError('invalid_client', 'CIMD document: jwks key kid must be a non-empty string'); + } + if (seenKids.has(k.kid)) { + throw new CimdClientError('invalid_client', 'CIMD document: jwks keys must have unique kid values'); + } + seenKids.add(k.kid); + } + } + + return { + client_id: clientId, + client_name: d.client_name as string, + client_uri: typeof d.client_uri === 'string' ? d.client_uri : undefined, + logo_uri: typeof d.logo_uri === 'string' ? d.logo_uri : undefined, + scope: typeof d.scope === 'string' ? d.scope : undefined, + contacts: Array.isArray(d.contacts) ? (d.contacts as string[]) : undefined, + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt', + software_id: typeof d.software_id === 'string' ? d.software_id : undefined, + software_version: typeof d.software_version === 'string' ? d.software_version : undefined, + jwks: { keys: keys as Record[] }, + client_id_issued_at: 0, // CIMD records are not persisted; no issued-at timestamp. + _cimd: true, + }; +} + // --- Core resolution --- /** @@ -676,10 +813,21 @@ export async function resolveCimdClient( if (cached.expiresAt <= now) { cimdCache.delete(clientId); } else { - // Revalidate against the LIVE redirect-host policy: a record cached - // under a looser `allowedRedirectUriHosts` must not survive the - // operator tightening it (cache TTL can be up to 24 h). - for (const uri of cached.record.redirect_uris) { + // Revalidate against the LIVE policies: a record cached under a looser + // `allowedRedirectUriHosts` — or a credentials record cached while + // `allowedHosts` was pinned — must not survive the operator tightening + // or dropping the policy (cache TTL can be up to 24 h). + if ( + cached.record.grant_types?.includes('client_credentials') && + !(cimdConfig?.allowedHosts && cimdConfig.allowedHosts.length > 0) + ) { + cimdCache.delete(clientId); + throw new CimdClientError( + 'invalid_client', + 'client_credentials CIMD clients require the server to pin clientIdMetadataDocuments.allowedHosts' + ); + } + for (const uri of cached.record.redirect_uris ?? []) { const policyErr = validateRedirectUri(uri, allowedRedirectUriHosts); if (policyErr) { cimdCache.delete(clientId); @@ -807,7 +955,12 @@ async function fetchAndValidateCimd( throw new CimdClientError('invalid_client', 'CIMD document is not valid JSON', { cause: error }); } - record = validateCimdDocument(doc, clientId, allowedRedirectUriHosts); + record = validateCimdDocument( + doc, + clientId, + allowedRedirectUriHosts, + !!(cimdConfig?.allowedHosts && cimdConfig.allowedHosts.length > 0) + ); // Positive cache, LRU-bounded (the key is attacker-chosen input). if (cimdCache.size >= CACHE_MAX_ENTRIES) { diff --git a/src/lib/mcp/clientAssertion.ts b/src/lib/mcp/clientAssertion.ts index 6d65352..5dd8956 100644 --- a/src/lib/mcp/clientAssertion.ts +++ b/src/lib/mcp/clientAssertion.ts @@ -89,8 +89,8 @@ function fail(reason: string): ClientAssertionResult { * the conservative default on anything non-finite. These options are the * enforcement boundary for the RFC 7523 §3 validity-window checks, and the * comparisons below fail OPEN on `NaN`/`Infinity` (e.g. `exp > now + NaN` is - * always false, so a far-future `exp` would be accepted). #162 will wire these - * from `mcp` config, where `${ENV}`/quoted-YAML can deliver a string or + * always false, so a far-future `exp` would be accepted). Callers may wire + * these from config, where `${ENV}`/quoted-YAML can deliver a string or * garbage — so coerce here, mirroring token.ts's `coerceTtl`. `allowZero` * distinguishes the tolerance (0 is a valid "no skew") from the max window * (0 would be a nonsensical always-reject, treated as misconfig → default). @@ -283,6 +283,12 @@ export function verifyClientAssertion(params: VerifyClientAssertionParams): Clie if (iat > now + clockTolerance) { return fail('client_assertion iat is in the future'); } + // A non-positive lifetime is malformed — expired-at-issuance tokens are + // already unusable via the now-relative bound above; reject them as + // structurally invalid rather than letting them ride the tolerance window. + if (exp <= iat) { + return fail('client_assertion lifetime (exp - iat) must be positive'); + } // Strictness bound (defense-in-depth): reject an assertion whose self-declared // lifetime (exp - iat) exceeds the policy window even when `exp` sits inside // the now-relative bound above. Such a token isn't exploitable on its own (the diff --git a/src/lib/mcp/dcr.ts b/src/lib/mcp/dcr.ts index 12df06e..cfe87b2 100644 --- a/src/lib/mcp/dcr.ts +++ b/src/lib/mcp/dcr.ts @@ -261,7 +261,7 @@ export async function handleRegister( } logger?.info?.( - `MCP client registered: ${clientId} (${isConfidential ? 'confidential' : 'public'}, ${record.redirect_uris.length} redirect URI(s))` + `MCP client registered: ${clientId} (${isConfidential ? 'confidential' : 'public'}, ${record.redirect_uris?.length ?? 0} redirect URI(s))` ); return { diff --git a/src/lib/mcp/token.ts b/src/lib/mcp/token.ts index 7c6907d..7b00eca 100644 --- a/src/lib/mcp/token.ts +++ b/src/lib/mcp/token.ts @@ -14,8 +14,10 @@ import { createHash, timingSafeEqual } from 'node:crypto'; import type { HookManager } from '../hookManager.ts'; import type { Logger, MCPClientRecord, MCPConfig, Request } from '../../types.ts'; import { emitMCPAuditEvent } from './audit.ts'; +import { MCPAssertionJtiStore } from './assertionJtiStore.ts'; import { MCPAuthCodeStore } from './authCodeStore.ts'; import { CimdClientError, resolveClient } from './cimd.ts'; +import { CLIENT_ASSERTION_TYPE_JWT_BEARER, verifyClientAssertion } from './clientAssertion.ts'; import { MCPKeyStore } from './keyStore.ts'; import { hashRefreshToken, @@ -25,10 +27,13 @@ import { parseRefreshToken, } from './refreshTokenStore.ts'; import { signAccessToken } from './tokenIssuer.ts'; -import { resolveIssuer } from './wellKnown.ts'; +import { resolveIssuer, resolveResource } from './wellKnown.ts'; const DEFAULT_ACCESS_TOKEN_TTL = 3600; // 1 hour const DEFAULT_REFRESH_TOKEN_TTL = 2592000; // 30 days +// client_credentials tokens are re-minted on demand (no refresh token), so +// they stay short — ≤5 minutes per #159 security req 2. +const DEFAULT_CLIENT_CREDENTIALS_TTL = 300; // RFC 7636 §4.1: code_verifier = 43*128unreserved. Mirrors the code_challenge // check at authorize.ts so a malformed verifier fails fast here too. @@ -176,12 +181,22 @@ function pkceMatches(codeVerifier: string, storedChallenge: string): boolean { async function mintTokenPair( request: Request | undefined, mcpConfig: MCPConfig, - grant: { user: string; resource: string; scope?: string; clientId: string; issueRefresh: boolean }, + grant: { + user: string; + resource: string; + scope?: string; + clientId: string; + issueRefresh: boolean; + /** Pre-coerced TTL override (client_credentials); defaults to mcp.accessTokenTtl. */ + accessTtl?: number; + /** Hook event type; defaults to 'access' (authorization_code). */ + hookType?: 'access' | 'client_credentials'; + }, hookManager?: HookManager, logger?: Logger ): Promise { const issuer = resolveIssuer(request as any, mcpConfig); - const accessTtl = coerceTtl(mcpConfig.accessTokenTtl, DEFAULT_ACCESS_TOKEN_TTL); + const accessTtl = grant.accessTtl ?? coerceTtl(mcpConfig.accessTokenTtl, DEFAULT_ACCESS_TOKEN_TTL); const refreshTtl = coerceTtl(mcpConfig.refreshTokenTtl, DEFAULT_REFRESH_TOKEN_TTL); const key = await new MCPKeyStore(logger).getSigningKey(mcpConfig); @@ -239,7 +254,14 @@ async function mintTokenPair( }); if (hookManager) { hookManager.callOnMCPTokenIssued( - { type: 'access', client_id: grant.clientId, sub: grant.user, aud: grant.resource, scope: grant.scope, jti }, + { + type: grant.hookType ?? 'access', + client_id: grant.clientId, + sub: grant.user, + aud: grant.resource, + scope: grant.scope, + jti, + }, request ); } @@ -413,6 +435,126 @@ async function handleRefreshTokenGrant( return { status: 200, body: responseBody, headers: NO_STORE_HEADERS }; } +/** + * RFC 7523 client_credentials grant for headless agents (#162): the client + * authenticates with a signed EdDSA assertion (private_key_jwt) instead of an + * interactive consent flow. Client identity resolves through `resolveClient()` + * — in practice a CIMD document (#161), since DCR never registers + * private_key_jwt clients. No refresh token is ever issued: agents re-mint on + * 401, and the short TTL bounds leak blast radius (#159 req 2). + */ +async function handleClientCredentialsGrant( + request: Request | undefined, + body: any, + mcpConfig: MCPConfig, + hookManager?: HookManager, + logger?: Logger +): Promise { + const clientId = typeof body?.client_id === 'string' ? body.client_id : undefined; + const assertionType = typeof body?.client_assertion_type === 'string' ? body.client_assertion_type : undefined; + const assertion = typeof body?.client_assertion === 'string' ? body.client_assertion : undefined; + + if (!clientId) { + return errorResponse(400, 'invalid_request', 'client_id is required'); + } + if (assertionType !== CLIENT_ASSERTION_TYPE_JWT_BEARER) { + return errorResponse(400, 'invalid_request', `client_assertion_type must be ${CLIENT_ASSERTION_TYPE_JWT_BEARER}`); + } + if (!assertion) { + return errorResponse(400, 'invalid_request', 'client_assertion is required'); + } + // Proof of key possession is the ONLY accepted authentication for this + // grant — a Basic header or client_secret must not ride along (#159 req 6: + // no credential type may substitute for the private key). Scheme match is + // case-insensitive per RFC 9110 §11.1. + if (/^basic\s/i.test(request?.headers?.authorization ?? '') || typeof body?.client_secret === 'string') { + return errorResponse( + 400, + 'invalid_request', + 'client_credentials accepts only a client_assertion (no secret or Basic auth)' + ); + } + + let client: MCPClientRecord | null; + try { + client = await resolveClient(clientId, mcpConfig, logger); + } catch (err) { + if (err instanceof CimdClientError) { + return errorResponse(401, err.oauthError, err.message); + } + logger?.error?.('MCP token: client lookup failed:', err instanceof Error ? err.message : String(err)); + return errorResponse(500, 'server_error', 'Client lookup failed'); + } + if (!client) { + return errorResponse(401, 'invalid_client', 'Unknown client'); + } + // Pinned to CIMD-resolved clients: the allowedHosts allowlist — the gate + // that stands between "hosts a reachable document" and "mints tokens" — + // is enforced on the CIMD resolution path. A stored (DCR) record must + // never mint here, even if a future DCR surface could register this + // shape; lifting this requires its own registration gate (#161's + // optional initialAccessToken leg). + if ( + client._cimd !== true || + client.token_endpoint_auth_method !== 'private_key_jwt' || + client.grant_types?.length !== 1 || + client.grant_types[0] !== 'client_credentials' + ) { + return errorResponse(400, 'unauthorized_client', 'Client is not registered for the client_credentials grant'); + } + + const issuer = resolveIssuer(request as any, mcpConfig); + const keys = Array.isArray(client.jwks?.keys) ? client.jwks.keys : []; + const result = verifyClientAssertion({ + assertion, + clientId, + tokenEndpoint: `${issuer}/oauth/mcp/token`, + jwks: keys, + }); + if (!result.valid) { + logger?.warn?.(`MCP token: client_assertion rejected for ${clientId}: ${result.reason}`); + return errorResponse(401, 'invalid_client', `client_assertion verification failed: ${result.reason}`); + } + + // RFC 8707 resource binding: exact match against the canonical MCP + // resource, fail closed — no prefix or wildcard comparisons (#159 req 3). + // Checked BEFORE the jti is consumed: a recoverable request-param mistake + // must not burn the single-use assertion. + const canonicalResource = resolveResource(request as any, mcpConfig); + const requestedResource = typeof body?.resource === 'string' ? body.resource : undefined; + if (requestedResource !== undefined && requestedResource !== canonicalResource) { + return errorResponse(400, 'invalid_target', 'resource does not match the configured MCP resource'); + } + + // Replay guard: a storage failure here THROWS to the top-level 500 handler + // — "could not check" must never degrade to "not seen" (fail closed). Runs + // LAST: consuming the jti is the one irreversible step before minting. + // Single-use is best-effort under concurrency — see the bound documented in + // assertionJtiStore.ts and docs/mcp-oauth.md (atomic reserve: harper#1745). + const fresh = await new MCPAssertionJtiStore(logger).checkAndRecord(clientId, result.claims.jti); + if (!fresh) { + return errorResponse(400, 'invalid_grant', 'client_assertion jti has already been used'); + } + + return mintTokenPair( + request, + mcpConfig, + { + // RFC 9068 §2.2: for client_credentials, sub is the CLIENT identity — + // there is no end user in this grant. + user: clientId, + resource: canonicalResource, + scope: client.scope, + clientId, + issueRefresh: false, + accessTtl: coerceTtl(mcpConfig.clientCredentials?.accessTokenTtl, DEFAULT_CLIENT_CREDENTIALS_TTL), + hookType: 'client_credentials', + }, + hookManager, + logger + ); +} + /** * Handle POST /oauth/mcp/token. Returns `{ status, body }`; the `enabled` gate * is applied upstream in handleMCPPost. @@ -435,8 +577,20 @@ export async function handleToken( // their own 4xx errors; this only catches the unexpected. try { const grantType = typeof body?.grant_type === 'string' ? body.grant_type : undefined; + // client_credentials is explicit opt-in (default OFF); when disabled it + // is indistinguishable from any other unsupported grant. + const clientCredentialsEnabled = mcpConfig.clientCredentials?.enabled === true; + if (grantType === 'client_credentials' && clientCredentialsEnabled) { + return await handleClientCredentialsGrant(request, body, mcpConfig, hookManager, logger); + } if (grantType !== 'authorization_code' && grantType !== 'refresh_token') { - return errorResponse(400, 'unsupported_grant_type', 'grant_type must be authorization_code or refresh_token'); + return errorResponse( + 400, + 'unsupported_grant_type', + clientCredentialsEnabled + ? 'grant_type must be authorization_code, refresh_token, or client_credentials' + : 'grant_type must be authorization_code or refresh_token' + ); } const auth = await authenticateClient(request, body, mcpConfig, logger); diff --git a/src/lib/mcp/wellKnown.ts b/src/lib/mcp/wellKnown.ts index 157784c..424f702 100644 --- a/src/lib/mcp/wellKnown.ts +++ b/src/lib/mcp/wellKnown.ts @@ -153,6 +153,8 @@ export function buildAuthorizationServerMetadata( const issuer = resolveIssuer(request, mcpConfig); // 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. + const clientCredentialsEnabled = mcpConfig.clientCredentials?.enabled === true; return { issuer, authorization_endpoint: `${issuer}/oauth/mcp/authorize`, @@ -160,9 +162,20 @@ export function buildAuthorizationServerMetadata( registration_endpoint: `${issuer}/oauth/mcp/register`, jwks_uri: `${issuer}${JWKS_PATH}`, response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'refresh_token'], + grant_types_supported: [ + 'authorization_code', + 'refresh_token', + ...(clientCredentialsEnabled ? ['client_credentials'] : []), + ], code_challenge_methods_supported: ['S256'], - token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'], + token_endpoint_auth_methods_supported: [ + 'none', + 'client_secret_basic', + 'client_secret_post', + ...(clientCredentialsEnabled ? ['private_key_jwt'] : []), + ], + // 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'], diff --git a/src/types.ts b/src/types.ts index d8ec1bd..27bed09 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,6 +106,25 @@ export interface MCPConfig { * to opt out or restrict which hosts may be used as CIMD client_ids. */ clientIdMetadataDocuments?: MCPClientIdMetadataDocumentsConfig; + /** + * RFC 7523 client_credentials grant for headless agents (#159/#162). + * Explicit opt-in — disabled unless `enabled: true`, and requires a + * non-empty `clientIdMetadataDocuments.allowedHosts` allowlist (enforced + * at startup): hosting a reachable metadata document must never suffice + * to mint tokens. + */ + clientCredentials?: MCPClientCredentialsConfig; +} + +/** Configuration for the RFC 7523 client_credentials grant (#162). */ +export interface MCPClientCredentialsConfig { + /** Enable the grant. Default: false (explicit opt-in). */ + enabled?: boolean; + /** + * Access-token lifetime in seconds for this grant. Default: 300 (5 min) — + * agents re-mint on 401; no refresh token is ever issued. + */ + accessTokenTtl?: number; } /** @@ -137,8 +156,13 @@ export interface MCPDynamicClientRegistrationConfig { * optional in the request and populated by the registration handler. */ export interface MCPClientMetadata { - /** Required: array of allowed redirect URIs (exact-match validated on /authorize) */ - redirect_uris: string[]; + /** + * Allowed redirect URIs (exact-match validated on /authorize). Required + * for redirect-based grants (enforced at registration/resolution); + * absent on client_credentials-only CIMD clients, which have no + * redirect surface (RFC 7591 §2 requires it only for redirect flows). + */ + redirect_uris?: string[]; client_name?: string; client_uri?: string; logo_uri?: string; @@ -154,6 +178,12 @@ export interface MCPClientMetadata { application_type?: string; software_id?: string; software_version?: string; + /** + * Public Ed25519 JWK Set for private_key_jwt client authentication. + * Present only on client_credentials CIMD clients (#161); validated at + * resolution (OKP/Ed25519, public keys only, bounded count). + */ + jwks?: { keys: Record[] }; } /** @@ -417,7 +447,15 @@ export interface OAuthHooks { * Harper version independence). NOT sanitized — see SECURITY above. */ onMCPTokenIssued?: ( - event: { type: 'access' | 'refresh'; client_id: string; sub: string; aud: string; scope?: string; jti: string }, + event: { + /** 'access' = authorization_code; 'client_credentials' = headless agent grant (sub is the client, not a user). */ + type: 'access' | 'refresh' | 'client_credentials'; + client_id: string; + sub: string; + aud: string; + scope?: string; + jti: string; + }, request: any ) => Promise; } diff --git a/test/lib/config.test.js b/test/lib/config.test.js index 3cbd205..cd84b24 100644 --- a/test/lib/config.test.js +++ b/test/lib/config.test.js @@ -69,6 +69,14 @@ describe('OAuth Configuration', () => { assert.equal(cfg.enabled, false); assert.equal(cfg.clientIdMetadataDocuments.enabled, false); }); + it('coerces clientCredentials.enabled the same way (token-minting switch must not be string-truthy)', () => { + const cfg = { clientCredentials: { enabled: 'false' } }; + normalizeMcpSecurityConfig(cfg); + assert.equal(cfg.clientCredentials.enabled, false); + const cfgTrue = { clientCredentials: { enabled: 'true' } }; + normalizeMcpSecurityConfig(cfgTrue); + assert.equal(cfgTrue.clientCredentials.enabled, true); + }); it('leaves real booleans and absent values alone', () => { const cfg = { enabled: true, clientIdMetadataDocuments: {} }; normalizeMcpSecurityConfig(cfg); diff --git a/test/lib/mcp/cimd.test.js b/test/lib/mcp/cimd.test.js index 8fe7fcf..bbb0d75 100644 --- a/test/lib/mcp/cimd.test.js +++ b/test/lib/mcp/cimd.test.js @@ -593,13 +593,13 @@ describe('resolveCimdClient — document validation', () => { await assert.rejects(() => resolveCimdClient(VALID_URL, { fetchTimeoutMs: 50 }), /body read aborted/); }); - it('rejects unsupported token_endpoint_auth_method for CIMD', async () => { + it('rejects unsupported token_endpoint_auth_method for interactive CIMD clients', async () => { setupOk({ ...VALID_DOC, token_endpoint_auth_method: 'private_key_jwt' }); await assert.rejects( () => resolveCimdClient(VALID_URL, undefined), (err) => { assert.ok(err instanceof CimdClientError); - assert.match(err.message, /not yet supported/); + assert.match(err.message, /not supported for interactive CIMD clients/); assert.equal(err.oauthError, 'invalid_client'); return true; } @@ -614,7 +614,7 @@ describe('resolveCimdClient — document validation', () => { assert.equal(record._cimd, true); }); - it('does not carry jwks fields through in v1 (deferred to #159 with validation)', async () => { + it('interactive documents do not carry jwks fields (only credentials documents do)', async () => { setupOk({ ...VALID_DOC, jwks_uri: 'https://example.com/.well-known/jwks.json', @@ -626,6 +626,147 @@ describe('resolveCimdClient — document validation', () => { }); }); +describe('resolveCimdClient — client_credentials documents (#161)', () => { + beforeEach(() => _clearCimdCache()); + afterEach(() => { + _setDnsLookup(null); + _setFetch(null); + }); + + const AGENT_JWK = { kty: 'OKP', crv: 'Ed25519', x: 'A'.repeat(43), kid: 'agent-key-1' }; + const CREDENTIALS_DOC = { + client_id: VALID_URL, + client_name: 'Fleet Agent', + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt', + jwks: { keys: [AGENT_JWK] }, + }; + // The allowlist gate is a hard prerequisite for this shape. + const GATED = { allowedHosts: ['example.com'] }; + + function setup(doc) { + _setDnsLookup(makeDnsOk()); + _setFetch(makeOkFetch(doc)); + } + + async function rejects(doc, pattern, cimdConfig = GATED) { + _clearCimdCache(); + setup(doc); + await assert.rejects( + () => resolveCimdClient(VALID_URL, cimdConfig), + (err) => { + assert.ok(err instanceof CimdClientError, `expected CimdClientError, got: ${err?.message}`); + assert.equal(err.oauthError, 'invalid_client'); + assert.match(err.message, pattern); + return true; + } + ); + } + + it('resolves a valid credentials document (jwks carried, no redirect surface)', async () => { + setup(CREDENTIALS_DOC); + const record = await resolveCimdClient(VALID_URL, GATED); + assert.ok(record); + assert.deepEqual(record.grant_types, ['client_credentials']); + assert.equal(record.token_endpoint_auth_method, 'private_key_jwt'); + assert.deepEqual(record.jwks, { keys: [AGENT_JWK] }); + assert.equal(record.redirect_uris, undefined); + assert.equal(record.response_types, undefined); + assert.equal(record._cimd, true); + }); + + it('fails closed when allowedHosts is not configured', async () => { + // Passed inline (not via the helper): an explicit `undefined` third + // argument would trigger the helper's GATED default parameter. + setup(CREDENTIALS_DOC); + await assert.rejects( + () => resolveCimdClient(VALID_URL, undefined), + (err) => err instanceof CimdClientError && /allowedHosts/.test(err.message) + ); + }); + + it('fails closed when allowedHosts is empty', async () => { + await rejects(CREDENTIALS_DOC, /allowedHosts/, { allowedHosts: [] }); + }); + + it('a cached credentials record does not survive the allowlist being dropped', async () => { + setup(CREDENTIALS_DOC); + assert.ok(await resolveCimdClient(VALID_URL, GATED)); + // Same URL, allowlist now gone: the cache hit must reject, not serve. + await assert.rejects( + () => resolveCimdClient(VALID_URL, undefined), + (err) => err instanceof CimdClientError && /allowedHosts/.test(err.message) + ); + }); + + it('rejects client_credentials combined with other grants', async () => { + await rejects({ ...CREDENTIALS_DOC, grant_types: ['client_credentials', 'refresh_token'] }, /must not be combined/); + await rejects( + { ...CREDENTIALS_DOC, grant_types: ['authorization_code', 'client_credentials'] }, + /must not be combined/ + ); + }); + + it('rejects a declared redirect surface', async () => { + await rejects({ ...CREDENTIALS_DOC, redirect_uris: ['https://example.com/cb'] }, /must not declare redirect_uris/); + await rejects({ ...CREDENTIALS_DOC, response_types: ['code'] }, /must not declare response_types/); + }); + + it('rejects any auth method other than private_key_jwt', async () => { + await rejects({ ...CREDENTIALS_DOC, token_endpoint_auth_method: 'none' }, /private_key_jwt/); + const withoutMethod = { ...CREDENTIALS_DOC }; + delete withoutMethod.token_endpoint_auth_method; + await rejects(withoutMethod, /private_key_jwt/); + }); + + it('rejects jwks_uri outright (no second SSRF surface)', async () => { + await rejects( + { ...CREDENTIALS_DOC, jwks_uri: 'https://example.com/.well-known/jwks.json' }, + /jwks_uri is not supported/ + ); + }); + + it('rejects a missing or malformed jwks', async () => { + const withoutJwks = { ...CREDENTIALS_DOC }; + delete withoutJwks.jwks; + await rejects(withoutJwks, /require a jwks JWK Set/); + await rejects({ ...CREDENTIALS_DOC, jwks: 'not-an-object' }, /require a jwks JWK Set/); + await rejects({ ...CREDENTIALS_DOC, jwks: { keys: 'nope' } }, /require a jwks JWK Set/); + }); + + it('bounds the key count to 1..8', async () => { + await rejects({ ...CREDENTIALS_DOC, jwks: { keys: [] } }, /between 1 and 8/); + await rejects({ ...CREDENTIALS_DOC, jwks: { keys: new Array(9).fill(AGENT_JWK) } }, /between 1 and 8/); + }); + + it('rejects private key material and non-Ed25519 keys', async () => { + await rejects({ ...CREDENTIALS_DOC, jwks: { keys: [{ ...AGENT_JWK, d: 'B'.repeat(43) }] } }, /PUBLIC keys/); + await rejects({ ...CREDENTIALS_DOC, jwks: { keys: [{ kty: 'RSA', n: 'x', e: 'AQAB' }] } }, /OKP\/Ed25519/); + await rejects( + { ...CREDENTIALS_DOC, jwks: { keys: [{ kty: 'OKP', crv: 'X25519', x: 'A'.repeat(43) }] } }, + /OKP\/Ed25519/ + ); + await rejects({ ...CREDENTIALS_DOC, jwks: { keys: [null] } }, /must be a JWK object/); + }); + + it('enforces kid presence + uniqueness on multi-key sets and precise x shape', async () => { + const secondKey = { kty: 'OKP', crv: 'Ed25519', x: 'B'.repeat(43), kid: 'agent-key-2' }; + const kidless = { kty: 'OKP', crv: 'Ed25519', x: 'C'.repeat(43) }; + await rejects({ ...CREDENTIALS_DOC, jwks: { keys: [AGENT_JWK, kidless] } }, /must each have a kid/); + await rejects( + { ...CREDENTIALS_DOC, jwks: { keys: [AGENT_JWK, { ...secondKey, kid: 'agent-key-1' }] } }, + /unique kid/ + ); + await rejects({ ...CREDENTIALS_DOC, jwks: { keys: [{ ...AGENT_JWK, x: 'A'.repeat(42) }] } }, /OKP\/Ed25519/); + await rejects({ ...CREDENTIALS_DOC, jwks: { keys: [{ ...AGENT_JWK, x: '!'.repeat(43) }] } }, /OKP\/Ed25519/); + // A well-formed rotation set (distinct kids) still resolves. + _clearCimdCache(); + setup({ ...CREDENTIALS_DOC, jwks: { keys: [AGENT_JWK, secondKey] } }); + const record = await resolveCimdClient(VALID_URL, GATED); + assert.equal(record.jwks.keys.length, 2); + }); +}); + describe('resolveCimdClient — cache', () => { beforeEach(() => _clearCimdCache()); afterEach(() => { diff --git a/test/lib/mcp/clientAssertion.test.js b/test/lib/mcp/clientAssertion.test.js index 427029d..ddcf625 100644 --- a/test/lib/mcp/clientAssertion.test.js +++ b/test/lib/mcp/clientAssertion.test.js @@ -296,6 +296,11 @@ describe('verifyClientAssertion', () => { rejectPayload(defaultPayload({ exp: nowSeconds() + 300 }), /exceeds the maximum window/); }); + it('rejects a non-positive lifetime (exp <= iat) as malformed', () => { + rejectPayload(defaultPayload({ iat: nowSeconds() + 2, exp: nowSeconds() + 1 }), /must be positive/); + rejectPayload(defaultPayload({ iat: nowSeconds(), exp: nowSeconds() }), /must be positive/); + }); + it('rejects an over-long self-declared lifetime (old iat, near-now exp)', () => { // exp is inside the now-relative window, but exp - iat advertises a // far-longer lifetime than the policy allows — strict verifier refuses it. diff --git a/test/lib/mcp/token.test.js b/test/lib/mcp/token.test.js index 8f442f2..92f19b4 100644 --- a/test/lib/mcp/token.test.js +++ b/test/lib/mcp/token.test.js @@ -6,9 +6,11 @@ import { describe, it, before, after, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; -import { createHash, generateKeyPairSync, randomBytes } from 'node:crypto'; +import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto'; import { handleToken } from '../../../dist/lib/mcp/token.js'; +import { resetMCPAssertionJtisTableCache } from '../../../dist/lib/mcp/assertionJtiStore.js'; import { resetMCPAuthCodesTableCache } from '../../../dist/lib/mcp/authCodeStore.js'; +import { _clearCimdCache, _setDnsLookup, _setFetch } from '../../../dist/lib/mcp/cimd.js'; import { resetMCPClientsTableCache } from '../../../dist/lib/mcp/clientStore.js'; import { resetMCPKeysTableCache, SIGNING_KEY_ID } from '../../../dist/lib/mcp/keyStore.js'; import { resetMCPRefreshFamiliesTableCache, makeRefreshToken } from '../../../dist/lib/mcp/refreshTokenStore.js'; @@ -635,3 +637,283 @@ describe('handleToken', () => { assert.equal(res.body.error, 'invalid_grant'); }); }); + +describe('handleToken — client_credentials grant (#162)', () => { + let originalDatabases; + let clients; + let keys; + let jtis; + let hookEvents; + + const edKeypair = generateKeyPairSync('ed25519'); + const AGENT_JWK = edKeypair.publicKey.export({ format: 'jwk' }); + const AGENT_CLIENT_ID = 'https://agents.example.com/fleet/agent-1.json'; + const TOKEN_ENDPOINT = `${ISSUER}/oauth/mcp/token`; + + const AGENT_DOC = { + client_id: AGENT_CLIENT_ID, + client_name: 'Fleet Agent 1', + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt', + jwks: { keys: [AGENT_JWK] }, + }; + + const ccConfig = { + ...mcpConfig, + clientIdMetadataDocuments: { allowedHosts: ['agents.example.com'] }, + clientCredentials: { enabled: true, accessTokenTtl: 300 }, + }; + + const hookManager = { + callOnMCPTokenIssued(event) { + hookEvents.push(event); + }, + }; + + function signAssertion(overrides = {}) { + const now = Math.floor(Date.now() / 1000); + const { + iss = AGENT_CLIENT_ID, + sub = iss, + aud = TOKEN_ENDPOINT, + exp = now + 30, + iat = now, + jti = randomBytes(8).toString('hex'), + key = edKeypair.privateKey, + } = overrides; + const header = Buffer.from(JSON.stringify({ alg: 'EdDSA', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ iss, sub, aud, exp, iat, jti })).toString('base64url'); + const signature = sign(null, Buffer.from(`${header}.${payload}`), key).toString('base64url'); + return `${header}.${payload}.${signature}`; + } + + function grantBody(overrides = {}) { + return { + grant_type: 'client_credentials', + client_id: AGENT_CLIENT_ID, + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: signAssertion(), + ...overrides, + }; + } + + before(() => { + originalDatabases = global.databases; + }); + after(() => { + global.databases = originalDatabases; + _setDnsLookup(null); + _setFetch(null); + }); + + beforeEach(() => { + resetMCPClientsTableCache(); + resetMCPKeysTableCache(); + resetMCPAssertionJtisTableCache(); + _clearCimdCache(); + hookEvents = []; + + clients = new Map(); + keys = new Map(); + jtis = new Map(); + keys.set(SIGNING_KEY_ID, { + kid: SIGNING_KEY_ID, + alg: 'RS256', + public_key_pem: keypair.publicKey, + private_key_pem: keypair.privateKey, + created_at: 1700000000, + }); + // A DCR public client, to prove stored clients can't use this grant. + clients.set('public-1', { + client_id: 'public-1', + token_endpoint_auth_method: 'none', + redirect_uris: JSON.stringify([REDIRECT]), + client_id_issued_at: 1700000000, + }); + // A stored record wearing the full credentials shape — must still be + // rejected by the grant's CIMD pin (no allowlist gate on the DCR path). + clients.set('agent-dcr-1', { + client_id: 'agent-dcr-1', + token_endpoint_auth_method: 'private_key_jwt', + grant_types: JSON.stringify(['client_credentials']), + client_id_issued_at: 1700000000, + }); + + global.databases = { + oauth: { + harper_oauth_mcp_clients: makeTable(clients, 'client_id'), + harper_oauth_mcp_keys: makeTable(keys, 'kid'), + mcp_assertion_jtis: { + ...makeTable(jtis, 'id'), + create: async (record) => { + if (jtis.has(record.id)) { + const err = new Error('Record already exists'); + err.statusCode = 409; + throw err; + } + jtis.set(record.id, record); + }, + }, + }, + }; + + // Serve the agent's CIMD document through the stubbed resolver. + _setDnsLookup(async () => [{ address: '93.184.216.34', family: 4 }]); + _setFetch(async () => { + const bytes = Buffer.from(JSON.stringify(AGENT_DOC)); + return { + status: 200, + headers: new Map([ + ['content-type', 'application/json'], + ['content-length', String(bytes.length)], + ]), + body: { + getReader: () => { + let sent = false; + return { + read: async () => + sent ? { done: true, value: undefined } : ((sent = true), { done: false, value: bytes }), + cancel: () => {}, + }; + }, + }, + }; + }); + }); + + it('issues a short-TTL token with no refresh token (sub = client identity)', async () => { + const res = await handleToken({ headers: {} }, grantBody(), ccConfig, hookManager); + assert.equal(res.status, 200, JSON.stringify(res.body)); + assert.equal(res.body.token_type, 'Bearer'); + assert.equal(res.body.expires_in, 300); + assert.equal(res.body.refresh_token, undefined, 'client_credentials must never issue a refresh token'); + const claims = verifyAccessToken(res.body.access_token, keypair.publicKey); + assert.equal(claims.sub, AGENT_CLIENT_ID); + assert.equal(claims.aud, RESOURCE); + assert.equal(claims.client_id, AGENT_CLIENT_ID); + assert.equal(hookEvents.length, 1); + assert.equal(hookEvents[0].type, 'client_credentials'); + }); + + it('rejects a replayed jti', async () => { + const body = grantBody(); + const first = await handleToken({ headers: {} }, body, ccConfig); + assert.equal(first.status, 200); + const replay = await handleToken({ headers: {} }, body, ccConfig); + assert.equal(replay.status, 400); + assert.equal(replay.body.error, 'invalid_grant'); + assert.match(replay.body.error_description, /already been used/); + }); + + it('rejects a wrong audience', async () => { + const res = await handleToken( + { headers: {} }, + grantBody({ client_assertion: signAssertion({ aud: 'https://other.example.com/token' }) }), + ccConfig + ); + assert.equal(res.status, 401); + assert.equal(res.body.error, 'invalid_client'); + assert.match(res.body.error_description, /aud/); + }); + + it('rejects an expired assertion', async () => { + const past = Math.floor(Date.now() / 1000) - 120; + const res = await handleToken( + { headers: {} }, + grantBody({ client_assertion: signAssertion({ exp: past + 30, iat: past }) }), + ccConfig + ); + assert.equal(res.status, 401); + assert.equal(res.body.error, 'invalid_client'); + }); + + it('rejects an assertion signed by a different key', async () => { + const rogue = generateKeyPairSync('ed25519'); + const res = await handleToken( + { headers: {} }, + grantBody({ client_assertion: signAssertion({ key: rogue.privateKey }) }), + ccConfig + ); + assert.equal(res.status, 401); + assert.match(res.body.error_description, /signature/); + }); + + it('rejects an iss/client_id mismatch', async () => { + const res = await handleToken( + { headers: {} }, + grantBody({ client_assertion: signAssertion({ iss: 'https://agents.example.com/fleet/agent-2.json' }) }), + ccConfig + ); + assert.equal(res.status, 401); + assert.match(res.body.error_description, /iss/); + }); + + it('accepts an exact resource match and rejects any other target', async () => { + const ok = await handleToken({ headers: {} }, grantBody({ resource: RESOURCE }), ccConfig); + assert.equal(ok.status, 200); + const bad = await handleToken({ headers: {} }, grantBody({ resource: `${RESOURCE}/sub` }), ccConfig); + assert.equal(bad.status, 400); + assert.equal(bad.body.error, 'invalid_target'); + }); + + it('does not burn the jti on a resource mismatch — the same assertion retries successfully', async () => { + const assertion = signAssertion(); + const bad = await handleToken( + { headers: {} }, + grantBody({ client_assertion: assertion, resource: `${RESOURCE}/sub` }), + ccConfig + ); + assert.equal(bad.body.error, 'invalid_target'); + const retry = await handleToken( + { headers: {} }, + grantBody({ client_assertion: assertion, resource: RESOURCE }), + ccConfig + ); + assert.equal(retry.status, 200, 'a recoverable request-param mistake must not consume the single-use jti'); + }); + + it('is indistinguishable from an unknown grant when disabled', async () => { + const res = await handleToken({ headers: {} }, grantBody(), mcpConfig); + assert.equal(res.status, 400); + assert.equal(res.body.error, 'unsupported_grant_type'); + assert.equal(res.body.error_description, 'grant_type must be authorization_code or refresh_token'); + }); + + it('requires the RFC 7523 assertion type and the assertion itself', async () => { + const wrongType = await handleToken({ headers: {} }, grantBody({ client_assertion_type: 'urn:nope' }), ccConfig); + assert.equal(wrongType.status, 400); + assert.equal(wrongType.body.error, 'invalid_request'); + const missing = await handleToken({ headers: {} }, grantBody({ client_assertion: undefined }), ccConfig); + assert.equal(missing.status, 400); + assert.equal(missing.body.error, 'invalid_request'); + }); + + it('rejects a Basic header or client_secret riding along (key possession only)', async () => { + const withBasic = await handleToken({ headers: basicHeader('admin', 'admin-secret') }, grantBody(), ccConfig); + assert.equal(withBasic.status, 400); + assert.equal(withBasic.body.error, 'invalid_request'); + const withSecret = await handleToken({ headers: {} }, grantBody({ client_secret: 'oops' }), ccConfig); + assert.equal(withSecret.status, 400); + // RFC 9110 §11.1: auth schemes are case-insensitive — a lowercase + // `basic` must not slip past the mixed-auth guard. + const lowercase = await handleToken( + { headers: { authorization: basicHeader('admin', 'admin-secret').authorization.replace(/^Basic/, 'basic') } }, + grantBody(), + ccConfig + ); + assert.equal(lowercase.status, 400); + assert.equal(lowercase.body.error, 'invalid_request'); + }); + + it('rejects a stored (DCR) client on this grant', async () => { + const res = await handleToken({ headers: {} }, grantBody({ client_id: 'public-1' }), ccConfig); + assert.equal(res.status, 400); + assert.equal(res.body.error, 'unauthorized_client'); + }); + + it('rejects a stored client even when it wears the full credentials shape (CIMD pin)', async () => { + const res = await handleToken({ headers: {} }, grantBody({ client_id: 'agent-dcr-1' }), ccConfig); + assert.equal(res.status, 400); + assert.equal(res.body.error, 'unauthorized_client'); + }); +}); diff --git a/test/lib/mcp/wellKnown.test.js b/test/lib/mcp/wellKnown.test.js index 64dc619..4b08376 100644 --- a/test/lib/mcp/wellKnown.test.js +++ b/test/lib/mcp/wellKnown.test.js @@ -107,6 +107,25 @@ describe('MCP well-known: AS metadata document (RFC 8414)', () => { 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(), { + enabled: true, + clientCredentials: { enabled: true }, + }); + assert.ok(doc.grant_types_supported.includes('client_credentials')); + assert.ok(doc.token_endpoint_auth_methods_supported.includes('private_key_jwt')); + assert.deepEqual(doc.token_endpoint_auth_signing_alg_values_supported, ['EdDSA']); + }); + + it('omits client_credentials discovery when the grant is disabled or unset', () => { + for (const mcpConfig of [{ enabled: true }, { enabled: true, clientCredentials: { enabled: false } }]) { + const doc = 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 }); assert.deepEqual(doc.response_types_supported, ['code']); diff --git a/test/options-watcher.test.js b/test/options-watcher.test.js index 0b8c4ee..6fa3c76 100644 --- a/test/options-watcher.test.js +++ b/test/options-watcher.test.js @@ -112,6 +112,56 @@ describe('OAuth Plugin Options Watcher', () => { assert.ok(resources.oauth, 'OAuth resource should be registered'); }); + it('should fail to start when clientCredentials is enabled without an allowedHosts allowlist', async () => { + scope.options._config.mcp = { + enabled: true, + issuer: 'https://app.example.com', + clientCredentials: { enabled: true }, + }; + await assert.rejects(handleApplication(scope), /requires a non-empty mcp\.clientIdMetadataDocuments\.allowedHosts/); + }); + + it('should fail to start when clientCredentials is enabled but CIMD is disabled', async () => { + scope.options._config.mcp = { + enabled: true, + issuer: 'https://app.example.com', + clientCredentials: { enabled: true }, + clientIdMetadataDocuments: { enabled: false, allowedHosts: ['agents.example.com'] }, + }; + await assert.rejects(handleApplication(scope), /requires CIMD resolution/); + }); + + it('should start when clientCredentials is enabled with a pinned allowlist', async () => { + scope.options._config.mcp = { + enabled: true, + issuer: 'https://app.example.com', + clientCredentials: { enabled: true }, + clientIdMetadataDocuments: { allowedHosts: ['agents.example.com'] }, + }; + await handleApplication(scope); + assert.ok(resources.oauth, 'OAuth resource should be registered'); + }); + + it('should fail to start when clientCredentials is enabled on a cleartext remote issuer', async () => { + scope.options._config.mcp = { + enabled: true, + issuer: 'http://app.example.com', + clientCredentials: { enabled: true }, + clientIdMetadataDocuments: { allowedHosts: ['agents.example.com'] }, + }; + await assert.rejects(handleApplication(scope), /requires an https: mcp\.issuer/); + }); + + it('should start when clientCredentials is enabled on an http loopback issuer (development)', async () => { + scope.options._config.mcp = { + enabled: true, + issuer: 'http://localhost:9926', + clientCredentials: { enabled: true }, + clientIdMetadataDocuments: { allowedHosts: ['agents.example.com'] }, + }; + await handleApplication(scope); + }); + it('should fail to start when mcp.issuer is schemeless', async () => { scope.options._config.mcp = { enabled: true, issuer: 'as.example.com' }; await assert.rejects(handleApplication(scope), /mcp\.issuer must be an absolute http\(s\) origin/);