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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,33 @@ When MCP OAuth is enabled (see [issue #86](https://github.com/HarperFast/oauth/i

The plugin can also act as an OAuth authorization server for **Model Context Protocol** clients (Claude Desktop, Cursor, `mcp-remote`), letting them authenticate against the same upstream providers. Enable it with `mcp.enabled: true` (see [`docs/configuration.md`](./docs/configuration.md)).

**Status: experimental, opt-in.** Available now: RFC 7591 Dynamic Client Registration, discovery metadata (`/.well-known/*`), the authorization endpoint, and audience-bound JWT token issuance. Token _verification_ for app-owned MCP routes (`withMCPAuth`) and the remaining pieces land in 2.0.x — see [issue #86](https://github.com/HarperFast/oauth/issues/86).
**Status: experimental, opt-in.** Available now: RFC 7591 Dynamic Client Registration, discovery metadata (`/.well-known/*`), the authorization endpoint, audience-bound JWT token issuance, and token _verification_ for app-owned MCP routes (`withMCPAuth`, below). The remaining pieces land in 2.0.x — see [issue #86](https://github.com/HarperFast/oauth/issues/86).

### Protecting an MCP route — `withMCPAuth`

The plugin doesn't own the MCP endpoint — your app does. `withMCPAuth` wraps your handler so it enforces the spec contract once, centrally: it validates the `Authorization: Bearer` access token (signature against the published JWKS, `exp`/`nbf`, and audience binding to `mcp.resource` per RFC 8707), and on any failure returns `401` with `WWW-Authenticate: Bearer resource_metadata="…"` (RFC 9728) pointing MCP clients at discovery. On success it attaches the verified claims as `request.mcp = { sub, client_id, aud, scope }` and invokes your handler unchanged.

```ts
import { server } from 'harper';
import { withMCPAuth } from '@harperfast/oauth';

const mcpHandler = (request) => {
// request.mcp is guaranteed present here: { sub, client_id, aud, scope }
return { status: 200, body: JSON.stringify({ user: request.mcp.sub }) };
};

// Recommended: register on a urlPath subroute.
server.http(withMCPAuth(mcpHandler), { urlPath: '/mcp' });
```

**Registration matters — Harper's core auth will otherwise reject the token.** Core auth consumes `Authorization: Bearer` and rejects any non-Harper token with `WWW-Authenticate: Basic`, which breaks the MCP discovery loop. Register `withMCPAuth` so it owns the response for its route:

- **urlPath subroute (recommended):** `server.http(withMCPAuth(handler), { urlPath: '/mcp' })`. Harper routes the request to this chain alone, so core auth never runs for it — the same isolation the `/.well-known/*` endpoints use. No extra options needed.
- **Default-group fallback:** `server.http(withMCPAuth(handler, { path: '/mcp' }), { before: 'authentication' })`. When the route shares the default middleware chain with auth, pass `path` (so the guard scopes to your route and lets other paths fall through) and register `before: 'authentication'` so it runs ahead of core auth.

`withMCPAuth(handler, options?)` options: `path` (default-group scoping, above), `onAuthError(request, reason)` (custom denial response — a falsy return still fails closed to the default `401`), plus `getConfig` / `logger` / `keyStore` overrides (default to the plugin's live MCP config, logger, and key store). The guard fails closed: while MCP is disabled or no signing key has been published, every request is rejected. Query-string tokens are ignored (header-only, RFC 6750).

If your MCP route lives in a **different component** than the one declaring `@harperfast/oauth`, inject `getConfig` (the consumer's copy of `OAuthResource.mcpConfig` is unpopulated) — signing keys still resolve from the shared `oauth` database. See [`docs/configuration.md`](./docs/configuration.md).

## Security Considerations

Expand Down
66 changes: 65 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Each provider requires:

### MCP OAuth (work in progress)

Opt-in support for the Model Context Protocol authorization flow ([issue #86](https://github.com/HarperFast/oauth/issues/86)). Implemented so far: Dynamic Client Registration at `POST /oauth/mcp/register` (RFC 7591), the discovery documents under `/.well-known/*` (RFCs 8414, 9728) so MCP clients (Claude Desktop, Cursor, `mcp-remote`) can find and register themselves, and the authorization endpoint `GET /oauth/mcp/authorize` (OAuth 2.1 + PKCE-S256). The `/token` exchange and the `withMCPAuth` wrapper land in subsequent releases.
Opt-in support for the Model Context Protocol authorization flow ([issue #86](https://github.com/HarperFast/oauth/issues/86)). Implemented so far: Dynamic Client Registration at `POST /oauth/mcp/register` (RFC 7591), the discovery documents under `/.well-known/*` (RFCs 8414, 9728) so MCP clients (Claude Desktop, Cursor, `mcp-remote`) can find and register themselves, the authorization endpoint `GET /oauth/mcp/authorize` (OAuth 2.1 + PKCE-S256), the `POST /oauth/mcp/token` exchange (audience-bound RS256 JWTs), and the `withMCPAuth` route guard (below) that verifies those tokens on your MCP endpoint.

```yaml
'@harperfast/oauth':
Expand Down Expand Up @@ -104,6 +104,70 @@ Sensitive leaves inside `mcp` support `${ENV_VAR}` expansion (e.g., `initialAcce

All three documents include `Access-Control-Allow-Origin: *` so browser-based MCP clients and discovery tools can fetch them cross-origin.

#### Protecting your MCP route — `withMCPAuth`

The plugin issues and verifies tokens, but your app owns the MCP endpoint. Wrap your handler with `withMCPAuth` to enforce the spec contract on it:

```ts
import { server } from 'harper';
import { withMCPAuth } from '@harperfast/oauth';

const mcpHandler = (request) => {
// On success, the verified claims are attached: { sub, client_id, aud, scope }
return { status: 200, body: JSON.stringify({ user: request.mcp.sub }) };
};

// Recommended registration — a urlPath subroute (see "Registration", below):
server.http(withMCPAuth(mcpHandler), { urlPath: '/mcp' });
```

What it enforces on every request, failing closed with `401 + WWW-Authenticate: Bearer resource_metadata="<PRM URL>"`. The PRM URL is the RFC 9728 §3.1 location for the configured `mcp.resource` — `<resource-origin>/.well-known/oauth-protected-resource` with the resource's path appended when it has one (e.g. `https://app.example.com/.well-known/oauth-protected-resource/mcp` for resource `https://app.example.com/mcp`), matching exactly what the well-known handler serves:

- Bearer token present in the `Authorization` header (header-only; query-string tokens are ignored, RFC 6750).
- Valid RS256 signature against the published JWKS, selecting the key by the token's `kid`.
- `exp`/`nbf` within bounds, and `aud` equal to `mcp.resource` (audience binding, RFC 8707).
- MCP enabled and a signing key published — while either is missing, all requests are rejected.

On success it sets `request.mcp = { sub, client_id, aud, scope }` and calls your handler, returning its response unchanged.

**Registration.** Harper's core auth is a default-group middleware that consumes `Authorization: Bearer` and rejects any non-Harper token with `WWW-Authenticate: Basic` — which would break MCP discovery. Register `withMCPAuth` so it owns the response for its route:

- **urlPath subroute (recommended):** `server.http(withMCPAuth(handler), { urlPath: '/mcp' })`. Harper's routed dispatch runs only this chain, so core auth never runs for the route — the same isolation the `/.well-known/*` endpoints rely on. No extra options needed.
- **Default-group fallback:** `server.http(withMCPAuth(handler, { path: '/mcp' }), { before: 'authentication' })`. When the route shares the default chain with auth, pass `path` (the guard then scopes to that path and calls `next()` for everything else) and register `before: 'authentication'` so it runs ahead of core auth. In this mode the wrapped handler must terminate the request rather than call `next`, or core auth runs afterward and re-rejects the token.

**Options** — `withMCPAuth(handler, options?)`:

| Option | Type | Default | Description |
| ------------- | ------------------------------ | ---------------------- | -------------------------------------------------------------------------------------------------- |
| `path` | string | (none) | Default-group scoping only — the path this guard owns. Omit for the urlPath-subroute registration. |
| `onAuthError` | `(request, reason) => any` | (none) | Custom denial response. A falsy return still falls back to the default `401` (fail closed). |
| `getConfig` | `() => MCPConfig \| undefined` | live plugin MCP config | Override the MCP config source (read per request). |
| `logger` | Logger | plugin logger | Override the logger. |
| `keyStore` | `{ getAllPublicKeys() }` | plugin `MCPKeyStore` | Override the signing-key source (used by tests). |

##### Using `withMCPAuth` from a different component than the plugin

By default `withMCPAuth` reads the live MCP config from the OAuth plugin via `OAuthResource.mcpConfig`. That works when the component that **declares** `@harperfast/oauth` is the same one that exposes the MCP route. If your MCP tools live in a **separate** component (it imports `withMCPAuth` as a function but doesn't declare the plugin in its own `config.yaml`), that consumer resolves its **own** `node_modules` copy of the package, where `OAuthResource.mcpConfig` is a module-local static that is never populated — so it reads as `undefined` and the guard fails closed.

In that setup, **inject `getConfig`** so the wrapper sees the config:

```ts
server.http(
withMCPAuth(mcpHandler, {
// Pin issuer/resource to the values the plugin component issues tokens with,
// so the iss/aud checks match the minted tokens.
getConfig: () => ({
enabled: true,
issuer: 'https://my-app.example.com',
resource: 'https://my-app.example.com/mcp',
}),
}),
{ urlPath: '/mcp' }
);
```

Signing keys need no extra wiring: the default `MCPKeyStore` reads `databases.oauth.harper_oauth_mcp_keys`, which is cluster-global, so the consumer verifies against the same JWKS the plugin component mints with. Importing `withMCPAuth` as a function does **not** spin up a second plugin instance.

## Environment Variables

All configuration options can be set via environment variables:
Expand Down
37 changes: 37 additions & 0 deletions integrationTests/fixtures/mcp-auth-app/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Integration-test fixture for withMCPAuth (Stage 5).
#
# Loads the OAuth plugin with MCP enabled and a pinned issuer/resource, plus an
# app JS file (mcp-app.js) that registers an MCP route guarded by withMCPAuth in
# BOTH supported registration models:
# - /mcp via urlPath subroute (Harper's routed dispatch isolates it from
# the default-group core-auth middleware)
# - /mcp-dg via a default-group middleware ordered `before: authentication`
#
# The test asserts both routes answer an unauthenticated (or non-Harper-bearer)
# request with the spec `401 + WWW-Authenticate: Bearer resource_metadata="..."`
# — proving core auth never clobbers it with its `WWW-Authenticate: Basic`.
#
# A provider is configured so OAuthResource.configure() runs and the pinned
# mcp.issuer reaches OAuthResource.mcpConfig (withMCPAuth's default config
# source); without any provider the plugin registers an error resource and never
# calls configure(), leaving mcpConfig undefined.

rest: true

'@harperfast/oauth':
package: '@harperfast/oauth'
providers:
test:
provider: generic
clientId: test-client
clientSecret: test-secret
authorizationUrl: 'http://idp.test/authorize'
tokenUrl: 'http://idp.test/token'
userInfoUrl: 'http://idp.test/userinfo'
mcp:
enabled: true
issuer: 'https://mcp.test'
resource: 'https://mcp.test/mcp'

jsResource:
files: 'mcp-app.js'
27 changes: 27 additions & 0 deletions integrationTests/fixtures/mcp-auth-app/mcp-app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* App code (loaded via the built-in `jsResource` handler) that registers an
* app-owned MCP route guarded by withMCPAuth, in both registration models the
* wrapper supports. Route registration happens as an import side effect using
* the global `server` exported by Harper — `scope.server` is just a
* name-injecting Proxy over this same object, and our registration does not
* depend on the injected component name.
*/
import { server } from 'harper';
import { withMCPAuth } from '@harperfast/oauth';

// Echoes the verified MCP subject (null when unauthenticated requests are
// rejected before reaching here) so an authenticated test can assert on it.
const mcpHandler = (request) => ({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ok: true, sub: request.mcp?.sub ?? null }),
});

// PRIMARY — urlPath subroute. Harper's routed dispatch runs only this chain, so
// core auth (a default-group middleware) never runs for /mcp.
server.http(withMCPAuth(mcpHandler), { urlPath: '/mcp' });

// FALLBACK — default-group middleware ordered ahead of core auth. `path` scopes
// the guard to /mcp-dg (other routes fall through to auth); `before:
// 'authentication'` makes it run outermost.
server.http(withMCPAuth(mcpHandler, { path: '/mcp-dg' }), { before: 'authentication' });
6 changes: 6 additions & 0 deletions integrationTests/fixtures/mcp-auth-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "mcp-auth-app-fixture",
"private": true,
"type": "module",
"description": "Integration-test fixture for withMCPAuth (Stage 5). The @harperfast/oauth dep is installed at test setup time via scripts/install-fixtures.js (npm pack + install) so the plugin lives as real files under node_modules — Harper's default VM sandbox rejects symlinked file: deps. harper is resolved from the repo root."
}
71 changes: 71 additions & 0 deletions integrationTests/mcp-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* End-to-end proof that withMCPAuth's spec response survives Harper's core
* auth. Harper's core auth is a default-group HTTP middleware that consumes
* `Authorization: Bearer` and 401s any token it can't validate as a Harper
* operation token — stamping `WWW-Authenticate: Basic`, NOT the Bearer
* challenge MCP clients require. This test registers withMCPAuth in both
* supported models and asserts the FINAL response carries
* `WWW-Authenticate: Bearer resource_metadata="..."`:
*
* - /mcp (urlPath subroute) — routed dispatch isolates the chain, so core
* auth never runs for it.
* - /mcp-dg (default group, `before: authentication`) — withMCPAuth runs
* outermost and short-circuits before core auth.
*
* The discriminating case is a request bearing a *non-Harper* bearer token: a
* `Basic` challenge in the response would mean core auth ran first and won.
*/
import { suite, test, before, after } from 'node:test';
import { strictEqual, ok, match } from 'node:assert/strict';
import { join, dirname } from 'node:path';
import { createRequire } from 'node:module';
import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';

const require = createRequire(import.meta.url);

function getHarperBinPath(): string {
return join(dirname(require.resolve('harper')), 'bin', 'harper.js');
}

const fixturePath = join(import.meta.dirname, 'fixtures', 'mcp-auth-app');
// RFC 9728 §3.1 path-appended form (resource is https://mcp.test/mcp), which #133
// now serves and withMCPAuth points the challenge at.
const PRM_URL = 'https://mcp.test/.well-known/oauth-protected-resource/mcp';

suite('withMCPAuth: 401 + WWW-Authenticate: Bearer survives core auth', (ctx: ContextWithHarper) => {
before(async () => {
await setupHarperWithFixture(ctx, fixturePath, {
harperBinPath: getHarperBinPath(),
config: { logging: { stdStreams: true } },
});
});

after(async () => {
await teardownHarper(ctx);
});

// Both registration models must produce the same spec-compliant challenge.
for (const path of ['/mcp', '/mcp-dg']) {
test(`${path}: unauthenticated request → 401 + Bearer PRM challenge`, async () => {
const res = await fetch(`${ctx.harper.httpURL}${path}`, { method: 'POST' });
strictEqual(res.status, 401, `expected 401, got ${res.status}`);
const wa = res.headers.get('www-authenticate');
ok(wa, 'WWW-Authenticate header present');
match(wa!, /^Bearer /, `expected a Bearer challenge, got: ${wa}`);
ok(wa!.includes(`resource_metadata="${PRM_URL}"`), `challenge must point at the PRM URL; got: ${wa}`);
});

test(`${path}: non-Harper bearer token → still Bearer (core auth did not win)`, async () => {
const res = await fetch(`${ctx.harper.httpURL}${path}`, {
method: 'POST',
headers: { authorization: 'Bearer not-a-real-mcp-token' },
});
strictEqual(res.status, 401, `expected 401, got ${res.status}`);
const wa = res.headers.get('www-authenticate');
ok(
wa?.startsWith('Bearer '),
`expected Bearer (a 'Basic' challenge would mean core auth handled the token first); got: ${wa}`
);
});
}
});
14 changes: 13 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export { getProvider } from './lib/providers/index.ts';
export { withOAuthValidation, getOAuthProviders } from './lib/withOAuthValidation.ts';
export type { OAuthValidationOptions } from './lib/withOAuthValidation.ts';

// Export MCP bearer-token guard (Stage 5) for app-owned MCP routes
export { withMCPAuth } from './lib/mcp/withMCPAuth.ts';
export type { WithMCPAuthOptions } from './lib/mcp/withMCPAuth.ts';
export type { MCPRequestClaims } from './types.ts';

// Store hooks registered at module load time and active hookManager
let pendingHooks: OAuthHooks | null = null;
let activeHookManager: HookManager | null = null;
Expand Down Expand Up @@ -192,7 +197,14 @@ export async function handleApplication(scope: Scope): Promise<void> {

// Update the resource with new providers
if (Object.keys(providers).length === 0) {
// No valid providers configured - register a simple error resource
// No valid providers configured - register a simple error resource.
// Fail closed: clear the MCP config static too. OAuthResource.configure()
// (the only other writer) runs only in the else branch, so without this a
// live reload that drops all providers would leave the PREVIOUS mcpConfig in
// place — and withMCPAuth's default getter (and the well-known handlers) read
// that static, so they'd keep verifying tokens / serving discovery against
// stale config after the plugin is no longer validly configured.
OAuthResource.mcpConfig = undefined;
resources.set('oauth', {
async get() {
return {
Expand Down
5 changes: 4 additions & 1 deletion src/lib/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export { handleRegister } from './dcr.ts';
export { MCPKeyStore, resetMCPKeysTableCache, SIGNING_KEY_ID } from './keyStore.ts';
export { MCPRefreshFamilyStore, resetMCPRefreshFamiliesTableCache } from './refreshTokenStore.ts';
export { handleToken } from './token.ts';
export { publicKeyToJwk, signAccessToken, verifyAccessToken } from './tokenIssuer.ts';
export { publicKeyToJwk, signAccessToken, verifyAccessToken, verifyAccessTokenWithKeySet } from './tokenIssuer.ts';
export type { VerifyWithKeySetOptions } from './tokenIssuer.ts';
export { withMCPAuth } from './withMCPAuth.ts';
export type { WithMCPAuthOptions } from './withMCPAuth.ts';

/**
* Dispatch POST /oauth/mcp/<action>.
Expand Down
Loading
Loading