Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 deletions docs/configuration.md

Large diffs are not rendered by default.

26 changes: 14 additions & 12 deletions docs/mcp-oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ GitHub login, and presents a bearer token on every subsequent call — which
│ ◀─────────────────────────────│ │
│ │ │
│ 6. POST /oauth/mcp/token (code + code_verifier) │
│ ─────────────────────────────▶│ → access_token (RS256 JWT)
│ ─────────────────────────────▶│ → access_token (signed JWT) │
│ │ + refresh_token │
│ │ │
│ 7. GET /mcp Authorization: Bearer <access_token> │
Expand All @@ -130,8 +130,8 @@ GitHub login, and presents a bearer token on every subsequent call — which
user to the upstream IdP; on return it mints a single-use authorization code
and redirects back to the client's `redirect_uri`.
6. **Token exchange.** The client posts the code plus its PKCE `code_verifier` to
`/oauth/mcp/token` and receives an RS256-signed access token (and a refresh
token) bound to the `resource` as its `aud`.
`/oauth/mcp/token` and receives a signed access token (RS256 or ES256, per `mcp.signingAlgorithm`)
and a refresh token, bound to the `resource` as its `aud`.
7. **Authenticated requests.** The client calls your MCP route with
`Authorization: Bearer <access_token>`. `withMCPAuth` verifies the signature,
audience, and issuer, attaches the claims as `request.mcp`, and runs your
Expand All @@ -149,11 +149,11 @@ discovery handlers fall through).

### Discovery (`/.well-known/*`)

| Path | Spec | Returns |
| --------------------------------------------- | -------- | ------------------------------------------------------------------------------------ |
| `GET /.well-known/oauth-protected-resource` | RFC 9728 | `resource`, `authorization_servers`, `bearer_methods_supported` |
| `GET /.well-known/oauth-authorization-server` | RFC 8414 | `issuer`, the endpoint URLs, and supported response/grant/PKCE/auth methods |
| `GET /.well-known/jwks.json` | — | The RS256 public keys for verifying issued tokens (empty until the first token mint) |
| Path | Spec | Returns |
| --------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `GET /.well-known/oauth-protected-resource` | RFC 9728 | `resource`, `authorization_servers`, `bearer_methods_supported` |
| `GET /.well-known/oauth-authorization-server` | RFC 8414 | `issuer`, the endpoint URLs, and supported response/grant/PKCE/auth methods |
| `GET /.well-known/jwks.json` | — | The signing public keys (RSA and/or EC, per-key `alg`) for verifying issued tokens (empty until the first token mint) |

All three send `Access-Control-Allow-Origin: *` so browser-based clients and
discovery tools can fetch them cross-origin. When `mcp.resource` carries a path
Expand All @@ -175,7 +175,9 @@ form the `WWW-Authenticate` challenge advertises.

### Issued access tokens

RS256 JWT, signed with key id `rs256-default`, carrying:
A signed JWT — RS256 by default, ES256 when configured (`mcp.signingAlgorithm`).
The `kid` header identifies the signing key; resolve it against the published
JWKS rather than assuming a fixed key id. Claims:

| Claim | Value |
| ----------- | --------------------------------------------------------------------- |
Expand Down Expand Up @@ -455,7 +457,7 @@ Point your MCP clients at the same route; they rediscover the endpoints via the

## Signing-key rotation

By default, the plugin generates one RS256 keypair per node on first mint and
By default, the plugin generates one keypair per node (RS256 by default; `mcp.signingAlgorithm: ES256` for EC P-256) on first mint and
Comment thread
heskew marked this conversation as resolved.
keeps it indefinitely. The JWKS endpoint publishes **all** persisted keys, so
tokens signed by any node's key are always verifiable — the cluster first-boot
race is resolved without any manual coordination.
Expand Down Expand Up @@ -703,7 +705,7 @@ of key possession is the only accepted authentication for this grant.
> 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
The issued token is the same signed 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.
Expand Down Expand Up @@ -747,5 +749,5 @@ These are **not** available and no config or code sample here implies them:

