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
34 changes: 27 additions & 7 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,20 @@ 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)). The first stage adds Dynamic Client Registration at `POST /oauth/mcp/register` (RFC 7591) so MCP clients (Claude Desktop, Cursor, `mcp-remote`) can register themselves at runtime. `/authorize`, `/token`, 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)). The first two stages add Dynamic Client Registration at `POST /oauth/mcp/register` (RFC 7591) and the discovery documents under `/.well-known/*` (RFCs 8414, 9728) so MCP clients (Claude Desktop, Cursor, `mcp-remote`) can find and register themselves. `/authorize`, `/token`, and the `withMCPAuth` wrapper land in subsequent releases.

```yaml
'@harperfast/oauth':
mcp:
enabled: true
# Pin the authorization-server identity. STRONGLY recommended in
# production — without this we derive issuer from the request's Host
# header, which a client controls. When tokens are signed (a later
# release), trusting Host would let an attacker influence `iss` claims.
issuer: https://my-app.example.com
# Canonical resource URI advertised in PRM and used as the `aud` claim
# on issued tokens (RFC 8707). Defaults to `<issuer>/mcp` when unset.
resource: https://my-app.example.com/mcp
dynamicClientRegistration:
# Optional: require Authorization: Bearer <token> on /register.
# Without this, registration is OPEN per RFC 7591 — anyone can register.
Expand All @@ -72,15 +80,27 @@ Opt-in support for the Model Context Protocol authorization flow ([issue #86](ht
- app.example.com
```

| Option | Type | Default | Description |
| ------------------------------------------------------- | -------- | ------- | ------------------------------------------------------------------------------------------ |
| `mcp.enabled` | boolean | `false` | Master switch for the MCP OAuth endpoints |
| `mcp.dynamicClientRegistration.enabled` | boolean | `true` | Enable the `/register` endpoint when `mcp.enabled` is true |
| `mcp.dynamicClientRegistration.initialAccessToken` | string | (none) | If set, registration requires `Authorization: Bearer <token>`. Otherwise open per RFC 7591 |
| `mcp.dynamicClientRegistration.allowedRedirectUriHosts` | string[] | (none) | Allowlist for redirect_uri hosts. Localhost always allowed per RFC 8252 |
| Option | Type | Default | Description |
| ------------------------------------------------------- | -------- | ---------------------- | ------------------------------------------------------------------------------------------------------------ |
| `mcp.enabled` | boolean | `false` | Master switch for the MCP OAuth endpoints |
| `mcp.issuer` | string | (derived from request) | Authorization-server URI advertised in AS metadata. Pin in production to prevent Host-header-driven identity |
| `mcp.resource` | string | `<issuer>/mcp` | Canonical resource URI advertised in PRM (RFC 9728) and validated as `aud` on issued tokens (RFC 8707) |
| `mcp.dynamicClientRegistration.enabled` | boolean | `true` | Enable the `/register` endpoint when `mcp.enabled` is true |
| `mcp.dynamicClientRegistration.initialAccessToken` | string | (none) | If set, registration requires `Authorization: Bearer <token>`. Otherwise open per RFC 7591 |
| `mcp.dynamicClientRegistration.allowedRedirectUriHosts` | string[] | (none) | Allowlist for redirect_uri hosts. Localhost always allowed per RFC 8252 |

Sensitive leaves inside `mcp` support `${ENV_VAR}` expansion (e.g., `initialAccessToken: ${OAUTH_MCP_REGISTRATION_TOKEN}`), the same way provider credentials do.

**Discovery endpoints** (served when `mcp.enabled: true`):

| Path | Spec | Purpose |
| ----------------------------------------- | -------- | ------------------------------------------------------------------------------------ |
| `/.well-known/oauth-protected-resource` | RFC 9728 | Tells MCP clients where to find the authorization server |
| `/.well-known/oauth-authorization-server` | RFC 8414 | Advertises authorize / token / register / JWKS endpoints and supported methods |
| `/.well-known/jwks.json` | — | Public keys for verifying issued JWTs (returns an empty key set until signing lands) |

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

## Environment Variables

All configuration options can be set via environment variables:
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { validateAndRefreshSession } from './lib/sessionValidator.ts';
import { clearOAuthSession } from './lib/handlers.ts';
import { HookManager } from './lib/hookManager.ts';
import { DynamicProviderCache } from './lib/dynamicProviderCache.ts';
import { registerWellKnownHandlers } from './lib/mcp/wellKnown.ts';
import type { Scope, OAuthPluginConfig, ProviderRegistry, OAuthHooks } from './types.ts';

