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)
- Assertion replay guard: enforce
exp ≤ 60s and a jti nonce store (reject a re-seen
jti within the window). Timestamp-only is insufficient.
- Issued-token TTL ≤ 5 minutes. Re-mint on 401; no refresh token needed. Limits leak blast radius.
- Audience binding fail-closed: issued
aud MUST exactly equal the MCP resource — no prefix/wildcard.
- Audit log every issuance:
client_id, audience, timestamp, jti.
- Rate-limit the grant (defense-in-depth).
- 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
- 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.
Problem
Headless agents (no browser, no human at request time) need to authenticate as themselves
to a Harper MCP server's
/mcpendpoint. The plugin today issues MCP tokens only via theauthorization-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_jwtclient authentication + aclient_credentialsgrant. This is thecurrent, 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_credentialswithclient_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearerand a
client_assertionJWT (RFC 7523 §2.2).alg: EdDSA(Ed25519, RFC 8037). The plugin's currentjsonwebtokendep cannot verifyEdDSA, but this needs no new dependency — Node's built-in
cryptodoes it natively:crypto.verify(null, signingInput, publicKey, sig)over the JWT'sheader.payload, with thekey 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.
iss=sub=client_id;aud= the token endpoint; shortexp(recommend ≤ 60s);
jti(replay guard);iat.tokenIssuermachinery;aud= theMCP 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 formatcrypto.createPublicKeyconsumesdirectly. Ideally reuses the existing
clientStore/dcrpath. The agent's single long-livedEd25519 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)
exp≤ 60s and ajtinonce store (reject a re-seenjtiwithin the window). Timestamp-only is insufficient.audMUST exactly equal the MCP resource — no prefix/wildcard.client_id, audience, timestamp,jti.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
public key, the 6 security reqs above.
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)
node:crypto(proven end-to-end), notjose.client_credentials. Nothing bespoke.Open coordination question
command? Leans admin-gated for a fixed fleet, but that's a call on the plugin's registration model.