Skip to content

client_credentials (3/4): token-endpoint grant — resource binding, per-grant TTL, discovery #162

Description

@heskew

Part 3 of 4 for #159 (see the design review). Depends on parts 1 and 2 — this wires them into the token endpoint and completes the end-to-end flow. Re-scoped 2026-07-09 for CIMD-first (#161 rewrite): client identity resolves through #167's resolveClient() — a CIMD document for the agent fleet, or a stored (DCR) record as back-compat — the grant is agnostic to which.

Scope

Token endpoint (src/lib/mcp/token.ts) + config/discovery/docs:

  • grant_type=client_credentials branch: requires client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer + client_assertion; client_id required in the form body and must match assertion iss/sub; client resolved via resolveClient() (feat: Client ID Metadata Documents with SSRF-guarded resolution + consent interstitial (#166) #167 — CIMD document or stored record), then client auth via part 1's verifier against the resolved jwks; jti checked through the replay store; the resolved client must carry private_key_jwt + client_credentials, and CIMD-resolved clients must pass the client_credentials (2/4): CIMD-first client resolution for private_key_jwt agents (DCR back-compat optional) #161 allowedHosts gate.
  • Resource binding: accept the RFC 8707 resource parameter, defaulting to the configured canonical resource; exact-match, fail-closed (no prefix/wildcard) against what the client is registered for.
  • Issued token: existing RS256 mint path (mintTokenPair with issueRefresh: false); sub = client_id (RFC 9068 §2.2) — document that for this grant sub is a client identity, not an end user; never a refresh token.
  • TTL knob: mcp.clientCredentials.accessTokenTtl (default 300, i.e. ≤ 5 min per the issue's req 2); global accessTokenTtl default unchanged.
  • Audit/hooks: reuse oauth.mcp.token.issued; onMCPTokenIssued hook type gains 'client_credentials' — public plugin API, documented.
  • Discovery: grant_types_supported += client_credentials; token_endpoint_auth_methods_supported += private_key_jwt; advertise token_endpoint_auth_signing_alg_values_supported: ["EdDSA"].
  • Docs: MCP flow documentation gains the headless-agent section (registration → assertion → token → /mcp call).

Tests

End-to-end: register (gated) → signed assertion → token → authenticated /mcp request through withMCPAuth. Plus: replayed assertion rejected; wrong-resource rejected; default resource honored; unregistered-resource rejected; TTL knob honored (expires_in, exp); no refresh_token in the response; client_id/iss mismatch rejected; audit event + hook fired with type: 'client_credentials'; discovery metadata updated; Basic/ops-auth cannot reach the grant without a valid assertion (issue req 6, at the endpoint layer).

Integration coverage inherited from #160 (must land here, not in #165): the mcp_assertion_jtis primitives are unit-tested against a mocked harper, so their real-storage behavior is only exercised once this grant wires them into an HTTP path against a real Harper child process. This grant's e2e integration test is the first thing to drive the table, so it must assert (a) the replay guard round-trips on real storage — a second presentation of the same assertion is rejected across a real DB read/write, not just the mock — and (b) @createdTime actually populates mcp_assertion_jtis.created_at with a Harper-assigned epoch-ms value (confirming the directive does more than load). #165 confirms the directive loads (schema boots in the integration fixture); it cannot confirm it populates because nothing writes the table until this grant exists.

Dependencies

Parts 1 and 2 (#160 — merged, #161 as re-scoped) and #167 (CIMD resolution layer).

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions