Skip to content

v2.8.0

Choose a tag to compare

@heskew heskew released this 02 Oct 05:49
38263ac

Added

  • Interactive CIMD clients can authenticate with private_key_jwt: the token endpoint verifies client assertions from interactive Client ID Metadata Document clients.
    • Algorithms: RS256, ES256 or EdDSA, narrowed to the document's token_endpoint_auth_signing_alg.
    • Keys come from inline jwks or from jwks_uri. A jwks_uri is fetched with the same controls as the document (validated and pinned addresses, no redirects, size and time limits) and must be on the client ID's origin unless mcp.clientIdMetadataDocuments.privateKeyJwt.jwksUriAllowedOrigins admits another. Only key material is cached, per client; a fetched key set is assigned a TTL of at least 60 seconds, whatever its caching directives.
    • The server permits one method per client: the document's preference when this server advertises it. Selection checks the inline key set or the jwks_uri location policy and any signing-algorithm pin. For jwks_uri, the fetched keys are validated during token exchange. The ChatGPT-shaped test document resolves to none when the interactive setting is omitted and headless agents are off, and to private_key_jwt when privateKeyJwt.enabled: true or headless agents advertise it. In one session recorded against a test authorization server that offered both methods, ChatGPT authenticated its code exchange and four refreshes with private_key_jwt (RS256, a 60-second lifetime) and the token endpoint URL as aud, which the issuer-only policy refuses unless an unexpired privateKeyJwt.tokenEndpointAudience exception lists its client ID. In a second session, offered only none, it used none. The recording was not made against this plugin.
    • The interactive assertion audience is the issuer. privateKeyJwt.tokenEndpointAudience is an opt-in exception with a required expiry that also accepts the advertised token endpoint for listed client IDs.
  • The client-authentication method is bound to the grant: the method permitted at /oauth/mcp/authorize must be used for the code exchange and for every refresh of that grant. A request that authenticates under current policy but conflicts with a bound code or family returns invalid_grant; a currently unpermitted presentation returns invalid_client before grant lookup. The client then reauthorizes.

