Skip to content
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ Opt-in support for the Model Context Protocol authorization flow ([issue #86](ht
| `mcp.clientIdMetadataDocuments.allowedHosts` | string[] | (none) | If set, only CIMD `client_id` URLs whose hostname is in this list are resolved. Others are silently rejected (`invalid_client`) without revealing the allowlist |
| `mcp.clientIdMetadataDocuments.fetchTimeoutMs` | number | `5000` | Deadline for CIMD document retrieval covering DNS, connect, and body read (milliseconds). Non-finite or non-positive values fall back to the default |
| `mcp.clientIdMetadataDocuments.maxDocumentBytes` | number | `65536` | Maximum CIMD document size in bytes (64 KB default). Responses exceeding this limit are rejected. Non-finite or non-positive values fall back to the default |
| `mcp.clientCredentials.enabled` | boolean | `false` | Enable the RFC 7523 `client_credentials` grant (`private_key_jwt`, EdDSA) for headless agents. Explicit opt-in; requires a non-empty `mcp.clientIdMetadataDocuments.allowedHosts` allowlist, CIMD enabled, and an `https:` `mcp.issuer` (RFC 6749 §3.2 — the token endpoint must be TLS; `http:` is permitted only for loopback development issuers) — all enforced at startup. See [Headless agents](./mcp-oauth.md#headless-agents-client_credentials) |
| `mcp.clientCredentials.accessTokenTtl` | number | `300` | Access-token lifetime in seconds for the `client_credentials` grant. No refresh token is ever issued — agents re-mint on 401. Non-finite or non-positive values fall back to the default |
| `mcp.signingKeyPem` | string | (generated) | PEM-encoded RS256 private key (PKCS#8) used to sign access tokens. When set, this key **always** wins as the signer — it is found in the key set by material match, or persisted on first use (under a deterministic kid so concurrent cluster nodes are idempotent). When unset, a UUID-kid keypair is generated on first boot. Because all persisted keys are published in the JWKS, tokens signed by any node verify everywhere — pinning is **recommended** for clusters but not strictly required |
| `mcp.keyRotationInterval` | number | `0` (disabled) | Signing-key rotation period in seconds. When `> 0`, a fresh UUID-kid keypair is generated at token-mint time once the current signer is older than this interval. Old keys are kept in the JWKS and deleted lazily once `2 × accessTokenTtl` has passed since their immediate successor was created (covering replication lag). Rotation is skipped while `signingKeyPem` is set — setting both emits a startup warning |
| `mcp.signingAlgorithm` | string | `RS256` | JWT signing algorithm. Only `RS256` is supported in v1 (reserved for a future EdDSA option) |
Expand Down
11 changes: 9 additions & 2 deletions docs/lifecycle-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,14 +342,21 @@ Called after an MCP access or refresh token is minted. Because it runs detached

```typescript
async function onMCPTokenIssued(
event: { type: 'access' | 'refresh'; client_id: string; sub: string; aud: string; scope?: string; jti: string },
event: {
type: 'access' | 'refresh' | 'client_credentials';
client_id: string;
sub: string;
aud: string;
scope?: string;
jti: string;
},
request: Request
): Promise<void>;
```

**Parameters:**

- `event` - Identifies the token issued: `type` (`access` for the authorization-code grant, `refresh` for a rotation), `client_id`, `sub`, `aud`, `scope` (optional), and `jti` (the token id)
- `event` - Identifies the token issued: `type` (`access` for the authorization-code grant, `refresh` for a rotation, `client_credentials` for the headless-agent grant — where `sub` is the client, not a user), `client_id`, `sub`, `aud`, `scope` (optional), and `jti` (the token id)
- `request` - The HTTP request that triggered issuance

**Returns:** void. Fire-and-forget — the hook is **not awaited** (it runs detached, so it never delays or blocks token issuance); a throwing hook is caught and logged, never surfaced.
Expand Down
104 changes: 100 additions & 4 deletions docs/mcp-oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ form the `WWW-Authenticate` challenge advertises.
| ---------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `/oauth/mcp/register` | POST | RFC 7591 Dynamic Client Registration. Open by default; gate with `initialAccessToken`. Returns `201`. |
| `/oauth/mcp/authorize` | GET | OAuth 2.1 + PKCE. Requires `client_id`, `redirect_uri`, `response_type=code`, `code_challenge`, `code_challenge_method=S256`, `resource`. |
| `/oauth/mcp/token` | POST | Grants: `authorization_code`, `refresh_token`. Returns the token pair with `Cache-Control: no-store`. |
| `/oauth/mcp/token` | POST | Grants: `authorization_code`, `refresh_token`, and (opt-in) `client_credentials`. Returns the token pair with `Cache-Control: no-store`. |

> `mcp` is a reserved provider name — the plugin refuses to start if you configure
> a provider called `mcp`, because it would collide with `/oauth/mcp/*`.
Expand Down Expand Up @@ -608,9 +608,9 @@ hostname string is accepted and normalized to a one-element list. Omitting
gate still applies.

> **v1 limitation:** only `token_endpoint_auth_method: none` (public clients) is
> supported for CIMD clients. `private_key_jwt` authentication will be activated
> by issue [#159](https://github.com/HarperFast/oauth/issues/159). Other auth
> methods are rejected with `invalid_client`.
> supported for **interactive** CIMD clients. `private_key_jwt` is accepted only
> in the [headless-agent document shape](#headless-agents-client_credentials) —
> any other combination is rejected with `invalid_client`.

### Stored/DCR clients are unchanged

Expand All @@ -619,6 +619,102 @@ not parse as an HTTPS URL with a non-root path goes directly to the DCR store as
before. CIMD clients and DCR clients can coexist; existing DCR registrations are
not affected.

## Headless agents (client_credentials)

Autonomous agents — no browser, no human at request time — authenticate **as
themselves** with the RFC 7523 `client_credentials` grant (`private_key_jwt`,
EdDSA/Ed25519). The grant is **explicit opt-in** and gated on a pinned CIMD
allowlist:

```yaml
mcp:
enabled: true
issuer: https://as.example.com
clientIdMetadataDocuments:
allowedHosts:
- agents.example.com # REQUIRED for client_credentials — startup error without it
clientCredentials:
enabled: true
accessTokenTtl: 300 # default; agents re-mint on 401
```

Agents don't register. Each agent's `client_id` is an HTTPS URL to a CIMD
document carrying its public Ed25519 key set:

```json
{
"client_id": "https://agents.example.com/fleet/agent-1.json",
"client_name": "Fleet Agent 1",
"grant_types": ["client_credentials"],
"token_endpoint_auth_method": "private_key_jwt",
"jwks": { "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": "…", "kid": "agent-key-1" }] }
}
```

Document rules (all rejections are `invalid_client`):

- `grant_types` must be exactly `["client_credentials"]` — no mixing with
redirect-based grants or `refresh_token`.
- `token_endpoint_auth_method` must be `private_key_jwt`.
- `jwks` is required inline: 1–8 **public** OKP/Ed25519 keys. Any key carrying
private material (`d`) rejects the whole document. `jwks_uri` is rejected —
the document itself is the hosted-key story, and a second SSRF-fetch surface
isn't worth an indirection.
- `redirect_uris` / `response_types` must be **absent**. This deviates from the
CIMD draft's required-fields list deliberately: RFC 7591 §2 requires
`redirect_uris` only for redirect-based grant types, and a
`client_credentials`-only client has no redirect surface by construction.
(The MCP [OAuth Client Credentials extension](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials)
doesn't profile the document shape; if it later does, revisit.)
- The document's host must be in `clientIdMetadataDocuments.allowedHosts`. The
grant refuses to start without a non-empty allowlist (startup error) and
refuses credentials documents at resolution without it — hosting a reachable
document must never suffice to mint tokens.

The token request (RFC 7523 §2.2 client authentication):

```
POST /oauth/mcp/token
grant_type=client_credentials
client_id=https://agents.example.com/fleet/agent-1.json
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<EdDSA-signed JWT>
resource=https://app.example.com/mcp (optional; must exactly match when present)
```

Assertion requirements: `alg: EdDSA`; `iss` = `sub` = the `client_id`; `aud` =
the token endpoint URL exactly; `exp` within 60 s of now; `jti` required and
single-use (a replay is rejected via the shared `mcp_assertion_jtis` table).
A `Basic` header or `client_secret` alongside the assertion is rejected — proof
of key possession is the only accepted authentication for this grant.

> **Replay-guard bound:** `jti` single-use is enforced best-effort under
> concurrency — Harper's `Table.create()` existence check is not atomic across
> simultaneous in-flight requests ([harper#1745](https://github.com/HarperFast/harper/issues/1745)
> tracks the atomic-reserve contract), so concurrent presentations of the same
> assertion can race; anything after the first row lands is rejected. The
> residual is deliberately narrow: assertions live ≤ 60 s, the grant requires
> an `https:` issuer, and capturing a live assertion in transit therefore
> implies a vantage point (TLS interception, host access) from which the
> minted bearer token itself is equally exposed.

The issued token is the same RS256 Bearer JWT as the interactive flow, with two
differences: **`sub` is the client identity** (`sub` = `client_id`, RFC 9068
§2.2 — there is no end user in this grant) and **no refresh token is ever
issued** — the default TTL is 5 minutes and agents simply re-mint on 401.
`onMCPTokenIssued` fires with `type: 'client_credentials'`. The token's scope
is the document-declared `scope`; a `scope` parameter on the token request is
not honored (a client can never escalate past its registered scope, and
RFC 6749 §3.3 downscoping-on-request is future work).

Key rotation / revocation semantics: the fleet rotates a key by updating the
agent's metadata document. The change takes effect within the CIMD cache TTL
(up to 24 h, typically 1 h — bound it with `Cache-Control: max-age` on the
document), further bounded by the ≤60 s assertion window and the short access
token TTL. Removing the document (or the host from `allowedHosts`) revokes the
agent on the same schedule; a dropped allowlist takes effect immediately, even
for cached documents.

---

## Not yet supported (v1.1+)
Expand Down
37 changes: 37 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,43 @@ export async function handleApplication(scope: Scope): Promise<void> {
);
}
}
// client_credentials mints tokens for headless agents with no human in
// the loop, so its prerequisites are startup errors, not runtime 4xxs:
// the CIMD allowlist must be pinned (hosting a reachable metadata
// document must never suffice to mint tokens — #159 design update) and
// CIMD resolution must be on (DCR never registers private_key_jwt
// clients, so without CIMD the grant could authenticate no one).
if (mcpConfig?.enabled && mcpConfig.clientCredentials?.enabled === true) {
const allowedHosts = mcpConfig.clientIdMetadataDocuments?.allowedHosts;
if (!Array.isArray(allowedHosts) || allowedHosts.length === 0) {
throw new Error(
'mcp.clientCredentials.enabled requires a non-empty mcp.clientIdMetadataDocuments.allowedHosts ' +
'allowlist — pin the hosts that may serve agent metadata documents.'
);
}
if (mcpConfig.clientIdMetadataDocuments?.enabled === false) {
throw new Error(
'mcp.clientCredentials.enabled requires CIMD resolution ' +
'(mcp.clientIdMetadataDocuments.enabled must not be false).'
);
}
// The token endpoint carries signed assertions in and bearer tokens
// out — RFC 6749 §3.2 requires TLS. An http: issuer is tolerated for
// the interactive flows (the __Host- consent cookie fails safe there),
// but this grant has no such self-protection, so a cleartext remote
// AS is a startup error. Loopback stays allowed for local development.
// (mcp.issuer is guaranteed present and origin-validated by the
// mcp.enabled checks above, which throw before this block runs.)
const issuerUrl = new URL(mcpConfig.issuer!);
const loopback =
issuerUrl.hostname === 'localhost' || issuerUrl.hostname === '127.0.0.1' || issuerUrl.hostname === '[::1]';
if (issuerUrl.protocol !== 'https:' && !loopback) {
throw new Error(
'mcp.clientCredentials.enabled requires an https: mcp.issuer (the token endpoint must be TLS ' +
'per RFC 6749 §3.2); http: is only permitted for loopback development issuers.'
);
}
}
// Warn when the operator sets both a pinned key and a rotation interval —
// pin wins and rotation is silently skipped, which could surprise them.
if (mcpConfig?.signingKeyPem && mcpConfig?.keyRotationInterval) {
Expand Down
9 changes: 9 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,20 @@ export function coerceConfigBoolean(value: unknown): boolean | undefined {
* `String.includes` would turn into substring matching) is wrapped into a
* single-element array; anything that isn't a string or array of strings is
* rejected rather than treated as "no restriction".
* - `mcp.clientCredentials.enabled` is coerced the same way — this flag mints
* tokens for headless agents, so a stray truthy string must not enable it
* (it is explicit opt-in, default OFF).
*/
export function normalizeMcpSecurityConfig(mcpConfig: Record<string, any>): void {
const enabled = coerceConfigBoolean(mcpConfig.enabled);
if (enabled !== undefined) mcpConfig.enabled = enabled;

const clientCredentials = mcpConfig.clientCredentials;
if (clientCredentials && typeof clientCredentials === 'object') {
const ccEnabled = coerceConfigBoolean(clientCredentials.enabled);
if (ccEnabled !== undefined) clientCredentials.enabled = ccEnabled;
}

const cimd = mcpConfig.clientIdMetadataDocuments;
if (cimd && typeof cimd === 'object') {
const cimdEnabled = coerceConfigBoolean(cimd.enabled);
Expand Down
9 changes: 8 additions & 1 deletion src/lib/hookManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,14 @@ export class HookManager {
* never surfaced to the caller.
*/
callOnMCPTokenIssued(
event: { type: 'access' | 'refresh'; client_id: string; sub: string; aud: string; scope?: string; jti: string },
event: {
type: 'access' | 'refresh' | 'client_credentials';
client_id: string;
sub: string;
aud: string;
scope?: string;
jti: string;
},
request: any
): void {
const hook = this.hooks.onMCPTokenIssued;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/mcp/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ export async function handleAuthorize(
};
}

if (!query.redirect_uri || !redirectUriMatches(query.redirect_uri, client.redirect_uris)) {
if (!query.redirect_uri || !redirectUriMatches(query.redirect_uri, client.redirect_uris ?? [])) {
return {
status: 400,
body: {
Expand Down
Loading
Loading