// Export HookManager class, OAuthResource class, and types
Expand Down Expand Up @@ -299,6 +300,12 @@ export async function handleApplication(scope: Scope): Promise<void> {
// Initial configuration (errors propagate to plugin loader)
await updateConfiguration();

// Register MCP well-known metadata endpoints once. The handlers read the
// current mcpConfig from OAuthResource at request time, so live config
// changes apply without re-registering routes (Harper's server.http does
// not support deregistration).
registerWellKnownHandlers(server, () => OAuthResource.mcpConfig, logger);

// Watch for configuration changes (errors caught internally)
scope.options.on('change', () => {
runUpdate().catch((error) => {
Expand Down
194 changes: 194 additions & 0 deletions src/lib/mcp/wellKnown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* MCP Well-Known Metadata Endpoints
*
* Mounts three discovery documents under `/.well-known/*` for MCP clients:
*
* - `/.well-known/oauth-protected-resource` — RFC 9728 Protected Resource
* Metadata. The entry point: an MCP client that hits the protected resource
* without credentials receives a `401 + WWW-Authenticate: Bearer
* resource_metadata="..."` (issued by withMCPAuth, Stage 5) pointing here,
* then fetches this document to discover the authorization server.
* - `/.well-known/oauth-authorization-server` — RFC 8414 Authorization Server
* Metadata. Advertises authorize / token / register / JWKS endpoints and
* the supported algorithms / methods / response types.
* - `/.well-known/jwks.json` — Public key set used to verify issued JWTs.
* Returns an empty key set until Stage 4 of issue #86 lands key material in
* the `harper_oauth_mcp_keys` table.
*
* Endpoints are registered through `server.http(handler, { urlPath })` so
* routing works for `.well-known` paths that don't fit Harper's Resource API.
* Harper's urlPath matching is prefix-based (segment-boundary aware), so each
* handler also checks the exact path before responding — sub-paths like
* `/.well-known/oauth-authorization-server/foo` fall through to 404.
*/

import type { Logger, MCPConfig } from '../../types.ts';

interface HarperRequest {
pathname?: string;
protocol?: string;
host?: string;
headers?: Record<string, any> & { host?: string };
}

type HttpResponse = {
status: number;
headers: Record<string, string>;
body: string;
};

const PRM_PATH = '/.well-known/oauth-protected-resource';
const AS_METADATA_PATH = '/.well-known/oauth-authorization-server';
const JWKS_PATH = '/.well-known/jwks.json';

function jsonResponse(body: unknown, status = 200): HttpResponse {
return {
status,
headers: {
'Content-Type': 'application/json',
// Discovery documents are unauthenticated and intended for cross-origin
// fetches — browser-based MCP clients and inspectors won't be able to
// read them without CORS. Simple `*` is sufficient because no
// credentials are involved.
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
},
body: JSON.stringify(body),
};
}

/**
* Resolve the issuer (authorization-server origin). Configured value wins;
* otherwise derive from the request — scheme + host.
*
* Security note: the request-derived path trusts the Host header, which a
* client controls. For Stage 2 (metadata-only) this is self-defeating — an
* attacker who spoofs Host gets back metadata describing their own origin.
* Stage 4 will sign JWTs with `iss`; at that point `mcp.issuer` MUST be
* pinned at startup (or derived from a fixed config) to prevent attacker-
* controlled `iss` claims. Production deployments should set `mcp.issuer`
* explicitly regardless. Documented in docs/configuration.md.
*/
export function resolveIssuer(request: HarperRequest, mcpConfig: MCPConfig): string {
if (mcpConfig.issuer) return mcpConfig.issuer;
const host = request.host ?? request.headers?.host ?? 'localhost';
const scheme = request.protocol ?? 'https';
return `${scheme}://${host}`;
}

/**
* Resolve the canonical resource URI (the MCP endpoint clients talk to).
* Configured value wins; otherwise derive as `<issuer>/mcp`.
*/
export function resolveResource(request: HarperRequest, mcpConfig: MCPConfig): string {
if (mcpConfig.resource) return mcpConfig.resource;
return `${resolveIssuer(request, mcpConfig)}/mcp`;
}

/**
* RFC 9728 Protected Resource Metadata document.
*/
export function buildProtectedResourceMetadata(request: HarperRequest, mcpConfig: MCPConfig): Record<string, unknown> {
const resource = resolveResource(request, mcpConfig);
const issuer = resolveIssuer(request, mcpConfig);
return {
resource,
authorization_servers: [issuer],
bearer_methods_supported: ['header'],
};
}

/**
* RFC 8414 Authorization Server Metadata document.
* Advertises the spec-required fields for the MCP authorization spec 2025-06-18.
*/
export function buildAuthorizationServerMetadata(
request: HarperRequest,
mcpConfig: MCPConfig
): Record<string, unknown> {
const issuer = resolveIssuer(request, mcpConfig);
return {
issuer,
authorization_endpoint: `${issuer}/oauth/mcp/authorize`,
token_endpoint: `${issuer}/oauth/mcp/token`,
registration_endpoint: `${issuer}/oauth/mcp/register`,
jwks_uri: `${issuer}${JWKS_PATH}`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
// Advertise both signing algorithms; the concrete key is selected at
// token-mint time (Stage 4). Empty until that lands; clients see this
// advertisement and the empty JWKS together for now.
id_token_signing_alg_values_supported: ['RS256', 'EdDSA'],
// RFC 8707 §2: server understands the `resource` parameter.
resource_parameter_supported: true,
// CORS-friendly metadata is the spec norm; we serve cross-origin reads.
// (No spec-mandated field for this — included as a hint for proxy configs.)
};
}

/**
* JWKS document. Placeholder until Stage 4 generates and persists signing keys
* in the `harper_oauth_mcp_keys` table.
*/
export function buildJWKS(_mcpConfig: MCPConfig): Record<string, unknown> {
return { keys: [] };
}

type WellKnownMatch = {
exactPath: string;
build: (request: HarperRequest, mcpConfig: MCPConfig) => Record<string, unknown>;
};

const HANDLERS: WellKnownMatch[] = [
{ exactPath: PRM_PATH, build: buildProtectedResourceMetadata },
{ exactPath: AS_METADATA_PATH, build: buildAuthorizationServerMetadata },
{ exactPath: JWKS_PATH, build: (_req, cfg) => buildJWKS(cfg) },
];

/**
* Make a Harper http() middleware bound to one well-known path.
*
* - Falls through (calls `next`) when MCP is disabled or the request doesn't
* match the exact path (urlPath matching is prefix-based, so sub-paths
* reach this handler and we must reject them here).
* - Reads `getConfig()` at request time so live config changes take effect
* without re-registering the route.
*/
function makeHandler(
match: WellKnownMatch,
getConfig: () => MCPConfig | undefined,
logger?: Logger
): (req: HarperRequest, next: (r: HarperRequest) => any) => any {
return (req, next) => {
const cfg = getConfig();
if (!cfg?.enabled) return next(req);
if (req.pathname !== match.exactPath) return next(req);
try {
return jsonResponse(match.build(req, cfg));
} catch (error) {
logger?.error?.(`MCP well-known handler ${match.exactPath} failed:`, (error as Error).message);
return jsonResponse({ error: 'server_error' }, 500);
}
};
}

/**
* Register all MCP well-known handlers against the Harper server.
*
* `getConfig` is a getter so middleware sees the current config on each
* request — the OAuth plugin re-initializes its config block on options
* change, and we want those changes to apply without re-registering routes
* (Harper's server.http() does not support deregistration).
*/
export function registerWellKnownHandlers(server: any, getConfig: () => MCPConfig | undefined, logger?: Logger): void {
if (typeof server?.http !== 'function') {
logger?.warn?.('MCP well-known: server.http() not available; skipping route registration');
return;
}
for (const match of HANDLERS) {
server.http(makeHandler(match, getConfig, logger), { urlPath: match.exactPath });
}
logger?.debug?.('MCP well-known handlers registered');
}
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ export interface OAuthPluginConfig {
export interface MCPConfig {
/** Master switch for MCP OAuth endpoints */
enabled?: boolean;
/**
* Canonical resource URI advertised in PRM and validated as the `aud` claim
* on issued tokens (RFC 8707). Defaults to `<request-origin>/mcp` when unset.
*/
resource?: string;
/**
* Authorization-server issuer URI advertised in AS metadata. Defaults to
* the request origin (scheme + host) when unset.
*/
issuer?: string;
/** Dynamic Client Registration settings (RFC 7591) */
dynamicClientRegistration?: MCPDynamicClientRegistrationConfig;
}
Expand Down
Loading
Loading