Skip to content

feat(mcp): client_credentials grant with CIMD-first private_key_jwt agents - #170

Merged
heskew merged 7 commits into
mainfrom
feat/161-162-client-credentials
Jul 11, 2026
Merged

feat(mcp): client_credentials grant with CIMD-first private_key_jwt agents#170
heskew merged 7 commits into
mainfrom
feat/161-162-client-credentials

Conversation

@heskew

@heskew heskew commented Jul 10, 2026

Copy link
Copy Markdown
Member

Closes #161, closes #162 (parts 2+3 of #159 — headless agents authenticating as themselves to a Harper MCP server).

Summary

  • Grant (token.ts): grant_type=client_credentials per RFC 7523 §2.2 client authentication — requires client_assertion_type=…jwt-bearer + an EdDSA (Ed25519) client_assertion; client_id must equal assertion iss/sub; verification via the client_credentials (1/4): assertion primitives — strict EdDSA JWT verify + jti replay store #160 primitives (aud = token endpoint, ≤60s window, jti replay store fail-closed); RFC 8707 resource exact-match fail-closed (checked before the jti burn — a recoverable param mistake doesn't consume the single-use assertion); mints the existing RS256 token with sub = client_id (RFC 9068 §2.2: a client identity, not an end user), TTL default 300s, never a refresh token. A Basic header or client_secret riding along is rejected (key possession is the only accepted auth).
  • CIMD credentials documents (cimd.ts): documents with grant_types exactly ["client_credentials"] + token_endpoint_auth_method: private_key_jwt + inline jwks of 1–8 public Ed25519 keys (private-d material, jwks_uri, redirect surfaces all rejected). Interactive CIMD documents are unchanged.
  • Gate: clientCredentials.enabled is explicit opt-in (default off; disabled is byte-identical to an unknown grant). When enabled, startup fails fast unless clientIdMetadataDocuments.allowedHosts is non-empty — hosting a reachable document must never suffice to mint tokens. The allowlist is enforced at resolution, on cache hits, and in the document validator.
  • Discovery (wellKnown.ts): advertises client_credentials / private_key_jwt / EdDSA only when enabled.
  • Docs: headless-agents section in docs/mcp-oauth.md, knobs in docs/configuration.md (docs live in-repo; no external docs PR needed).

Where to look / deliberate choices

  1. Assertion-failure reasons are echoed in error_description (token.ts). Deliberate: signature is verified before any claim, so claim-level reasons are unreachable without the private key, and pre-signature reasons only reveal publicly-fetchable registration state — while detailed reasons make clock-drift debugging (the classic private_key_jwt pain) tractable for legit agents. Weigh in if you'd rather have a generic message.
  2. redirect_uris omitted for credentials-only documents deviates from the CIMD draft's required-fields list; documented in docs/mcp-oauth.md per RFC 7591's conditional-requirement semantics (required only for redirect-based grants). The MCP client-credentials extension doesn't profile the document shape (checked against the normative ext-auth text).
  3. Spec conformance: the normative MCP ext-auth client-credentials spec requires grant_type=client_credentials with RFC 7523 §2.2 JWT client authentication and the exact discovery fields added here. (Its docs page shows a §2.1 JWT-bearer-grant diagram — non-normative.)
  4. jti single-use is best-effort under concurrency (external-review finding, dispositioned document-the-bound): Harper's Table.create() existence check is not atomic across simultaneous in-flight requests until harper#1745 lands an atomic reserve. The race covers concurrent presentations only — anything after the first row lands is rejected — and the grant now requires an https: issuer (69eba93), so capturing a live ≤60s assertion implies a vantage point from which the minted bearer is equally exposed. Bound documented in docs/mcp-oauth.md, assertionJtiStore.ts, and the grant's replay-guard comment.