Changed

  • Compatibility: Interactive CIMD private_key_jwt remains opt-in: set mcp.clientIdMetadataDocuments.privateKeyJwt.enabled: true to advertise it when headless agents are off. When headless agents are off, omitting the flag or setting it to false preserves the prior public-client advertisement. Interactive clients selected for private_key_jwt must present a verified assertion on code exchange and refresh. Explicitly enabling the setting requires CIMD resolution and an https: issuer (loopback http: is allowed); these startup checks do not apply to an omitted or false setting. With MCP enabled, a declared non-boolean value, including an own undefined property, "true", "false" or null, fails config normalization. Unresolved placeholders and empty strings retain their specific startup errors. With MCP off, prior coercion and warn-and-drop behavior is unchanged.
  • Token-request resource validation: for authorization_code and refresh_token, a verified client assertion is recorded before grant and resource checks, including when resource is present. On a redeemable authorization_code or current refresh_token with a stored resource, every parsed resource must match both it and the current MCP resource; otherwise 400 invalid_target precedes code consumption or refresh rotation. Superseded refresh replay precedes resource validation. Omitting resource is unchanged; a valid grant without a stored resource still returns server_error at signing.
  • Compatibility: CIMD documents containing client_secret* members are rejected with invalid_client (for example client_secret or client_secret_expires_at), in either document shape.
  • Compatibility: a client_credentials request in which grant_type, code, redirect_uri, code_verifier, refresh_token, client_id, client_secret, client_assertion, client_assertion_type or scope is an array now receives 400 invalid_request before client authentication or token issuance. Every value of a resource array must be the configured MCP resource, otherwise the request receives 400 invalid_target; previously a resource array was not checked. On Harper's old form deserializer (including 5.1.9), a repeated listed single-valued parameter or resource keeps its first value under its own name; later values are not checked there.
  • Client credentials presented at the token endpoint are verified or rejected (authorization_code and refresh_token). An array value for a parameter listed in the compatibility note above, a client_id or credential parameter that is empty or not a single string, half an assertion pair, or more than one authentication mechanism (any Basic header counts as one) is rejected with invalid_request (400); otherwise, malformed Basic credentials are rejected with invalid_client (401). A 401 answering a request that used Basic carries WWW-Authenticate: Basic. A client permitted none that sends assertion parameters is rejected. Compatibility: assertion parameters that were previously ignored are now verified or rejected.
  • Interactive CIMD documents are validated for their declared authentication. A document is rejected with invalid_client when:
    • token_endpoint_auth_method is present but not a string;
    • token_endpoint_auth_methods_supported is not an array of strings, or omits the singular value;
    • it declares client_secret_basic, client_secret_post or client_secret_jwt;
    • it has both jwks and jwks_uri, or inline keys with private key material.
  • Advertised signing algorithms are the union of what the enabled verification paths accept. A server with client_credentials enabled now lists RS256, ES256 and EdDSA in token_endpoint_auth_signing_alg_values_supported (previously EdDSA only), because interactive CIMD assertions are verified there as well.
  • client_credentials assertions also accept the issuer as aud. The token endpoint URL stays accepted unless mcp.clientCredentials.acceptTokenEndpointAudience is false. typ: client-authentication+jwt is accepted, and jku, jwk, x5u and x5c headers are rejected.
  • Fetched documents' media type is compared exactly. The response media type, excluding parameters, must be exactly application/json or application/jwk-set+json. CIMD documents accept application/json only; a type that merely contains it, such as text/plain; x=application/json, is rejected.
  • Client-assertion replay records carry an explicit per-record expiry instead of the table's fixed 120 seconds. Expires at the later of assertion exp and insertion time, plus 60 seconds.
  • Upgrade note:
    • Flow states and authorization codes created before this version are rejected; the client restarts authorization.
    • New refresh families use p2- ids and carry the binding; p1- families are treated as bound to none for CIMD clients and to the registered method (default none) for stored clients. A different currently permitted method requires reauthorization.
    • Drain older nodes before issuing bound grants and never route bound grants to versions before 2.7. Version 2.7.x retires p2- families.
    • Every new authorization code and refresh family is bound, whether or not private_key_jwt is advertised, so drain older nodes before this version serves any grant. On rollback to 2.7.x, p2- families are retired and those clients re-authorize; codes in flight can be redeemed without the binding check. Routing bound grants to a version below 2.7 is unsafe.
    • With MCP and CIMD enabled and headless agents off, omitting privateKeyJwt.enabled preserves the prior advertisement: a document declaring both none and private_key_jwt selects none, and a document declaring only private_key_jwt has no permitted method. When an operator opts in with privateKeyJwt.enabled: true, selection takes the singular preference in the declared, advertised and permitted intersection; otherwise the sole member wins, or private_key_jwt wins when both it and none remain. A client newly selected for private_key_jwt that keeps presenting none is refused with invalid_client, and its p1- families, bound to none, can no longer be refreshed; the client reauthorizes.
    • For key-authenticated ChatGPT now, set mcp.clientIdMetadataDocuments.privateKeyJwt.enabled: true and mcp.clientIdMetadataDocuments.privateKeyJwt.tokenEndpointAudience: { clientIds: ["https://chatgpt.com/oauth/client.json"], expiresAt: "<operator-chosen ISO 8601 date-time>" }. Headless agents also advertise private_key_jwt even when the interactive flag is omitted or false; add chatgpt.com to clientIdMetadataDocuments.allowedHosts when that allowlist is set (required for headless) and to dynamicClientRegistration.allowedRedirectUriHosts if set, then reauthorize ChatGPT links bound to none. This exception accepts the token-endpoint audience for that exact client until expiry. RFC 7523bis forbids that audience by default because a malicious authorization server can inject our token endpoint URL as its own and obtain a replayable assertion; use an expiry you choose and renew only while needed.

Fixed

  • Interactive CIMD token-endpoint-audience refusals can give an operator warning when a warning logger is available: with keys on the client-ID origin, an unconfigured exception gets the full exact-ID setting with a future expiry, while an existing exception gets every needed field edit, including both expiry and client ID when both fail; off-origin keys get an explanation that the exception cannot apply, without a setting. The client response and acceptance policy are unchanged. Each node warns at most once per client ID per five minutes. When its 1,024 slots are full, it suppresses warnings for new IDs until an expired slot is cleared by a later refusal.
  • mcp.dynamicClientRegistration.initialAccessToken fails closed on an unresolved placeholder or empty value (#240): a declared initialAccessToken that resolved to an unresolved ${VAR} placeholder (the variable is unset) previously became the accepted bearer secret itself — anyone who could read the committed config (placeholders routinely are) could register MCP clients — and a set-but-empty value silently enabled open registration behind a warning worded like the legitimate RFC 7591 open mode. Both now throw at startup, naming the key, whenever DCR is enabled (mcp.enabled is true and dynamicClientRegistration.enabled is not false). Open registration remains available only by omitting initialAccessToken entirely. Upgrade note: an instance with DCR enabled and a declared-but-unresolved/empty initialAccessToken now fails to start; applying the same bad value via a live config reload is rejected and the previous configuration keeps serving. Also closed here: Harper's OptionsWatcher applies a multi-key config edit one key at a time, emitting a change event synchronously per key. The config-update loop had no synchronization on that batch, so a reload that flipped dynamicClientRegistration.enabled to true in one key and set initialAccessToken in a second key could read and publish the config after only the first key landed — briefly serving open DCR before the batch's final, correctly-rejected snapshot was ever evaluated. The update loop now yields once before reading the config, so a synchronous burst of per-key change events is fully applied before any snapshot is read.
  • An unresolved ${VAR} placeholder on a feature-scoped mcp boolean now throws instead of silently dropping to the documented default (#207): mcp.refreshTokenRequiresOfflineAccess, mcp.clientCredentials.enabled, mcp.clientIdMetadataDocuments.enabled, and mcp.dynamicClientRegistration.enabled previously treated an unset environment variable's leftover placeholder as absent (warn and drop to the documented default) — introduced in 2.5.0 but never called out here. That's safe when the default is off, but dynamicClientRegistration's default is on (a declared block with no explicit enabled: false is enabled), so the identical mistake could silently re-enable a gate the operator meant to turn off. These four still drop to their default while the surface they gate is inactive (mcp.enabled isn't true), so a disabled block with a stray placeholder still boots unchanged. mcp.enabled itself keeps its original 2.5.0 warn-and-drop behavior rather than joining the other four in failing closed: dropping it already lands on the safe direction (MCP off), and there's no outer flag to hide an ambiguous value behind — failing closed here would stop the whole plugin, including every provider's browser OAuth login, over one unset variable. Upgrade note: a config with mcp.enabled: true and an unresolved placeholder on any of the four feature-scoped fields above now fails to start; the same value applied via a live config reload is rejected and the previous configuration keeps serving. An unresolved placeholder (or empty value) on mcp.enabled itself still only warns and boots with MCP off. Set the variable, or replace the placeholder with a literal true/false. Also closed here: not every substitution mechanism leaves the placeholder text behind when its variable is unset — docker-compose's VAR=${VAR} resolves to an empty string (not the literal ${VAR}) for an unset VAR. An empty string left on one of the four feature-scoped boolean fields now fails closed the same way an unresolved placeholder does, instead of falling through to "must be a boolean" and getting silently dropped; mcp.enabled treats an empty string the same (warn and drop) as a placeholder.
  • A non-mapping dynamicClientRegistration or clientIdMetadataDocuments block no longer silently enables the feature it was supposed to disable: dynamicClientRegistration: false (or 0, an unresolved ${VAR} placeholder, or an array — a YAML list is still typeof 'object') skipped the block's validation entirely and fell through to the feature's runtime enabled-check, where .enabled on a non-mapping value reads as undefined — !== false resolved to true, so DCR opened for registration anyway (CIMD, whose block defaults to enabled, had the same gap for an operator trying to disable it with a scalar or array value). Both blocks now require an actual mapping whenever mcp.enabled is true: a non-mapping, non-null value throws at startup naming the field (mcp.dynamicClientRegistration must be a mapping; use enabled: false to disable, and the clientIdMetadataDocuments equivalent). dcrEnabled() and CIMD's own enabled check are hardened with the same type guard as defense in depth. A bare YAML key (parses as null) is unaffected and still means absent/disabled.
  • A scalar dynamicClientRegistration.allowedRedirectUriHosts is no longer matched by substring: the redirect-uri allowlist check (shared by the DCR and CIMD redirect-uri validators) runs Array.prototype.includes, but a scalar string value ran String.prototype.includes instead — allowedRedirectUriHosts: "example.com" would accept evil-example.com.attacker.net as a substring match. It's now normalized to a one-element, lowercased array at startup, exactly like clientIdMetadataDocuments.allowedHosts already was.
  • Over-length client assertions are rejected before any client lookup. The verifier's 8192-character bound is applied at the token endpoint before an assertion is parsed for its client ID or any client is looked up, on every grant that accepts assertions.
  • A client assertion presented concurrently is accepted at most once per node, on every grant that accepts assertions. Replay records (mcp_assertion_jtis) gain a uses counter: each presentation adds 1 atomically, and only the one that reads back 1 is accepted, so concurrent presentations can all be refused. Previously they could all be accepted.
  • A refresh-family revocation is no longer undone by a concurrent refresh. Rotation and revocation now write only their own field (current_token_hash, revoked), so a revocation committed while another request rotates the family stays in force.
  • A quoted max-age (max-age="600") in a CIMD or JWKS response's Cache-Control is read instead of falling back to the default lifetime.
  • A client_credentials request refused with invalid_target is no longer counted against mcp.clientCredentials.rateLimit.
  • Client, authorization-code and refresh-family read failures return server_error (500) instead of being reported as an unknown client or an invalid grant. A missing record still gives invalid_client or invalid_grant.
  • A superseded refresh token whose family revocation cannot be written no longer claims the revocation. A superseded refresh token is refused with invalid_grant and revokes the family; if the revocation write fails, the token is still refused with invalid_grant, as before, and nothing is issued. The response no longer claims the revocation: its error_description is "Refresh token has been superseded" (without "; family revoked"). The token endpoint's handler no longer logs the error text followed by a "revoked family" line; it logs one line naming the family and failure, without the error text or the token. The refresh-family store's own write-error log, unchanged, still carries the underlying error. The family stays live until a later presentation retires or revokes it, or it expires.
  • A mixed-case provider value (e.g. 'GitHub') now gets the same GitHub-authenticated evidence as the lowercase preset (#242): preset resolution (getProvider) has always matched provider names case-insensitively, but the 2.7.0 evidence path compared config.provider === 'github' case-sensitively, so a config with provider: 'GitHub' kept emailProvenance: 'unauthenticated' and emailAuthenticated: false even after a successful authenticated GitHub email fetch — denying account adoption, and any onLogin hook gating on authEvidence.emailAuthenticated, for a genuine GitHub login. buildProviderConfig now lowercases provider only when it matches a known preset (so 'GitHub' → 'github'): a custom provider identifier with no preset (e.g. 'AcmeOIDC') keeps its original case — it flows into hooks, session metadata, and any binding keyed on it — and an explicit provider: '' under a preset's registry key (e.g. auth0) stays '', so the preset's configure() switch is not triggered by the registry-key fallback and the operator's custom endpoints are preserved. TenantManager.registerTenant normalizes tenant.provider the same way once its preset is resolved, so the configure() switch, its error messages, and the stored provider all see one case — e.g. 'Okta' now hits the domain-required check and is stored as provider: 'okta' even if additionalConfig also sets a mixed-case provider. Upgrade note: a static or onResolveProvider config written as 'Okta', 'Auth0', or 'Azure' (mixed case) previously skipped that preset's configure() step entirely; it now runs, so a config that also sets domain/tenantId gets (or must already have) the same domain-derived endpoints a lowercase 'okta'/'auth0'/'azure' config has always gotten.
  • An undefined provider option no longer overwrites its preset default (#243): buildProviderConfig copied every key of the caller's provider options into the final config, including keys whose value was undefined — the common case for a dynamically resolved provider (e.g. from onResolveProvider) that passes through an unset database field such as scope: row.scope. For the Google preset this silently replaced scope: 'openid profile email' with undefined, and OAuthProvider then sent an empty scope to Google, which rejects the authorization request. undefined values are now skipped when building the provider config, so the preset (or plugin default) applies; null and '' remain explicit values a caller can still use to blank a field.
  • TenantManager.registerTenant's additionalConfig no longer wipes a preset default with an undefined value (#248): the same "pass through an unset DB column" pattern #243 fixed in buildProviderConfig was still present on the multi-tenant path — additionalConfig was spread onto the provider config last with no undefined skip, so a tenant registered with additionalConfig: { scope: undefined } silently discarded the preset's default scope (e.g. Okta's openid profile email groups), and the resulting authorize request went out with no scope. buildProviderConfig's undefined-skip is now a shared skipUndefined helper used by both call sites; null and '' remain explicit values, and the normalized-provider pin added in #246 still wins regardless. Upgrade note: additionalConfig: { field: undefined } previously cleared whatever the preset/base provider had set for field; it now leaves the preset's value in place, matching #243's buildProviderConfig behavior. A caller that relied on undefined to blank a preset-set field should pass null or '' instead.
  • A declared redirect-host allowlist that resolves empty no longer silently means "no restriction" (#249): mcp.dynamicClientRegistration.allowedRedirectUriHosts and mcp.clientIdMetadataDocuments.allowedHosts are filtered down to non-empty, trimmed, lowercased entries, but validateRedirectUri and CIMD's own gate both read an empty or absent list as "no allowlist" — so a declared value whose entries all resolved blank (an env placeholder expanding to "", or whitespace-only entries) silently opened the exact restriction the operator thought they'd pinned, the same fail-open shape #240 and #207 closed for initialAccessToken and the MCP feature booleans. Both lists now throw at startup, naming the key, when the block(s) that read them are active and the declared list resolves to zero usable hosts — including an explicit allowedHosts: [], treated as declared-but-empty rather than an intentional "deny every host": neither validator has a mode where an empty list means "deny," so a present-but-empty array can't express that choice, and omitting the key entirely remains the only way to get the documented no-allowlist default. A single unresolved ${VAR} placeholder entry inside an otherwise non-empty list also now throws instead of surviving as a literal, unmatchable hostname. allowedRedirectUriHosts is read by both the DCR and CIMD redirect-uri checks, so its guard fires whenever either is active — disabling DCR alone does not make a declared-but-empty list inert while CIMD (default-enabled) still reads it. Upgrade note: an instance with MCP enabled and a declared allowlist that resolves empty (or contains an unresolved placeholder) now fails to start; applying the same value via a live config reload is rejected and the previous configuration keeps serving.