- Per-tool / fine-grained scopes (the `scope` claim is passed through, not enforced per tool)
- Transitive revocation (revoking the upstream IdP session does not invalidate already-issued MCP tokens)
- Signing algorithms other than RS256
- Signing algorithms other than RS256 and ES256 (e.g. EdDSA)
- A native, composed MCP server (this plugin is the authorization server, not the MCP transport)
6 changes: 3 additions & 3 deletions schema/oauth.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ type mcp_auth_codes @table(database: "oauth", expiration: 300) {
## MCP JWT signing keys (Stage 4)
## Persisted here (not on disk) so all replicated Harper nodes share one key
## set — file storage would diverge across nodes. No expiration: keys are
## retained for verification until explicitly rotated. RS256 only in v1
## (jsonwebtoken cannot emit EdDSA); the public half is published at
## retained for verification until explicitly rotated. Per-row `alg` (RS256 or
## ES256; EdDSA unsupported — jsonwebtoken cannot emit it); the public half is published at
## /.well-known/jwks.json, the private half never leaves the server.
type harper_oauth_mcp_keys @table(database: "oauth") {
kid: ID @primaryKey
alg: String # "RS256"
alg: String # "RS256" | "ES256"
public_key_pem: String
private_key_pem: String
# Hand-managed (NOT @createdTime): keyStore deliberately mutates created_at
Expand Down
34 changes: 34 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { clearOAuthSession } from './lib/handlers.ts';
import { HookManager } from './lib/hookManager.ts';
import { DynamicProviderCache, DEFAULT_DYNAMIC_PROVIDER_CACHE_TTL_SECONDS } from './lib/dynamicProviderCache.ts';
import { registerWellKnownHandlers } from './lib/mcp/wellKnown.ts';
import { algFromPrivateKeyPem } from './lib/mcp/keyStore.ts';
import { redactSecrets } from './lib/redact.ts';
import type { Scope, OAuthPluginConfig, ProviderRegistry, OAuthHooks } from './types.ts';

Expand Down Expand Up @@ -261,6 +262,39 @@ export async function handleApplication(scope: Scope): Promise<void> {
'Remove mcp.keyRotationInterval if you intend to use a fixed key.'
);
}
// An unrecognized signingAlgorithm silently resolves to the RS256 default
// (resolveConfiguredAlg) — warn so a typo ("es256") isn't a mystery.
if (
mcpConfig?.signingAlgorithm &&
mcpConfig.signingAlgorithm !== 'RS256' &&
mcpConfig.signingAlgorithm !== 'ES256'
) {
logger?.warn?.(
`MCP: mcp.signingAlgorithm "${mcpConfig.signingAlgorithm}" is not supported (use "RS256" or "ES256"); ` +
'falling back to RS256 for generated keys.'
);
}
// A pinned key's algorithm comes from its key material; warn when
// mcp.signingAlgorithm disagrees so the operator isn't surprised the
// config value is ignored. An unparseable/unsupported pin only warns
// here — startup stays up for the human-OAuth flows; the mint path
// throws when the pin is actually used.
if (mcpConfig?.signingKeyPem) {
try {
const pinnedAlg = algFromPrivateKeyPem(mcpConfig.signingKeyPem);
if (mcpConfig.signingAlgorithm && pinnedAlg !== mcpConfig.signingAlgorithm) {
logger?.warn?.(
`MCP: mcp.signingAlgorithm is "${mcpConfig.signingAlgorithm}" but the pinned mcp.signingKeyPem ` +
`is a ${pinnedAlg} key. The pinned key's algorithm (${pinnedAlg}) is used.`
);
}
} catch (error) {
logger?.warn?.(
'MCP: mcp.signingKeyPem is not a supported signing key (RSA or EC P-256):',
error instanceof Error ? error.message : String(error)
);
}
}

// Re-initialize providers from new configuration
// Clear existing providers and repopulate (don't reassign to preserve closure reference)
Expand Down
Loading
Loading