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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,13 +186,16 @@ type CSRFToken @table {

Tokens automatically expire after 10 minutes.

When MCP OAuth is enabled (see [issue #86](https://github.com/HarperFast/oauth/issues/86)), the plugin also creates a `harper_oauth_mcp_clients` table for RFC 7591 Dynamic Client Registration. Registrations persist indefinitely so `client_id`s cached by MCP clients (Claude Desktop, Cursor, `mcp-remote`) survive Harper restarts.

## Security Considerations

- **HTTPS required** - OAuth requires HTTPS in production
- **CSRF protection** - Automatic via state parameter validation
- **ID token verification** - OIDC providers verify token signatures
- **Secure sessions** - Use Harper's secure session configuration
- **Token storage** - Tokens stored in session (configure secure cookies)
- **MCP client registration (when `mcp.enabled` is true)** - The `/oauth/mcp/register` endpoint defaults to **open registration** per RFC 7591. Set `mcp.dynamicClientRegistration.initialAccessToken` to require a bearer token on registration, or `mcp.dynamicClientRegistration.allowedRedirectUriHosts` to restrict which hosts may register `redirect_uri`s. See [`docs/configuration.md`](./docs/configuration.md).

## Debug Mode

Expand Down
27 changes: 27 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Complete configuration options for the `@harperfast/oauth` plugin.
| `redirectUri` | string | (auto-gen) | OAuth callback URL where providers redirect back to |
| `postLoginRedirect` | string | `/` | Default URL to redirect users after successful OAuth login |
| `cacheDynamicProviders` | boolean \| number | `true` | Cache providers resolved via `onResolveProvider` hook. `true` = forever, `false` = never, number = TTL in seconds |
| `mcp` | object | (off) | MCP OAuth flow configuration. See [MCP OAuth](#mcp-oauth-work-in-progress) below |

### Provider Configuration

Expand Down Expand Up @@ -54,6 +55,32 @@ Each provider requires:
- `userInfoUrl` - User info endpoint URL (required)
- `jwksUrl` - JWKS endpoint URL (required for ID token verification)

### 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.

```yaml
'@harperfast/oauth':
mcp:
enabled: true
dynamicClientRegistration:
# Optional: require Authorization: Bearer <token> on /register.
# Without this, registration is OPEN per RFC 7591 — anyone can register.
initialAccessToken: ${OAUTH_MCP_REGISTRATION_TOKEN}
# Optional: restrict redirect_uri hosts (localhost always allowed).
allowedRedirectUriHosts:
- 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 |

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

## Environment Variables

All configuration options can be set via environment variables:
Expand Down
25 changes: 25 additions & 0 deletions schema/oauth.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,31 @@ type csrf_tokens @table(database: "oauth", expiration: 600) {
created_at: Float
}

## MCP Dynamic Client Registration table (RFC 7591)
## Stores OAuth client registrations for MCP clients (Claude Desktop, Cursor,
## mcp-remote, etc.). No expiration: clients like Claude Desktop cache their
## issued client_id across launches, so registrations must survive restarts.
## Array-valued fields (redirect_uris, contacts, grant_types, response_types)
## are stored as JSON-encoded strings to match the existing csrf_tokens pattern.
type harper_oauth_mcp_clients @table(database: "oauth") {
client_id: ID @primaryKey
client_secret: String
redirect_uris: String # JSON array of allowed redirect URIs
client_name: String
client_uri: String
logo_uri: String
scope: String
contacts: String # JSON array
grant_types: String # JSON array; default ["authorization_code", "refresh_token"]
response_types: String # JSON array; default ["code"]
token_endpoint_auth_method: String # "none" for public clients (default), "client_secret_basic", "client_secret_post"
application_type: String # "web" (default) or "native"
software_id: String
software_version: String
client_id_issued_at: Float
client_secret_expires_at: Float # 0 = never expires
}

## OAuth User Session table (optional, for future use)
## Could store OAuth-specific user data
# type oauth_sessions @table(database: "oauth") {
Expand Down
17 changes: 14 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Supports any standard OAuth 2.0 provider through configuration.
*/

import { initializeProviders, expandEnvVar, extractPluginDefaults } from './lib/config.ts';
import { initializeProviders, expandEnvVar, expandEnvVarsDeep, extractPluginDefaults } from './lib/config.ts';
import { OAuthResource } from './lib/resource.ts';
import { validateAndRefreshSession } from './lib/sessionValidator.ts';
import { clearOAuthSession } from './lib/handlers.ts';
Expand Down Expand Up @@ -175,8 +175,19 @@ export async function handleApplication(scope: Scope): Promise<void> {
},
});
} else {
// Configure the OAuth resource with providers and settings
OAuthResource.configure(providers, debugMode, hookManager, pluginDefaults, logger, dynamicProviderCache);
// Configure the OAuth resource with providers and settings.
// expandEnvVarsDeep on the mcp block so sensitive leaves like
// mcp.dynamicClientRegistration.initialAccessToken support ${ENV_VAR}.
const mcpConfig = options.mcp ? expandEnvVarsDeep(options.mcp) : undefined;
OAuthResource.configure(
providers,
debugMode,
hookManager,
pluginDefaults,
logger,
dynamicProviderCache,
mcpConfig
);

// Register the OAuth resource class
resources.set('oauth', OAuthResource);
Expand Down
30 changes: 28 additions & 2 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ export function expandEnvVar(value: any): any {
return value;
}

/**
* Recursively expand `${ENV_VAR}` placeholders on every string leaf of a value.
*
* Used for structured config blocks (like `mcp`) where sensitive leaves
* (e.g., `mcp.dynamicClientRegistration.initialAccessToken`) still need
* env-var expansion but the block itself isn't a flat property bag.
*/
export function expandEnvVarsDeep<T>(value: T): T {
if (typeof value === 'string') {
return expandEnvVar(value);
}
if (Array.isArray(value)) {
return value.map(expandEnvVarsDeep) as unknown as T;
}
if (value !== null && typeof value === 'object') {
const expanded: Record<string, any> = {};
for (const [key, item] of Object.entries(value as Record<string, any>)) {
expanded[key] = expandEnvVarsDeep(item);
}
return expanded as T;
}
return value;
}

/**
* Build configuration for a specific provider
*/
Expand Down Expand Up @@ -117,9 +141,11 @@ export function buildProviderConfig(
export function extractPluginDefaults(options: OAuthPluginConfig): Partial<OAuthProviderConfig> {
const pluginDefaults: Partial<OAuthProviderConfig> = {};

// Copy all non-provider config to defaults, expanding environment variables
// Copy all non-provider config to defaults, expanding environment variables.
// `mcp` is a structured top-level config block (not a provider-level default)
// so it's excluded; it's threaded through OAuthResource.configure separately.
for (const [key, value] of Object.entries(options)) {
if (key !== 'providers' && key !== 'debug') {
if (key !== 'providers' && key !== 'debug' && key !== 'mcp') {
pluginDefaults[key as keyof OAuthProviderConfig] = expandEnvVar(value);
}
}
Expand Down
167 changes: 167 additions & 0 deletions src/lib/mcp/clientStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* MCP Client Store
*
* Persists Dynamic Client Registration (RFC 7591) records in the Harper
* `harper_oauth_mcp_clients` table. Clients survive Harper restarts so MCP
* clients (Claude Desktop, Cursor, mcp-remote) that cache their issued
* client_id continue to authenticate after a deploy.
*
* Array-valued fields (redirect_uris, contacts, grant_types, response_types)
* are JSON-encoded on write and decoded on read, matching the
* csrf_tokens.data pattern already used in this plugin.
*/

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

// Harper's databases global contains all databases
declare const databases: any;

let clientsTable: Table | undefined;

function getMCPClientsTable(): Table {
if (!clientsTable) {
if (!databases?.oauth?.harper_oauth_mcp_clients) {
throw new Error(
'OAuth MCP clients table (oauth.harper_oauth_mcp_clients) not found. ' +
'Please ensure the OAuth plugin is properly installed with its schema.'
);
}
clientsTable = databases.oauth.harper_oauth_mcp_clients;
}
return clientsTable as Table;
}

/**
* Reset the cached table reference (for testing only)
* @internal
*/
export function resetMCPClientsTableCache(): void {
clientsTable = undefined;
}

function serializeArrayField(value: unknown): string | undefined {
return value === undefined ? undefined : JSON.stringify(value);
}

function parseArrayField(value: unknown): string[] | undefined {
if (typeof value !== 'string') {
// null, undefined, or anything else from the DB collapses to undefined.
return undefined;
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : undefined;
} catch {
// Malformed stored data — treat as absent rather than crashing callers.
return undefined;
}
}

/**
* Encode the record for storage. Explicit field access (no spread) so we
* write a well-typed record even if a caller hands us a tracked object —
* per CLAUDE.md's "GenericTrackedObject + spread" gotcha.
*/
function encodeRecord(record: MCPClientRecord): Record<string, any> {
return {
client_id: record.client_id,
client_secret: record.client_secret,
client_name: record.client_name,
client_uri: record.client_uri,
logo_uri: record.logo_uri,
scope: record.scope,
token_endpoint_auth_method: record.token_endpoint_auth_method,
application_type: record.application_type,
software_id: record.software_id,
software_version: record.software_version,
client_id_issued_at: record.client_id_issued_at,
client_secret_expires_at: record.client_secret_expires_at,
redirect_uris: serializeArrayField(record.redirect_uris),
contacts: serializeArrayField(record.contacts),
grant_types: serializeArrayField(record.grant_types),
response_types: serializeArrayField(record.response_types),
};
}

/**
* Decode a stored row. Must use explicit property access — Harper returns
* GenericTrackedObject Proxies whose own-keys are empty, so { ...raw } drops
* every scalar field (client_id, client_secret, …) and breaks retrieval.
* Caught by Gemini review on PR #89; documented in CLAUDE.md.
*/
function decodeRecord(raw: Record<string, any>): MCPClientRecord {
return {
client_id: raw.client_id,
client_secret: raw.client_secret,
client_name: raw.client_name,
client_uri: raw.client_uri,
logo_uri: raw.logo_uri,
scope: raw.scope,
token_endpoint_auth_method: raw.token_endpoint_auth_method,
application_type: raw.application_type,
software_id: raw.software_id,
software_version: raw.software_version,
client_id_issued_at: raw.client_id_issued_at,
client_secret_expires_at: raw.client_secret_expires_at,
redirect_uris: parseArrayField(raw.redirect_uris) as string[],
contacts: parseArrayField(raw.contacts),
grant_types: parseArrayField(raw.grant_types),
response_types: parseArrayField(raw.response_types),
};
}

export class MCPClientStore {
private logger?: Logger;

constructor(logger?: Logger) {
this.logger = logger;
}

/**
* Persist a client registration. Overwrites any existing record with the
* same client_id (RFC 7591 registration is idempotent at the storage layer;
* the registration endpoint allocates a fresh client_id per request).
*/
async set(record: MCPClientRecord): Promise<void> {
const table = getMCPClientsTable();
try {
await table.put(encodeRecord(record));
this.logger?.debug?.(`Stored MCP client: ${record.client_id}`);
} catch (error) {
this.logger?.error?.('Failed to store MCP client:', error);
throw error;
}
}

/**
* Look up a client by client_id. Returns null if not found or on read error
* (errors logged; we don't surface storage failures to OAuth clients).
*/
async get(clientId: string): Promise<MCPClientRecord | null> {
const table = getMCPClientsTable();
try {
const raw = await table.get(clientId);
if (!raw || !raw.client_id) {
return null;
}
return decodeRecord(raw);
} catch (error) {
this.logger?.error?.('Failed to retrieve MCP client:', error);
return null;
}
}

/**
* Remove a client registration.
*/
async delete(clientId: string): Promise<void> {
const table = getMCPClientsTable();
try {
await table.delete(clientId);
this.logger?.debug?.(`Deleted MCP client: ${clientId}`);
} catch (error) {
// Not critical if delete fails — admin can retry.
this.logger?.warn?.('Failed to delete MCP client:', error);
}
}
}
Loading
Loading