Skip to content

Add ES256 signing support for MCP access tokens#191

Merged
heskew merged 4 commits into
mainfrom
es256-signing
Jul 23, 2026
Merged

Add ES256 signing support for MCP access tokens#191
heskew merged 4 commits into
mainfrom
es256-signing

Conversation

@heskew

@heskew heskew commented Jul 21, 2026

Copy link
Copy Markdown
Member

Closes #127 (the ES256 leg; EdDSA stays deferred by design — jsonwebtoken cannot emit it, and per the issue it should only be implemented when a specific client needs it).

Summary

mcp.signingAlgorithm now accepts ES256 (EC P-256) alongside the RS256 default, exactly as sketched in #127 — no new dependency:

  • Generated keys (first boot and rotation) follow the configured algorithm; the alg column already existed per-row, so mixed key sets are schema-compatible and the change is additive (old keys stay in the JWKS until retired, outstanding tokens keep verifying, no migration).
  • Changing the config on a running deployment rotates in a new key under the new algorithm, dampened by a 5-minute age floor so nodes with briefly-disagreeing configs during a rolling rollout don't rotate against each other on every mint.
  • Pinned keys (mcp.signingKeyPem) derive their algorithm from the key material (RSA → RS256, EC P-256 → ES256; anything else throws at mint). An EC pin never claims the legacy rs256-default kid.
  • Verification pins algorithms to the selected key's declared alg (still blocks alg: none and cross-alg substitution); publicKeyToJwk branches on the exported key type (EC → crv/x/y).
  • AS metadata advertises the union of the effective algorithm (pin-aware) and any algorithm still live in the published key set, so tokens minted during a transition are never misdescribed; startup warns on a config/pin mismatch or an unrecognized signingAlgorithm value.
  • Post-review hardening (Codex findings): interval rotation cannot bypass the alg-switch age floor, and a legacy EC pin row persisted as RS256 by pre-ES256 code is repaired in place from its key material.

Where to look

  • The alg-switch rotation + age floor in keyStore.ts (getSigningKey) is the one genuinely new behavior with design latitude — the floor (300s) bounds rolling-rollout churn to one rotation per direction per interval instead of per-mint. Flagged by the cross-model review; alternative was documenting "roll out atomically" only.
  • algFromPrivateKeyPem decides a pinned key's algorithm from material, silently overriding signingAlgorithm (with a startup warning on mismatch) — confirm that precedence reads right.

Review notes

Cross-model review ran (Codex + Claude domain pass; the Gemini leg was unavailable in this environment, so no second-model perf lens). No blockers; the rolling-rollout thrash concern was addressed with the age floor above, and the unrecognized-value silent-RS256 fallback now warns at startup.

1095 unit tests pass (21 new: ES256 sign/verify/JWKS, key-store generation/switch/damper/pin, metadata advertisement, helper validation).

Generated by an LLM (Claude Fable 5) pairing with @heskew.

🤖 Generated with Claude Code

https://claude.ai/code/session_0185AoLyjuZUuKAwg9NxUDiq

mcp.signingAlgorithm now accepts 'ES256' (EC P-256) alongside the RS256
default. Generated keys (first boot and rotation) follow the configured
alg, and changing it on a running deployment rotates in a new key while
old keys stay in the JWKS until retired — additive, no migration. Pinned
keys (mcp.signingKeyPem) derive their alg from the key material (RSA or
EC P-256; anything else throws at mint); an EC pin never claims the
legacy rs256-default kid. Verification pins algorithms to the selected
key's declared alg, and JWK serialization branches on key type. AS
metadata advertises the effective alg (pin-aware). EdDSA remains
deferred (jsonwebtoken cannot emit it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185AoLyjuZUuKAwg9NxUDiq
@heskew
heskew requested a review from kriszyp July 21, 2026 22: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 introduces support for the ES256 (EC P-256) signing algorithm for Model Context Protocol (MCP) OAuth access tokens, alongside the existing RS256 algorithm. Key changes include updating configuration schemas, implementing EC keypair generation and rotation in the MCPKeyStore (with a rolling-deployment age floor to prevent churn), updating the token issuer to sign and verify ES256 tokens, and serializing EC public keys to JWK format. Additionally, configuration validation warnings have been added, and comprehensive unit tests have been introduced to cover these new capabilities. There are no review comments to address, and the implementation appears robust and well-tested, so I have no further feedback to provide.

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@heskew

heskew commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

Re the gemini-review suggestion on ALG_SWITCH_MIN_KEY_AGE_SECONDS: the constant already carries a comment block explaining the value (rolling-rollout damper — bounds mixed-config churn to one rotation per direction per interval), and the tradeoff is described in types.ts, docs/configuration.md, and the PR description. Making it configurable would be YAGNI until someone needs a different bound. Resolving.


🤖 Posted by Claude on Nathan's behalf

@heskew
heskew marked this pull request as ready for review July 21, 2026 23:03
The ternary + spread-encoding form made promisify's generateKeyPair
overloads resolve to KeyObject, so the 'as string' casts failed
tsc --noEmit (masked in CI by `tsc || true`). Inline literal options
per branch restore the string-returning overloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185AoLyjuZUuKAwg9NxUDiq
Comment thread docs/mcp-oauth.md
The integration guidance still described tokens/JWKS as RS256-only and
promised the fixed rs256-default kid (stale since UUID-kid multi-key
publication). Reworded to alg-neutral or 'RS256 or ES256', and the
issued-token section now tells integrators to resolve the kid against
the JWKS instead of assuming a fixed key id.

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

@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.

Three actionable issues found in the ES256 transition and pinned-key compatibility paths. The first two should be addressed before merge; the third is a narrower upgrade edge case.


🤖 Posted by Codex on Nathan's behalf.

Comment thread src/lib/mcp/wellKnown.ts Outdated
Comment thread src/lib/mcp/keyStore.ts
Comment thread src/lib/mcp/keyStore.ts Outdated
… legacy EC pin repair

P1: AS metadata now advertises the union of the effective alg and the
algs of keys still live in the JWKS (builder is async; the handler
already awaited), so a damper-deferred RS256 signer is not misdescribed
as ES256-only during a switch.
P2: interval rotation is skipped while the newest key's alg disagrees
with config — the deferred alg switch owns that state, so a short
keyRotationInterval can no longer bypass the 300s floor.
P3: a matching pinned row now has its alg re-derived from the key
material and repaired in place — pre-ES256 code persisted every pin as
RS256, leaving an EC pin durably unusable after upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185AoLyjuZUuKAwg9NxUDiq
Comment thread src/lib/mcp/keyStore.ts
@heskew
heskew merged commit fb4016f into main Jul 23, 2026
20 checks passed
@heskew
heskew deleted the es256-signing branch July 23, 2026 00:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP OAuth: optional additional signing algorithms (ES256 no-dep; EdDSA on demand)

2 participants