Skip to content

Add RFC 7523 client_credentials grant (private_key_jwt, EdDSA) for headless agent auth #159

Description

@tps-flint

Problem

Headless agents (no browser, no human at request time) need to authenticate as themselves
to a Harper MCP server's /mcp endpoint. The plugin today issues MCP tokens only via the
authorization-code + refresh flow — both assume an interactive human consent step. There is no
grant an autonomous agent can complete on its own.

Proposed solution — the standards path

RFC 7523 private_key_jwt client authentication + a client_credentials grant. This is the
current, preferred standard for "a named client authenticates as itself" (it's what the MCP
client-credentials extension and WorkOS/Scalekit agent-auth build on). No browser, no human, ever.

Add to the token endpoint:

  • grant_type=client_credentials with client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
    and a client_assertion JWT (RFC 7523 §2.2).
  • Assertion verification:
    • alg: EdDSA (Ed25519, RFC 8037). The plugin's current jsonwebtoken dep cannot verify
      EdDSA, but this needs no new dependency — Node's built-in crypto does it natively:
      crypto.verify(null, signingInput, publicKey, sig) over the JWT's header.payload, with the
      key reconstructed via crypto.createPublicKey({key: jwk, format: 'jwk'}). Verified end-to-end
      (correct key → true, tampered payload → false) on Node 25. ~15 lines of JWT plumbing, zero deps.
    • Verify against the registered client's public key.
    • Required claims: iss = sub = client_id; aud = the token endpoint; short exp
      (recommend ≤ 60s); jti (replay guard); iat.
  • Issued token: unchanged RS256 Bearer from the existing tokenIssuer machinery; aud = the
    MCP resource; short TTL (see security reqs).

Client registration

Each agent is registered as an MCP client whose registered credential is its Ed25519 public
key as a JWK ({kty:"OKP", crv:"Ed25519", x:"…"})
— the format crypto.createPublicKey consumes
directly. Ideally reuses the existing clientStore / dcr path. The agent's single long-lived
Ed25519 key then serves as both its identity and its client-assertion signing key — no new key
material.

Security requirements (from an internal security design review — please carry into the grant)

  1. Assertion replay guard: enforce exp ≤ 60s and a jti nonce store (reject a re-seen
    jti within the window). Timestamp-only is insufficient.
  2. Issued-token TTL ≤ 5 minutes. Re-mint on 401; no refresh token needed. Limits leak blast radius.
  3. Audience binding fail-closed: issued aud MUST exactly equal the MCP resource — no prefix/wildcard.
  4. Audit log every issuance: client_id, audience, timestamp, jti.
  5. Rate-limit the grant (defense-in-depth).
  6. No Basic-admin shortcut: obtaining an agent token must require proving possession of the
    client's private key — a human/service Basic super_user must not be able to mint a token as
    an agent. (client_credentials + private_key_jwt gives this for free; flagging so it stays true.)

Division of responsibility

  • Plugin (this repo): the grant, assertion verification (incl. EdDSA), client registration of a
    public key, the 6 security reqs above.
  • Consumer side (separate, ours): registering each agent as a client with its existing Ed25519
    pubkey, and a small helper that builds + signs the assertion and does the round-trip. No
    consumer code reaches into plugin internals
    — putting the grant in the plugin is what makes
    that clean, versus a consumer-built token endpoint poking at the plugin's key table.

Decided (design constraints)

  • No new dependency. EdDSA verify via built-in node:crypto (proven end-to-end), not jose.
  • Standards spine: RFC 7523 (client_assertion) + RFC 8037 (EdDSA/Ed25519) + OAuth 2.1
    client_credentials. Nothing bespoke.
  • Key format: Ed25519 public key as a JWK OKP in the client record.

Open coordination question

  1. Client registration through the existing DCR path, or a new admin-gated registration
    command? Leans admin-gated for a fixed fleet, but that's a call on the plugin's registration model.

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