Cross-model review (thorough): zero blockers, zero significant concerns; 4 of 5 suggestions applied in 6644754. Caveat: the Codex correctness leg stalled — coverage came from the Gemini leg plus a manual domain trace of clientAssertion.ts/cimd.ts.

Suite: 1015 tests / 1013 pass / 2 pre-existing skips (+30 vs main). Generated by an LLM (Claude Fable 5).

🤖 Generated with Claude Code

https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73

heskew and others added 2 commits July 10, 2026 16:14
…gents (#161, #162)

Headless agents authenticate as themselves with RFC 7523 private_key_jwt
(EdDSA/Ed25519) client assertions on a new client_credentials grant.

Resolution/validation (#161): CIMD documents declaring client_credentials
take a distinct shape — exactly that one grant, private_key_jwt, an inline
JWK Set of 1..8 PUBLIC Ed25519 keys (private material rejects the document;
jwks_uri rejected outright — no second SSRF surface), and NO
redirect_uris/response_types (deviation from the CIMD draft's required
list, per RFC 7591's redirect-based-grants-only semantics; documented).
Credentials documents only materialize when the operator has pinned
clientIdMetadataDocuments.allowedHosts — enforced at resolution, on cache
hits, and at startup: hosting a reachable document must never suffice to
mint tokens. DCR back-compat dropped: SUPPORTED_GRANT_TYPES unchanged, so
registration still rejects client_credentials.

Grant (#162): explicit opt-in via mcp.clientCredentials.enabled (default
OFF; disabled is indistinguishable from any unsupported grant). Verifies
the assertion with the #160 primitives (aud = token endpoint, exp <= 60s,
jti single-use via the replay table — storage failures fail closed to
500), rejects any Basic/secret riding along, binds RFC 8707 resource by
exact match (invalid_target otherwise), and mints the existing RS256 token
with sub = client_id (RFC 9068 §2.2), TTL 300s default, and never a
refresh token. onMCPTokenIssued gains type 'client_credentials'.
Discovery advertises client_credentials/private_key_jwt/EdDSA only when
enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
…etimes, case-insensitive auth-scheme guard

- Check the RFC 8707 resource match BEFORE consuming the jti: a
  recoverable request-param mistake must not burn the single-use
  assertion (regression test proves the same assertion retries).
- Reject exp <= iat as structurally malformed in verifyClientAssertion.
- Match the Basic-auth riding-along guard case-insensitively
  (RFC 9110 §11.1 schemes are case-insensitive).
- Drop the stale '#162 will wire' comment — the knobs stay unwired by
  design; defaults are the conservative 60s/5s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
@heskew
heskew requested a review from kriszyp July 10, 2026 23:44
@github-actions

This comment has been minimized.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the RFC 7523 client_credentials grant for headless agents within the Model Context Protocol (MCP) authorization flow. It introduces configuration options to enable the grant and define access token lifetimes, enforces strict startup validation requiring a pinned host allowlist and CIMD resolution, and implements client assertion verification with replay protection. Feedback on the changes suggests strengthening the validation of the registered JWK Set during CIMD document resolution by ensuring kid uniqueness, requiring kid when multiple keys are present, and strictly validating the Ed25519 x coordinate length and alphabet.

Comment thread src/lib/mcp/cimd.ts
@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

…shape at resolution

Verification already fails closed on missing/duplicate kids (selectKey
requires exactly one match) — but a fleet publishing a malformed
rotation set would cache fine and then fail every assertion with a
confusing verify error. Rejecting at document resolution surfaces the
problem once, clearly. Also pins x to the exact 43-char base64url shape
of an Ed25519 public key so malformed keys never enter the cache, and
fixes the unused _omit lint errors CI caught in cimd.test.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
@heskew
heskew marked this pull request as ready for review July 11, 2026 06:01
The allowedHosts allowlist — the gate between 'hosts a reachable
document' and 'mints tokens' — is enforced on the CIMD resolution path.
Require _cimd on the resolved record so a stored (DCR) client can never
mint here, even if a future DCR surface could register the credentials
shape; lifting the pin requires its own registration gate (#161's
optional initialAccessToken leg). Gemini review suggestion; the
companion suggestion (record the jti after minting) was rejected —
check-and-record before mint is the anti-replay barrier, and moving it
would open a concurrent-replay window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
@heskew

heskew commented Jul 11, 2026

Copy link
Copy Markdown
Member Author

Gemini suggestions dispositioned on a331bf9:

1. _cimd === true pin — taken. The grant is now explicitly pinned to CIMD-resolved clients, with a test proving a stored record wearing the full credentials shape still gets unauthorized_client. Rationale in the code comment: the allowedHosts allowlist is enforced on the CIMD resolution path, so a stored (DCR) record reaching this grant would bypass the only registration gate; any future DCR surface for this shape needs its own gate (#161's optional initialAccessToken leg).

2. Record the jti after minting — rejected. The check-and-record is the anti-replay barrier: deferring it past mintTokenPair opens a concurrent-replay window (two requests presenting the same jti would both reach the mint before either records). A transient mint failure burning a jti is fail-closed and cheap to recover — agents sign a fresh assertion per request by design. Wrong trade for an auth primitive.

Suite: 1017 tests / 1015 pass / 2 pre-existing skips; lint + prettier clean.

🤖 Response by Claude (Fable 5) on Nathan's behalf

@heskew heskew left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: request changes

1. Client-credentials can advertise and use a cleartext token endpoint — high

src/index.ts:190-200 accepts an http: mcp.issuer, and the new clientCredentials gate at :216-229 does not tighten that. Discovery consequently publishes an HTTP token endpoint; an agent sends its signed assertion there and receives the bearer token there. A network observer can steal the token and race the still-valid assertion. OAuth requires TLS for the token endpoint (RFC 6749 §§1.6, 3.2).

Require an https: issuer when mcp.clientCredentials.enabled is true. If local development needs HTTP, make a narrowly-scoped loopback-only development exception rather than permitting a cleartext remote AS.

2. The asserted single-use replay property is not provided under concurrency — high

src/lib/mcp/token.ts:532 treats MCPAssertionJtiStore.checkAndRecord() as the final anti-replay barrier, but src/lib/mcp/assertionJtiStore.ts:20-29 and its test at test/lib/mcp/assertionJtiStore.test.js:108-120 document that concurrent Table.create() calls can all succeed under Harper's current pre-staging existence check. A captured, still-valid assertion can therefore be redeemed concurrently for multiple five-minute access tokens; the 120-second row TTL only blocks later requests.

Do not claim or depend on single-use jti enforcement until the underlying write is atomic across the deployments this plugin supports. Gate this grant on an atomic store/primitive (including cross-node semantics), or defer it until harper#1745 supplies that guarantee; a per-process lock would not cover multiple workers or nodes.

Scope note: the credentials-only CIMD shape, inline-JWK restriction, socket cleanup, and bounded DNS/full-resolution work are all directly tied to this public, network-fetching grant. I did not find an overarchitecture-only change to remove.


AI-assisted review by Codex (GPT-5).

heskew and others added 2 commits July 10, 2026 23:37
RFC 6749 §3.2 — the token endpoint carries signed assertions in and
bearer tokens out, so a cleartext remote AS is a startup error when the
grant is enabled. http: stays permitted for loopback development
issuers only. The interactive flows keep tolerating http (the __Host-
consent cookie fails safe there); this grant has no such
self-protection. External review finding 1 (Codex, request-changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
Single-use jti enforcement is best-effort under concurrency until
harper#1745 supplies an atomic reserve — the race covers simultaneous
in-flight presentations only, and with the https-issuer requirement a
live-assertion capture implies a vantage point from which the minted
bearer is equally exposed. External review finding 2, dispositioned
document-the-bound per Nathan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
@heskew

heskew commented Jul 11, 2026

Copy link
Copy Markdown
Member Author

Both external-review findings addressed:

1. Cleartext token endpoint (high) — fixed in 69eba93. mcp.clientCredentials.enabled now requires an https: mcp.issuer at startup (RFC 6749 §3.2); http: is permitted only for loopback development issuers (localhost / 127.0.0.1 / [::1]). The interactive flows keep tolerating http since the __Host- consent cookie fails safe there — this grant had no such self-protection. Startup tests cover the cleartext-remote rejection and the loopback exception; docs updated.

2. jti single-use under concurrency (high) — dispositioned document-the-bound (Nathan's call), a70518a. The non-atomic Table.create() existence check is the known #160/#165 limitation tracked in harper#1745, and the race covers simultaneous in-flight presentations only — anything after the first row lands is rejected. Rather than gate the grant on a core primitive, the claim is now stated honestly everywhere it appears (docs/mcp-oauth.md replay-guard bound, assertionJtiStore.ts, the grant's replay-guard comment, and the PR description): single-use is best-effort under concurrency until harper#1745 supplies an atomic reserve. Residual-risk framing: with fix 1 making TLS mandatory, capturing a live ≤60s assertion implies a vantage point (TLS interception, host access) from which the minted 5-minute bearer is equally exposed — concurrent replay adds no marginal capability. A write-then-read-back claim check was evaluated and rejected as unsound under transaction interleaving; a per-process lock was considered and skipped as false assurance (doesn't cover workers/nodes).

Suite: 1019 tests / 1017 pass / 2 pre-existing skips; tsc, lint, prettier clean by exit code.

🤖 Response by Claude (Fable 5) on Nathan's behalf

@tps-flint

Copy link
Copy Markdown

Review — consumer + architecture lens (Flint, flair)

Reviewed as the first consumer of this grant: flair has a headless-agent MCP client (flair#663) that authenticates with private_key_jwt + client_credentials + no redirect surface, so I read this against "does the token endpoint accept the exact assertion and CIMD document our agent produces." It does, and the design is clean. Ship it. Details below.

Consumer alignment — confirmed both directions

  • CIMD credentials document (validateCredentialsDocument): our agent's document is grant_types: ["client_credentials"], token_endpoint_auth_method: "private_key_jwt", inline jwks of public Ed25519 keys, no redirect_uris/response_types. That is byte-for-byte the shape this accepts — including the private-d rejection, the OKP/Ed25519 + 43-char-x precision check, and the 1–8 key bound.
  • Assertion (handleClientCredentialsGrantverifyClientAssertion): client_assertion_type=jwt-bearer, EdDSA, client_id == iss == sub, aud = token endpoint, ≤60s window, jti. Matches what our client mints.

This also resolves the handoff I flagged on #167: redirect_uris is now optional on MCPClientMetadata, and credentials-only documents that declare one are rejected. RFC 7591 §2 requires redirect_uris only for redirect-based grants — correct call, and it unblocks the credentials-only client shape cleanly.

Architecture — what's right

  • Grant ordering is the thing I'd most want to get wrong, and it's right. Cheap/recoverable checks first; the jti burn is last and the RFC 8707 resource exact-match is checked before it. A wrong-resource or malformed request returns invalid_target/invalid_request without consuming the single-use assertion, so a client can retry with the same assertion inside its window. That ordering is a real correctness property, not incidental.
  • Four-layer allowlist, not one: host-membership gate at the top of resolveCimdClient (returns null → "unknown client", no list leak), the non-empty requirement inside validateCredentialsDocument, cache-hit revalidation (a credentials record cached while allowedHosts was pinned can't survive the operator dropping it), and — the one the description undersells — a boot-time fail-fast in handleApplication: clientCredentials.enabled with empty allowedHosts, or CIMD disabled, is a startup error, not a runtime 4xx. Prerequisites for minting-without-a-human belong at boot. Agreed.
  • CIMD-only pin on the grant (_cimd === true + private_key_jwt + grant_types exactly ["client_credentials"]) is defense-in-depth on the resolved record: a stored/DCR record can never mint here even if a future DCR surface registered this shape. Good belt over the suspenders that validateCredentialsDocument already provides.
  • coerceConfigBoolean on clientCredentials.enabled — a stray truthy YAML/env string can't silently switch on a token-minting grant. The right paranoia for an opt-in this consequential.

The four author-flagged choices — my verdict, endorse all four

  1. Assertion-failure reasons in error_description — fine. Signature is verified before claims, so claim-level reasons (clock drift, aud) are only reachable by the key holder; the pre-signature reasons are structural, not secret. For headless agents with no human watching logs, the debugging value is real and the surface is authenticated. Keep it.
  2. redirect_uris omitted for credentials-only — correct per RFC 7591 §2 (see consumer note above). This is the right deviation from the CIMD draft's blanket requirement.
  3. RFC 7523 §2.2 (client auth) over §2.1 (authorization grant) — right model. A headless agent authenticating as itself with no delegated user is client authentication, not an authorization grant; §2.1/urn:...jwt-bearer would model an authority the agent doesn't hold. Aligns with the normative MCP ext-auth client-credentials shape.
  4. jti single-use best-effort under concurrency — acceptable, and the https-issuer boot gate is what makes it acceptable: capturing a live assertion to replay inside the race requires TLS interception, which already exposes the minted bearer token — so the race yields the attacker nothing they don't already have. Combined with the ≤60s window and resource-check-before-burn, the residual is well-bounded. harper#1745 (atomic reserve) closes it fully; shipping with the documented limitation is the right call.

Also nice: the exp <= iat positive-lifetime reject in clientAssertion.ts — closes the "expired-at-issuance rides the tolerance window" gap.

Minor / forward-looking (none blocking)

  • Requested scope isn't honored. The grant mints with client.scope (the CIMD-declared scope) and ignores a scope parameter on the token request. That's safe (a client can't escalate past its registered scope), but a client that wants to request a narrower scope (RFC 6749 §4.4.2/§3.3) can't. Fine for v1; worth a line in the doc that downscoping-on-request is a future item.
  • jti-store retention. The replay guard is only as bounded as MCPAssertionJtiStore's eviction — entries only need to live for the max assertion window (≤60s + tolerance). Worth confirming the store TTLs entries to that window rather than retaining per (clientId, jti) indefinitely, so a frequently-minting agent can't grow the keyspace unbounded. (Store predates this PR; the integration just makes it load-bearing.)

Consumer notes for our side (not asks on this PR)

  • Our client should send resource explicitly (matching the canonical MCP resource) rather than relying on the server default — the check is !== undefined && !== canonical, so omitting it works today, but explicit RFC 8707 binding is better hygiene and future-proofs a multi-resource AS.
  • Operationally, enabling this grant means our MCP server must pin allowedHosts to our agents' metadata-document host, keep CIMD resolution on, and serve over https (loopback excepted) — the three boot gates. Good constraints; just flagging them as our deployment contract.

Suite 1015/1013 with the new token/cimd/clientAssertion/wellKnown coverage looks right for the surface. Nice work.

— Flint (flair)

@tps-flint

Copy link
Copy Markdown

Security review — Sherlock (proxied by Flint)

Posted on Sherlock's behalf — Sherlock is our security reviewer and doesn't yet have a public PAT on this org, so Flint is relaying his review verbatim. Reasoning and verdict are his.


Verdict: CLEAN. No findings.

(1) RFC 7523 §2.2 client-assertion verification (token.ts handleClientCredentialsGrant):

  • Signature verified BEFORE claims are checked. verifyClientAssertion() decodes the JWT → checks alg → verifies the EdDSA signature → then checks iss/sub/aud/exp/iat/jti. The error_description echo of claim failures only happens after signature verification passes — an attacker without the private key can't probe claim validity. Safe.
  • iss=sub=client_id, aud=token endpoint exactly, ≤60s window, jti required. All correct.
  • RFC 8707 resource exact-match checked BEFORE the jti burn. The test "does not burn the jti on a resource mismatch — the same assertion retries successfully" confirms this. Correct ordering.
  • jti replay store fail-closed: checkAndRecord() throws on storage failure → 500, never degrades to "not seen". The ≤60s assertion window + https issuer bounds the concurrency race (harper#1745). Acceptable for v1.
  • RS256 mint with sub=client_id, no refresh token (issueRefresh: false, default 300s TTL). Correct.
  • Basic header and client_secret rejected (case-insensitive per RFC 9110 §11.1). Test confirms lowercase basic is caught. Correct.

(2) CIMD credentials documents (cimd.ts validateCredentialsDocument):

  • grant_types exactly ['client_credentials'] (length !== 1 rejects combinations). Correct.
  • token_endpoint_auth_method 'private_key_jwt' only. Correct.
  • Inline JWKS: 1–8 PUBLIC Ed25519 keys. Private key material (d) rejected. jwks_uri rejected (no second SSRF surface). redirect_uris/response_types must be absent. All correct.
  • allowedHosts enforced at resolution (validateCredentialsDocument checks allowedHostsConfigured), cache-hit (revalidates on every hit — drops the cached record if the allowlist is gone), and the validator itself. Three-layer enforcement. Correct.
  • kid presence + uniqueness enforced on multi-key sets. 43-char base64url x shape validated. Correct.

(3) Discovery gating (wellKnown.ts):

  • client_credentials, private_key_jwt, and EdDSA only advertised when clientCredentials.enabled. Correct.

Additional hardening:

  • Startup enforcement: clientCredentials.enabled requires non-empty allowedHosts, CIMD enabled, https issuer (loopback excepted). Hosting a reachable document never suffices to mint tokens.
  • Config coercion: clientCredentials.enabled coerced via coerceConfigBoolean — a stray truthy string won't enable it.
  • DCR clients rejected: the grant checks client._cimd === true — stored clients can never use this grant, even wearing the full credentials shape.
  • redirect_uris nullable: correct for client_credentials-only clients (RFC 7591 §2 requires it only for redirect flows). ?? [] fallback handles the nullable case.

Author-flagged items — both correctly bounded:

  • Error echoing: safe. Signature verified first in verifyClientAssertion; unsigned callers never reach claim-level reasons.
  • jti race: acknowledged, bounded. ≤60s window + https issuer means exploitation requires TLS interception or host access — at which point the bearer token is equally exposed. harper#1745 is the proper fix; current state is acceptable for v1.

No findings. Ship it. 🔥

— Sherlock (security), proxied by Flint

The grant mints the document-declared scope; RFC 6749 §3.3
downscoping-on-request is future work (consumer review note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
@heskew

heskew commented Jul 11, 2026

Copy link
Copy Markdown
Member Author

Thanks Flint + Sherlock. Both minors handled on 6bad027:

Scope-downscoping — doc line added to the headless section: the grant mints the document-declared scope and a token-request scope parameter is not honored; RFC 6749 §3.3 downscoping-on-request is future work.

jti-store retention — already bounded: the mcp_assertion_jtis table is declared with expiration: 120 (Harper table-level TTL, documented in the assertionJtiStore.ts header), comfortably past the maximum assertion usability window (~70s: 60s exp + clock tolerance). Rows evict automatically; a frequently-minting agent cannot grow the keyspace beyond its 120s sliding window.

Also noting Flint's consumer-side hygiene point (send resource explicitly) is a flair-side item — no change here, the exact-match check already handles both forms.

🤖 Response by Claude (Fable 5) on Nathan's behalf

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants