diff --git a/.vscode/settings.json b/.vscode/settings.json index 9415e29..e89cd7c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,9 +3,12 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "cSpell.words": [ "bunx", + "EADDRINUSE", "konstantin", "kriasoft", "modelcontextprotocol", + "myapp", + "PKCE", "publint", "srcpack", "streamable", diff --git a/CLAUDE.md b/CLAUDE.md index ac8b8ce..139841e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,10 @@ # OAuth Callback Project Guide +## Documentation + +**ADRs** (`docs/adr/NNN-slug.md`): Architectural decisions (reference as ADR-NNN) +**SPECs** (`docs/specs/slug.md`): Design specifications (reference as SPEC-slug) + ## Project Structure ```bash @@ -56,7 +61,7 @@ oauth-callback/ - `browserAuth()` - MCP SDK-compatible OAuth provider - `inMemoryStore()` - Ephemeral token storage - `fileStore()` - Persistent file-based token storage -- Type exports: `BrowserAuthOptions`, `Tokens`, `TokenStore`, `ClientInfo`, `OAuthSession`, `OAuthStore` +- Type exports: `BrowserAuthOptions`, `Tokens`, `TokenStore`, `ClientInfo`, `OAuthStore` ## Key Constraints diff --git a/README.md b/README.md index ce233e5..164e2d4 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ import { browserAuth, inMemoryStore } from "oauth-callback/mcp"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +const serverUrl = new URL("https://mcp.notion.com/mcp"); + // Create MCP-compatible OAuth provider const authProvider = browserAuth({ port: 3000, @@ -135,18 +137,29 @@ const authProvider = browserAuth({ store: inMemoryStore(), // Or fileStore() for persistence }); -// Use with MCP SDK transport -const transport = new StreamableHTTPClientTransport( - new URL("https://mcp.notion.com/mcp"), - { authProvider }, -); - const client = new Client( { name: "my-app", version: "1.0.0" }, { capabilities: {} }, ); -await client.connect(transport); +// Connect with OAuth retry: first attempt completes OAuth and saves tokens, +// but SDK returns before checking them. Second attempt succeeds. +async function connectWithOAuthRetry() { + const transport = new StreamableHTTPClientTransport(serverUrl, { + authProvider, + }); + try { + await client.connect(transport); + } catch (error: any) { + if (error.message === "Unauthorized") { + await client.connect( + new StreamableHTTPClientTransport(serverUrl, { authProvider }), + ); + } else throw error; + } +} + +await connectWithOAuthRetry(); ``` #### Token Storage Options @@ -275,7 +288,7 @@ class OAuthError extends Error { ### `browserAuth(options)` -Available from `oauth-callback/mcp`. Creates an MCP SDK-compatible OAuth provider for browser-based flows. Handles Dynamic Client Registration (DCR), token storage, and automatic refresh. +Available from `oauth-callback/mcp`. Creates an MCP SDK-compatible OAuth provider for browser-based flows. Handles Dynamic Client Registration (DCR) and token storage. Expired tokens trigger re-authentication. #### Parameters diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 22821e2..3758e43 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -58,7 +58,7 @@ export default withMermaid( { text: "API", link: "/api/get-auth-code" }, { text: "Examples", link: "/examples/notion" }, { - text: "v2.0.0", + text: "v2.2.0", items: [ { text: "Release Notes", @@ -82,6 +82,7 @@ export default withMermaid( }, { text: "Getting Started", link: "/getting-started" }, { text: "Core Concepts", link: "/core-concepts" }, + { text: "ADRs", link: "/adr/" }, ], }, { diff --git a/docs/adr/000-template.md b/docs/adr/000-template.md new file mode 100644 index 0000000..8617dcd --- /dev/null +++ b/docs/adr/000-template.md @@ -0,0 +1,28 @@ +# ADR-NNN Title + +**Status:** Proposed | Accepted | Deprecated | Superseded +**Date:** YYYY-MM-DD +**Tags:** tag1, tag2 + +## Problem + +- One or two sentences on the decision trigger or constraint. + +## Decision + +- The chosen approach in a short paragraph. + +## Alternatives (brief) + +- Option A — why not. +- Option B — why not. + +## Impact + +- Positive: +- Negative/Risks: + +## Links + +- Code/Docs: +- Related ADRs: diff --git a/docs/adr/001-no-refresh-tokens.md b/docs/adr/001-no-refresh-tokens.md new file mode 100644 index 0000000..79d5d24 --- /dev/null +++ b/docs/adr/001-no-refresh-tokens.md @@ -0,0 +1,37 @@ +# ADR-001: No Refresh Tokens in browserAuth + +**Status:** Accepted +**Date:** 2025-01-25 +**Tags:** oauth, mcp, security + +## Problem + +- Should `browserAuth` handle OAuth refresh tokens and automatic token renewal? + +## Decision + +- `browserAuth` intentionally does not request or handle refresh tokens. +- When tokens expire, `tokens()` returns `undefined`, signaling the MCP SDK to re-authenticate. +- The SDK's built-in retry logic handles re-auth transparently. + +Rationale: + +- **MCP SDK lifecycle**: The SDK expects auth providers to return `undefined` for expired tokens, triggering its standard re-auth flow. +- **CLI/desktop UX**: Interactive re-consent is acceptable and often preferred over silent background refresh. +- **Simplicity**: Avoiding refresh eliminates token rotation complexity, race conditions, and concurrent refresh handling. +- **Security**: No long-lived refresh tokens stored; each session requires explicit user consent. + +## Alternatives (brief) + +- **Implement refresh flow** — Adds complexity (token rotation, concurrency), conflicts with SDK's re-auth expectations, stores long-lived credentials. +- **Optional refresh via config** — Increases API surface, creates two divergent code paths to maintain. + +## Impact + +- Positive: Simpler implementation, predictable behavior, aligns with MCP SDK design. +- Negative/Risks: More frequent browser prompts for long-running sessions (acceptable for CLI tools). + +## Links + +- Code: `src/auth/browser-auth.ts` +- Related: MCP SDK auth interface in `@modelcontextprotocol/sdk/client/auth` diff --git a/docs/adr/002-immediate-token-exchange.md b/docs/adr/002-immediate-token-exchange.md new file mode 100644 index 0000000..c0c5816 --- /dev/null +++ b/docs/adr/002-immediate-token-exchange.md @@ -0,0 +1,67 @@ +# ADR-002: Immediate Token Exchange in redirectToAuthorization() + +**Status:** Accepted +**Date:** 2025-01-25 +**Tags:** oauth, mcp, sdk-integration + +## Problem + +The MCP SDK's `auth()` flow works as follows: + +1. Check `provider.tokens()` — if valid tokens exist, return `'AUTHORIZED'` +2. If no tokens, start authorization: call `redirectToAuthorization(url)` +3. **Immediately** return `'REDIRECT'` (without re-checking tokens) + +For web-based OAuth, this makes sense: `redirectToAuthorization()` triggers a page redirect and control never returns synchronously. The SDK expects authentication to complete in a subsequent request. + +For CLI/desktop apps using `browserAuth()`, control **does** return synchronously—we capture the callback in-process via a local HTTP server. We exchange tokens inside `redirectToAuthorization()`, but the SDK has already decided to return `'REDIRECT'`, causing `UnauthorizedError`. + +## Decision + +Exchange tokens **inside** `redirectToAuthorization()` and document the retry pattern as the expected usage: + +```typescript +// First connect triggers OAuth flow and saves tokens, but SDK returns +// 'REDIRECT' before checking. Second connect finds valid tokens. +async function connectWithOAuthRetry(client, serverUrl, authProvider) { + const transport = new StreamableHTTPClientTransport(serverUrl, { + authProvider, + }); + try { + await client.connect(transport); + } catch (error) { + if (error.message === "Unauthorized") { + await client.connect( + new StreamableHTTPClientTransport(serverUrl, { authProvider }), + ); + } else throw error; + } +} +``` + +**Why a new transport on retry?** The transport caches connection state internally. A fresh transport ensures clean reconnection. + +## Rationale + +- **SDK constraint**: No hook exists between redirect completion and the `'REDIRECT'` return. The SDK interface (`Promise`) cannot signal "auth completed." +- **In-process capture**: CLI apps don't have page redirects that would trigger a fresh auth check cycle. +- **Correctness over elegance**: The retry is unusual but reliable—tokens are always saved before the error. + +## Alternatives Considered + +| Alternative | Why Rejected | +| ---------------------------------------------- | ------------------------------------------------------------- | +| `transport.finishAuth(callbackUrl)` | Breaks provider encapsulation; doesn't fit in-process capture | +| Return tokens from `redirectToAuthorization()` | SDK interface expects `Promise` | +| Upstream SDK change | Not viable for library consumers | + +## Impact + +- **Positive**: Self-contained auth flow; no external coordination needed +- **Negative**: First connection always throws `UnauthorizedError` after OAuth—must be documented clearly + +## Links + +- Code: `src/auth/browser-auth.ts` lines 254-368 +- MCP SDK auth interface: `@modelcontextprotocol/sdk/client/auth.js` +- Related: ADR-001 (no refresh tokens) diff --git a/docs/adr/003-stable-client-metadata.md b/docs/adr/003-stable-client-metadata.md new file mode 100644 index 0000000..1b40f86 --- /dev/null +++ b/docs/adr/003-stable-client-metadata.md @@ -0,0 +1,31 @@ +# ADR-003: Stable Client Metadata Across DCR + +**Status:** Accepted +**Date:** 2025-01-25 +**Tags:** oauth, dcr, security + +## Problem + +- During Dynamic Client Registration (DCR), the authorization server may return different capabilities than requested (e.g., `token_endpoint_auth_method`). +- If `clientMetadata` adapts to DCR responses, subsequent token requests may fail when the AS caches the original registration metadata. + +## Decision + +- `clientMetadata` is immutable after construction. +- `token_endpoint_auth_method` is determined at construction: `client_secret_post` if `clientSecret` is provided, `none` otherwise. DCR responses never change this value. +- DCR credentials (`client_id`, `client_secret`) are stored separately and never mutate the auth method. + +## Alternatives (brief) + +- **Dynamic metadata evolution** — Adapting to DCR responses seems flexible but causes cache mismatches with AS that remember original registration. +- **Per-request method detection** — Adds complexity and non-deterministic behavior across retries. + +## Impact + +- Positive: Predictable behavior with all AS implementations; eliminates cache-related auth failures. +- Negative/Risks: None identified; the fixed method (`client_secret_post`) has universal support. + +## Links + +- Code: `src/auth/browser-auth.ts` +- Related ADRs: ADR-001 (No Refresh Tokens), ADR-002 (Immediate Token Exchange) diff --git a/docs/adr/004-conditional-state-validation.md b/docs/adr/004-conditional-state-validation.md new file mode 100644 index 0000000..cc733ba --- /dev/null +++ b/docs/adr/004-conditional-state-validation.md @@ -0,0 +1,40 @@ +# ADR-004: Conditional OAuth State Validation + +**Status:** Accepted +**Date:** 2025-01-25 +**Tags:** oauth, security, csrf + +## Problem + +- RFC 6749 recommends `state` for CSRF protection, but RFC 8252 (native apps) relies on loopback redirect for security. +- Some authorization servers don't echo `state` back; others require it. +- Strict validation breaks compatibility; no validation weakens security. + +## Decision + +- Validate `state` only if it was present in the authorization URL. +- If the auth URL includes `state` and the callback doesn't match, reject as CSRF. +- If the auth URL omits `state`, accept callbacks without state validation. + +Rationale: + +- **Defense-in-depth**: Loopback binding (127.0.0.1) prevents network CSRF, but state adds protection against local attacks (malicious apps, browser extensions intercepting localhost). +- **CLI threat model**: Unlike web apps, CLI tools face local machine threats—other processes can probe localhost ports. State validation detects if a callback arrives from an unrelated auth flow. +- **Compatibility**: Authorization servers have inconsistent state handling. Conditional validation works with all servers while providing protection when available. + +## Alternatives (brief) + +- **Always require state** — Breaks servers that don't echo state or don't support it. +- **Never validate state** — Loopback provides baseline security, but ignores state when the AS cooperates. +- **Generate state internally always** — Conflicts with auth URLs that already include state from the MCP SDK. + +## Impact + +- Positive: Maximum security when AS supports state; universal compatibility otherwise. +- Negative/Risks: If an AS echoes arbitrary state values without validation, the protection is weaker (rare edge case). + +## Links + +- Code: `src/auth/browser-auth.ts:297-300` +- RFC 6749 Section 10.12 (CSRF Protection) +- RFC 8252 Section 8.1 (Loopback Redirect) diff --git a/docs/adr/005-store-responsibility-reduction.md b/docs/adr/005-store-responsibility-reduction.md new file mode 100644 index 0000000..b9e5f8f --- /dev/null +++ b/docs/adr/005-store-responsibility-reduction.md @@ -0,0 +1,36 @@ +# ADR-005: OAuthStore Responsibility Reduction + +**Status:** Accepted +**Date:** 2025-01-25 +**Tags:** api, storage, simplification + +## Problem + +The store was accumulating OAuth flow state (session, nonce, state parameter) alongside persistent data (tokens, client registration). This blurred the line between "what survives a crash" and "what's ephemeral by design," making the API harder to reason about and test. + +## Decision + +The store is responsible **only** for data that must survive process restarts: + +| Stored | Not Stored | +| --------------------- | ----------------- | +| `tokens` | `state` parameter | +| `client` (DCR result) | `nonce` | +| `codeVerifier` (PKCE) | session objects | + +The `codeVerifier` is the sole flow artifact persisted—it enables completing an in-progress authorization if the process crashes after browser launch but before callback. + +## Alternatives (brief) + +- **Full session persistence** — Would enable crash-recovery at any point, but adds complexity for a rare edge case. Users can simply restart the flow. +- **No verifier persistence** — Simpler, but loses the most common crash scenario (user switches apps, process dies). + +## Impact + +- Positive: Cleaner mental model; store implementations are trivial to write and test. +- Negative: If the process crashes before `codeVerifier` is saved, the flow must restart. This is acceptable—it's a sub-second window. + +## Links + +- Code: `src/storage/`, `src/mcp-types.ts` +- Related: ADR-002 (Immediate Token Exchange) diff --git a/docs/adr/index.md b/docs/adr/index.md new file mode 100644 index 0000000..7cb1fca --- /dev/null +++ b/docs/adr/index.md @@ -0,0 +1,11 @@ +# Architecture Decision Records + +Key design decisions with context and rationale. + +| ADR | Decision | +| ---------------------------------------------- | ----------------------------------------------------- | +| [001](./001-no-refresh-tokens.md) | No refresh tokens—rely on MCP SDK's re-auth flow | +| [002](./002-immediate-token-exchange.md) | Token exchange inside `redirectToAuthorization()` | +| [003](./003-stable-client-metadata.md) | Immutable client metadata across DCR | +| [004](./004-conditional-state-validation.md) | Validate `state` only when present in auth URL | +| [005](./005-store-responsibility-reduction.md) | Store persists only tokens, client, and PKCE verifier | diff --git a/docs/api/browser-auth.md b/docs/api/browser-auth.md index 064e5c2..78d53ff 100644 --- a/docs/api/browser-auth.md +++ b/docs/api/browser-auth.md @@ -5,7 +5,7 @@ description: MCP SDK-compatible OAuth provider for browser-based authentication # browserAuth -The `browserAuth` function creates an OAuth provider that integrates seamlessly with the Model Context Protocol (MCP) SDK. It handles the entire OAuth flow including Dynamic Client Registration, token management, and automatic refresh — all through a browser-based authorization flow. +The `browserAuth` function creates an OAuth provider that integrates seamlessly with the Model Context Protocol (MCP) SDK. It handles the entire OAuth flow including Dynamic Client Registration and token storage through a browser-based authorization flow. Expired tokens trigger re-authentication; refresh tokens are not used. ## Function Signature @@ -17,21 +17,22 @@ function browserAuth(options?: BrowserAuthOptions): OAuthClientProvider; ### BrowserAuthOptions -| Property | Type | Default | Description | -| -------------- | -------------------------- | ----------------- | ---------------------------------- | -| `clientId` | `string` | _none_ | Pre-registered OAuth client ID | -| `clientSecret` | `string` | _none_ | Pre-registered OAuth client secret | -| `scope` | `string` | _none_ | OAuth scopes to request | -| `port` | `number` | `3000` | Port for local callback server | -| `hostname` | `string` | `"localhost"` | Hostname to bind server to | -| `callbackPath` | `string` | `"/callback"` | URL path for OAuth callback | -| `store` | `TokenStore` | `inMemoryStore()` | Token storage implementation | -| `storeKey` | `string` | `"mcp-tokens"` | Storage key for token isolation | -| `launch` | `(url: string) => unknown` | _none_ | Callback to launch auth URL | -| `authTimeout` | `number` | `300000` | Auth timeout in ms (5 min) | -| `successHtml` | `string` | _built-in_ | Custom success page HTML | -| `errorHtml` | `string` | _built-in_ | Custom error page HTML | -| `onRequest` | `(req: Request) => void` | _none_ | Request logging callback | +| Property | Type | Default | Description | +| --------------- | -------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `clientId` | `string` | _none_ | Pre-registered OAuth client ID | +| `clientSecret` | `string` | _none_ | Pre-registered OAuth client secret | +| `scope` | `string` | _none_ | OAuth scopes to request. When omitted, the auth server uses its default scope. | +| `port` | `number` | `3000` | Port for local callback server | +| `hostname` | `string` | `"localhost"` | Hostname to bind server to | +| `callbackPath` | `string` | `"/callback"` | URL path for OAuth callback | +| `store` | `TokenStore` | `inMemoryStore()` | Token storage implementation | +| `storeKey` | `string` | `"mcp-tokens"` | Storage key for token isolation | +| `launch` | `(url: string) => unknown` | _none_ | Callback to launch auth URL | +| `authTimeout` | `number` | `300000` | Auth timeout in ms (5 min) | +| `successHtml` | `string` | _built-in_ | Custom success page HTML | +| `errorHtml` | `string` | _built-in_ | Custom error page HTML | +| `onRequest` | `(req: Request) => void` | _none_ | Request logging callback | +| `authServerUrl` | `string \| URL` | _auto_ | Base URL for OAuth metadata discovery. Defaults to the authorization URL's origin. Set this when the token endpoint is on a different origin. | ## Return Value @@ -39,10 +40,10 @@ Returns an `OAuthClientProvider` instance that implements the MCP SDK authentica ```typescript interface OAuthClientProvider { - // Called by MCP SDK for authentication + // Completes full OAuth flow: browser → callback → token exchange → persist redirectToAuthorization(authorizationUrl: URL): Promise; - // Token management + // Token storage tokens(): Promise; saveTokens(tokens: OAuthTokens): Promise; @@ -314,7 +315,6 @@ interface TokenStore { get(key: string): Promise; set(key: string, tokens: Tokens): Promise; delete(key: string): Promise; - clear(): Promise; } ``` @@ -324,8 +324,10 @@ interface TokenStore { interface OAuthStore extends TokenStore { getClient(key: string): Promise; setClient(key: string, client: ClientInfo): Promise; - getSession(key: string): Promise; - setSession(key: string, session: OAuthSession): Promise; + deleteClient(key: string): Promise; + getCodeVerifier(key: string): Promise; + setCodeVerifier(key: string, verifier: string): Promise; + deleteCodeVerifier(key: string): Promise; } ``` @@ -400,14 +402,6 @@ class RedisStore implements TokenStore { async delete(key: string): Promise { await this.redis.del(key); } - - async clear(): Promise { - // Clear all tokens with pattern matching - const keys = await this.redis.keys("mcp-tokens:*"); - if (keys.length > 0) { - await this.redis.del(...keys); - } - } } // Use custom store @@ -431,25 +425,19 @@ PKCE prevents authorization code interception attacks by: ### State Parameter -The provider automatically generates secure state parameters: - -```typescript -import open from "open"; +The `state()` method generates secure random values when called by the MCP SDK. State validation in `browserAuth` compares the callback's state against the state parameter in the authorization URL that was passed to `redirectToAuthorization()`. This means validation works regardless of whether `state()` was used - it validates whatever state is present in the URL. -// State is automatically generated and validated -const authProvider = browserAuth({ launch: open }); -// No manual state handling needed! -``` +For localhost flows, [RFC 8252](https://www.rfc-editor.org/rfc/rfc8252) considers loopback interface binding sufficient for security. State validation adds defense-in-depth. ### Token Expiry Management -Tokens are automatically managed with expiry tracking: +Tokens are tracked with expiry times. The provider returns `undefined` from `tokens()` 60 seconds before actual expiry to prevent mid-request failures. This triggers re-authentication before tokens become invalid: ```typescript -// The provider automatically: -// 1. Tracks token expiry time -// 2. Returns undefined for expired tokens -// 3. Attempts refresh when refresh tokens are available +// The provider: +// 1. Returns undefined 60s before token expiry +// 2. SDK triggers re-auth when tokens() returns undefined +// 3. Requests never fail due to mid-flight token expiry ``` ### Secure Storage @@ -486,17 +474,6 @@ try { } ``` -### Retry Logic - -The provider includes automatic retry for transient failures: - -```typescript -// Built-in retry logic: -// - 3 attempts for authorization -// - Exponential backoff between retries -// - OAuth errors are not retried (user-actionable) -``` - ### Timeout Handling Configure timeout for different scenarios: @@ -610,47 +587,15 @@ const authProvider = createAuthProvider( ); ``` -### Token Refresh Implementation - -While automatic refresh is pending full implementation, you can handle expired tokens: - -```typescript -import open from "open"; -import { browserAuth, fileStore } from "oauth-callback/mcp"; - -const authProvider = browserAuth({ - launch: open, - store: fileStore(), - scope: "offline_access", // Request refresh token -}); - -async function withTokenRefresh(client: Client, operation: () => Promise) { - try { - return await operation(); - } catch (error) { - if ( - error.message.includes("401") || - error.message.includes("unauthorized") - ) { - console.log("Token expired, re-authenticating..."); +### Handling Expired Tokens - // Clear expired tokens - await authProvider.invalidateCredentials("tokens"); +The provider does not use refresh tokens. When tokens expire, re-authentication is triggered automatically by returning `undefined` from `tokens()`. The MCP SDK handles this transparently. - // Reconnect (will trigger new auth) - await client.reconnect(); - - // Retry operation - return await operation(); - } - throw error; - } -} +For explicit control over re-authentication: -// Use with automatic retry -const result = await withTokenRefresh(client, async () => { - return await client.callTool("get-data", {}); -}); +```typescript +// Force re-authentication by clearing tokens +await authProvider.invalidateCredentials("tokens"); ``` ## Testing @@ -801,19 +746,18 @@ const authProvider = browserAuth({ The `browserAuth` provider implements the MCP SDK's `OAuthClientProvider` interface: -| Method | Status | Notes | -| ------------------------- | -------------------- | -------------------------- | -| `redirectToAuthorization` | ✅ Fully supported | Opens browser for auth | -| `tokens` | ✅ Fully supported | Returns current tokens | -| `saveTokens` | ✅ Fully supported | Persists to storage | -| `clientInformation` | ✅ Fully supported | Returns client credentials | -| `saveClientInformation` | ✅ Fully supported | Stores DCR results | -| `state` | ✅ Fully supported | Generates secure state | -| `codeVerifier` | ✅ Fully supported | PKCE verifier | -| `saveCodeVerifier` | ✅ Fully supported | Stores PKCE verifier | -| `invalidateCredentials` | ✅ Fully supported | Clears stored data | -| `validateResourceURL` | ✅ Returns undefined | Not applicable | -| `getPendingAuthCode` | ✅ Internal use | Used by SDK | +| Method | Status | Notes | +| ------------------------- | -------------------- | ------------------------------------------------------------------------- | +| `redirectToAuthorization` | ✅ Fully supported | Completes full OAuth flow (browser → callback → token exchange → persist) | +| `tokens` | ✅ Fully supported | Returns current tokens | +| `saveTokens` | ✅ Fully supported | Persists to storage | +| `clientInformation` | ✅ Fully supported | Returns client credentials | +| `saveClientInformation` | ✅ Fully supported | Stores DCR results | +| `state` | ✅ Fully supported | Generates secure state | +| `codeVerifier` | ✅ Fully supported | PKCE verifier | +| `saveCodeVerifier` | ✅ Fully supported | Stores PKCE verifier | +| `invalidateCredentials` | ✅ Fully supported | Clears stored data | +| `validateResourceURL` | ✅ Returns undefined | Not applicable | ## Migration Guide diff --git a/docs/api/get-auth-code.md b/docs/api/get-auth-code.md index fd404c9..1466dc4 100644 --- a/docs/api/get-auth-code.md +++ b/docs/api/get-auth-code.md @@ -248,14 +248,18 @@ try { ### Headless / Manual Browser Control -For environments where you want to handle browser opening yourself: +For environments where you want to handle browser opening yourself (SSH, CI, etc.): ```typescript // Headless mode - print URL, let user open manually +const redirectUri = "http://localhost:3000/callback"; +const authUrl = `https://oauth.example.com/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`; + console.log("Please open this URL in your browser:"); console.log(authUrl); -const result = await getAuthCode({ authorizationUrl: authUrl }); +// Server waits for callback without opening browser +const result = await getAuthCode({ port: 3000, timeout: 120000 }); ``` Or use a custom launcher: @@ -265,7 +269,7 @@ import open from "open"; const result = await getAuthCode({ authorizationUrl: authUrl, - launch: open, // Pass any function that accepts URL + launch: open, // Both authorizationUrl and launch are required together }); ``` diff --git a/docs/api/index.md b/docs/api/index.md index 0b0e007..ef5582d 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -22,7 +22,7 @@ OAuth Callback provides a comprehensive set of APIs for handling OAuth 2.0 autho - **inMemoryStore** - Ephemeral in-memory token storage - **fileStore** - Persistent file-based token storage -### Error Handling +### Errors - [**OAuthError**](/api/oauth-error) - OAuth-specific error class with RFC 6749 compliance @@ -109,15 +109,16 @@ interface TokenStore { get(key: string): Promise; set(key: string, tokens: Tokens): Promise; delete(key: string): Promise; - clear(): Promise; } -// Extended storage with DCR support +// Extended storage with DCR and PKCE support interface OAuthStore extends TokenStore { getClient(key: string): Promise; setClient(key: string, client: ClientInfo): Promise; - getSession(key: string): Promise; - setSession(key: string, session: OAuthSession): Promise; + deleteClient(key: string): Promise; + getCodeVerifier(key: string): Promise; + setCodeVerifier(key: string, verifier: string): Promise; + deleteCodeVerifier(key: string): Promise; } ``` @@ -249,7 +250,7 @@ const transport = new StreamableHTTPClientTransport( ); ``` -### Error Handling +### Handling Errors ```typescript import { getAuthCode, OAuthError } from "oauth-callback"; @@ -420,14 +421,17 @@ const authProvider = browserAuth({ launch: open, store: fileStore() }); ## API Stability -| API | Status | Since | Notes | -| --------------- | ------ | ------ | ----------------------------- | -| `getAuthCode` | Stable | v1.0.0 | Core API, backward compatible | -| `browserAuth` | Stable | v2.0.0 | MCP integration | -| `OAuthError` | Stable | v1.0.0 | Error handling | -| `inMemoryStore` | Stable | v2.0.0 | Storage provider | -| `fileStore` | Stable | v2.0.0 | Storage provider | -| Types | Stable | v1.0.0 | TypeScript definitions | +| API | Status | Since | Notes | +| ---------------- | ------ | ------ | ----------------------------- | +| `getAuthCode` | Stable | v1.0.0 | Core API, backward compatible | +| `getRedirectUrl` | Stable | v1.0.0 | Redirect URI helper | +| `OAuthError` | Stable | v1.0.0 | OAuth-specific errors | +| `TimeoutError` | Stable | v1.0.0 | Timeout error class | +| `mcp` | Stable | v2.0.0 | MCP namespace export | +| `browserAuth` | Stable | v2.0.0 | MCP integration | +| `inMemoryStore` | Stable | v2.0.0 | Storage provider | +| `fileStore` | Stable | v2.0.0 | Storage provider | +| Types | Stable | v1.0.0 | TypeScript definitions | ## Related Resources diff --git a/docs/api/storage-providers.md b/docs/api/storage-providers.md index 1db5bcd..353502f 100644 --- a/docs/api/storage-providers.md +++ b/docs/api/storage-providers.md @@ -20,7 +20,6 @@ interface TokenStore { get(key: string): Promise; set(key: string, tokens: Tokens): Promise; delete(key: string): Promise; - clear(): Promise; } ``` @@ -37,14 +36,21 @@ interface Tokens { ### OAuthStore Interface -The extended `OAuthStore` interface adds support for Dynamic Client Registration and session state: +The extended `OAuthStore` interface adds support for Dynamic Client Registration and PKCE verifier persistence: ```typescript +import { OAuthStoreBrand } from "oauth-callback/mcp"; + interface OAuthStore extends TokenStore { + readonly [OAuthStoreBrand]: true; // Required brand for type detection + getClient(key: string): Promise; setClient(key: string, client: ClientInfo): Promise; - getSession(key: string): Promise; - setSession(key: string, session: OAuthSession): Promise; + deleteClient(key: string): Promise; + + getCodeVerifier(key: string): Promise; + setCodeVerifier(key: string, verifier: string): Promise; + deleteCodeVerifier(key: string): Promise; } ``` @@ -59,15 +65,6 @@ interface ClientInfo { } ``` -#### OAuthSession Type - -```typescript -interface OAuthSession { - codeVerifier?: string; // PKCE code verifier - state?: string; // OAuth state parameter -} -``` - ## Built-in Storage Providers ### inMemoryStore() @@ -272,13 +269,6 @@ class RedisTokenStore implements TokenStore { async delete(key: string): Promise { await this.redis.del(this.prefix + key); } - - async clear(): Promise { - const keys = await this.redis.keys(this.prefix + "*"); - if (keys.length > 0) { - await this.redis.del(...keys); - } - } } // Usage @@ -351,10 +341,6 @@ class SQLiteTokenStore implements TokenStore { async delete(key: string): Promise { this.db.prepare("DELETE FROM tokens WHERE key = ?").run(key); } - - async clear(): Promise { - this.db.prepare("DELETE FROM tokens").run(); - } } // Usage @@ -371,14 +357,15 @@ const authProvider = browserAuth({ ```typescript import { - OAuthStore, - Tokens, - ClientInfo, - OAuthSession, + OAuthStoreBrand, + type OAuthStore, + type Tokens, + type ClientInfo, } from "oauth-callback/mcp"; import { MongoClient, Db } from "mongodb"; class MongoOAuthStore implements OAuthStore { + readonly [OAuthStoreBrand] = true as const; private db: Db; constructor(db: Db) { @@ -388,7 +375,6 @@ class MongoOAuthStore implements OAuthStore { // TokenStore methods async get(key: string): Promise { const doc = await this.db.collection("tokens").findOne({ _id: key }); - return doc ? { accessToken: doc.accessToken, @@ -402,25 +388,16 @@ class MongoOAuthStore implements OAuthStore { async set(key: string, tokens: Tokens): Promise { await this.db .collection("tokens") - .replaceOne( - { _id: key }, - { _id: key, ...tokens, updatedAt: new Date() }, - { upsert: true }, - ); + .replaceOne({ _id: key }, { _id: key, ...tokens }, { upsert: true }); } async delete(key: string): Promise { await this.db.collection("tokens").deleteOne({ _id: key }); } - async clear(): Promise { - await this.db.collection("tokens").deleteMany({}); - } - - // OAuthStore additional methods + // Client registration methods async getClient(key: string): Promise { const doc = await this.db.collection("clients").findOne({ _id: key }); - return doc ? { clientId: doc.clientId, @@ -434,32 +411,27 @@ class MongoOAuthStore implements OAuthStore { async setClient(key: string, client: ClientInfo): Promise { await this.db .collection("clients") - .replaceOne( - { _id: key }, - { _id: key, ...client, updatedAt: new Date() }, - { upsert: true }, - ); + .replaceOne({ _id: key }, { _id: key, ...client }, { upsert: true }); } - async getSession(key: string): Promise { - const doc = await this.db.collection("sessions").findOne({ _id: key }); + async deleteClient(key: string): Promise { + await this.db.collection("clients").deleteOne({ _id: key }); + } - return doc - ? { - codeVerifier: doc.codeVerifier, - state: doc.state, - } - : null; + // PKCE verifier methods + async getCodeVerifier(key: string): Promise { + const doc = await this.db.collection("verifiers").findOne({ _id: key }); + return doc?.verifier ?? null; } - async setSession(key: string, session: OAuthSession): Promise { + async setCodeVerifier(key: string, verifier: string): Promise { await this.db - .collection("sessions") - .replaceOne( - { _id: key }, - { _id: key, ...session, updatedAt: new Date() }, - { upsert: true }, - ); + .collection("verifiers") + .replaceOne({ _id: key }, { _id: key, verifier }, { upsert: true }); + } + + async deleteCodeVerifier(key: string): Promise { + await this.db.collection("verifiers").deleteOne({ _id: key }); } } @@ -553,10 +525,6 @@ class EncryptedTokenStore implements TokenStore { async delete(key: string): Promise { await this.store.delete(key); } - - async clear(): Promise { - await this.store.clear(); - } } // Usage @@ -685,11 +653,6 @@ class CachedTokenStore implements TokenStore { this.cache.delete(key); await this.store.delete(key); } - - async clear(): Promise { - this.cache.clear(); - await this.store.clear(); - } } // Usage @@ -726,10 +689,6 @@ class MockTokenStore implements TokenStore { this.data.delete(key); } - async clear(): Promise { - this.data.clear(); - } - // Test helper methods reset() { this.data.clear(); diff --git a/docs/api/types.md b/docs/api/types.md index 7a8c938..e14ca9a 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -28,7 +28,6 @@ flowchart TB OAuthStore Tokens ClientInfo - OAuthSession end subgraph "MCP Types" @@ -42,7 +41,6 @@ flowchart TB BrowserAuthOptions --> OAuthStore OAuthStore --> Tokens OAuthStore --> ClientInfo - OAuthStore --> OAuthSession ``` ## Core Types @@ -231,7 +229,6 @@ interface TokenStore { get(key: string): Promise; set(key: string, tokens: Tokens): Promise; delete(key: string): Promise; - clear(): Promise; } ``` @@ -271,23 +268,26 @@ class CustomTokenStore implements TokenStore { async delete(key: string): Promise { this.storage.delete(key); } - - async clear(): Promise { - this.storage.clear(); - } } ``` ### OAuthStore -Extended storage interface with Dynamic Client Registration support. +Extended storage interface with Dynamic Client Registration and PKCE verifier persistence. ```typescript +import { OAuthStoreBrand } from "oauth-callback/mcp"; + interface OAuthStore extends TokenStore { + readonly [OAuthStoreBrand]: true; // Required brand for type detection + getClient(key: string): Promise; setClient(key: string, client: ClientInfo): Promise; - getSession(key: string): Promise; - setSession(key: string, session: OAuthSession): Promise; + deleteClient(key: string): Promise; + + getCodeVerifier(key: string): Promise; + setCodeVerifier(key: string, verifier: string): Promise; + deleteCodeVerifier(key: string): Promise; } ``` @@ -304,28 +304,19 @@ interface ClientInfo { } ``` -### OAuthSession - -Active OAuth flow state for crash recovery. - -```typescript -interface OAuthSession { - codeVerifier?: string; // PKCE code verifier - state?: string; // OAuth state parameter -} -``` - #### Complete Storage Example ```typescript -import type { - OAuthStore, - Tokens, - ClientInfo, - OAuthSession, +import { + OAuthStoreBrand, + type OAuthStore, + type Tokens, + type ClientInfo, } from "oauth-callback/mcp"; class DatabaseOAuthStore implements OAuthStore { + readonly [OAuthStoreBrand] = true as const; + constructor(private db: Database) {} // TokenStore methods @@ -341,32 +332,31 @@ class DatabaseOAuthStore implements OAuthStore { await this.db.tokens.delete({ key }); } - async clear(): Promise { - await this.db.tokens.deleteMany({}); - } - - // OAuthStore additional methods + // Client registration methods async getClient(key: string): Promise { return await this.db.clients.findOne({ key }); } async setClient(key: string, client: ClientInfo): Promise { - // Check if client secret is expired - if ( - client.clientSecretExpiresAt && - Date.now() >= client.clientSecretExpiresAt - ) { - throw new Error("Cannot store expired client secret"); - } await this.db.clients.upsert({ key }, client); } - async getSession(key: string): Promise { - return await this.db.sessions.findOne({ key }); + async deleteClient(key: string): Promise { + await this.db.clients.delete({ key }); + } + + // PKCE verifier methods + async getCodeVerifier(key: string): Promise { + const doc = await this.db.verifiers.findOne({ key }); + return doc?.verifier ?? null; } - async setSession(key: string, session: OAuthSession): Promise { - await this.db.sessions.upsert({ key }, session); + async setCodeVerifier(key: string, verifier: string): Promise { + await this.db.verifiers.upsert({ key }, { verifier }); + } + + async deleteCodeVerifier(key: string): Promise { + await this.db.verifiers.delete({ key }); } } ``` @@ -509,7 +499,7 @@ try { Useful type guard functions for runtime type checking: ```typescript -import type { Tokens, ClientInfo, OAuthSession } from "oauth-callback/mcp"; +import type { Tokens, ClientInfo } from "oauth-callback/mcp"; // Check if object is Tokens function isTokens(obj: unknown): obj is Tokens { @@ -531,15 +521,6 @@ function isClientInfo(obj: unknown): obj is ClientInfo { ); } -// Check if object is OAuthSession -function isOAuthSession(obj: unknown): obj is OAuthSession { - return ( - typeof obj === "object" && - obj !== null && - ("codeVerifier" in obj || "state" in obj) - ); -} - // Check if error is OAuthError function isOAuthError(error: unknown): error is OAuthError { return error instanceof OAuthError; @@ -632,14 +613,7 @@ export { getAuthCode, OAuthError, inMemoryStore, fileStore }; ```typescript // From "oauth-callback/mcp" -export type { - BrowserAuthOptions, - TokenStore, - OAuthStore, - Tokens, - ClientInfo, - OAuthSession, -}; +export type { BrowserAuthOptions, TokenStore, OAuthStore, Tokens, ClientInfo }; export { browserAuth, inMemoryStore, fileStore }; ``` diff --git a/docs/core-concepts.md b/docs/core-concepts.md index 249d595..dfa5811 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -28,7 +28,7 @@ The authorization code flow provides several security benefits: - **No token exposure**: Access tokens never pass through the browser - **Short-lived codes**: Authorization codes expire quickly (typically 10 minutes) - **Server verification**: The auth server can verify the client's identity -- **Refresh capability**: Supports refresh tokens for long-lived access +- **PKCE support**: Protection against authorization code interception ## The Localhost Callback Pattern @@ -38,7 +38,7 @@ The core innovation of OAuth Callback is making the localhost callback pattern t Traditional web applications have public URLs where OAuth providers can send callbacks: -``` +```text https://myapp.com/oauth/callback?code=xyz123 ``` @@ -105,13 +105,12 @@ The heart of OAuth Callback is a lightweight HTTP server that: - Serves success/error HTML pages - Implements proper cleanup on completion -```typescript -// Internally, the server handles: -- Request routing (/callback path matching) -- Query parameter extraction (code, state, error) +Internally, the server handles: + +- Request routing (`/callback` path matching) +- Query parameter extraction (`code`, `state`, `error`) - HTML template rendering with placeholders - Graceful shutdown after callback -``` #### 2. The Authorization Handler (`getAuthCode`) @@ -157,7 +156,7 @@ The `TokenStore` interface enables different storage strategies: ```typescript interface TokenStore { - get(key: string): Promise; + get(key: string): Promise; set(key: string, tokens: Tokens): Promise; delete(key: string): Promise; } @@ -187,12 +186,14 @@ const store = fileStore("~/.myapp/tokens.json"); ### Token Lifecycle +OAuth Callback uses re-authentication instead of refresh tokens. When tokens expire, the provider returns `undefined`, signaling the MCP SDK to re-initiate the OAuth flow. This simplifies implementation and avoids storing long-lived refresh credentials. + ```mermaid stateDiagram-v2 [*] --> NoToken: Initial State NoToken --> Authorizing: User initiates OAuth Authorizing --> HasToken: Successful auth - HasToken --> Authorizing: Token expired + HasToken --> Authorizing: Token expired (re-auth) HasToken --> NoToken: User logs out ``` @@ -225,11 +226,17 @@ The `browserAuth()` function returns an `OAuthClientProvider` that integrates wi ```typescript interface OAuthClientProvider { - // Called by MCP SDK when authentication is needed - authenticate(params: AuthenticationParams): Promise; + // Token access - returns undefined when expired, triggering re-auth + tokens(): Promise; + saveTokens(tokens: OAuthTokens): Promise; + + // Completes full OAuth flow (browser → callback → token exchange) + redirectToAuthorization(authorizationUrl: URL): Promise; - // Manages token refresh automatically - refreshToken?(params: RefreshParams): Promise; + // PKCE and state management + codeVerifier(): Promise; + saveCodeVerifier(verifier: string): Promise; + state(): Promise; } ``` @@ -292,8 +299,7 @@ When using token storage: - **No tokens**: Need to authenticate - **Valid tokens**: Can make API calls -- **Expired tokens**: Need refresh -- **Refresh failed**: Need re-authentication +- **Expired tokens**: Triggers re-authentication (no refresh tokens used) ## Security Architecture diff --git a/docs/getting-started.md b/docs/getting-started.md index d33028a..e6438a5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -534,7 +534,7 @@ If you're in a headless environment or the browser doesn't open: ```typescript // Headless mode - print URL for manual opening console.log(`Please open: ${authUrl}`); -const result = await getAuthCode({ authorizationUrl: authUrl }); +const result = await getAuthCode({ port: 3000, timeout: 120000 }); ``` ::: diff --git a/examples/notion.ts b/examples/notion.ts index 546080a..47fe3ea 100644 --- a/examples/notion.ts +++ b/examples/notion.ts @@ -14,9 +14,43 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import open from "open"; import { browserAuth, inMemoryStore } from "../src/mcp"; +/** + * Connect with OAuth retry handling. + * + * The MCP SDK's auth flow returns 'REDIRECT' after `redirectToAuthorization()` + * completes, without re-checking for tokens. For in-process OAuth (CLI/desktop), + * tokens ARE saved but the SDK doesn't know—causing an initial UnauthorizedError. + * Retry succeeds because tokens exist. + */ +async function connectWithOAuthRetry( + client: Client, + serverUrl: URL, + authProvider: OAuthClientProvider, +): Promise { + const createTransport = () => + new StreamableHTTPClientTransport(serverUrl, { authProvider }); + + try { + await client.connect(createTransport()); + } catch (error: unknown) { + const isUnauthorized = + error instanceof Error && + (error.constructor.name === "UnauthorizedError" || + error.message === "Unauthorized"); + + if (isUnauthorized) { + // Tokens were saved during first attempt; fresh transport succeeds + await client.connect(createTransport()); + } else { + throw error; + } + } +} + async function main() { console.log("🚀 Starting OAuth flow example with Notion MCP Server\n"); console.log("This example demonstrates Dynamic Client Registration:"); @@ -36,97 +70,27 @@ async function main() { const url = new URL(req.url); console.log(`📨 Received ${req.method} request to ${url.pathname}`); }, - }) as any; // Cast required: getPendingAuthCode() is SDK workaround, not public API + }); try { console.log("🔌 Connecting to Notion MCP server..."); - const transport = new StreamableHTTPClientTransport(serverUrl, { - authProvider, - }); - const client = new Client( - { - name: "oauth-callback-example", - version: "1.0.0", - }, - { - capabilities: {}, - }, + { name: "oauth-callback-example", version: "1.0.0" }, + { capabilities: {} }, ); - // Initial connect triggers OAuth flow when no valid tokens exist - try { - await client.connect(transport); - console.log("\n🎉 Successfully connected with existing credentials!"); - - await listServerCapabilities(client); - await client.close(); - } catch (error: any) { - if (error.constructor.name === "UnauthorizedError") { - console.log("\n📋 Authorization required. Opening browser..."); - console.log( - " (If browser doesn't open, check the terminal for the URL)\n", - ); - - // SDK workaround: retrieve auth code captured during redirectToAuthorization() - const pendingAuth = authProvider.getPendingAuthCode(); - - if (pendingAuth?.code) { - console.log("\n✅ Authorization callback received!"); - console.log(" Code:", pendingAuth.code); - console.log(" State:", pendingAuth.state); - - console.log("\n🔄 Exchanging authorization code for access token..."); - - await transport.finishAuth(pendingAuth.code); - - console.log("\n✅ Token exchange successful!"); - console.log("\n🔌 Creating new connection with authentication..."); - - // SDK constraint: transport cannot be reused after finishAuth() - const newTransport = new StreamableHTTPClientTransport(serverUrl, { - authProvider, - }); - const newClient = new Client( - { - name: "oauth-callback-example", - version: "1.0.0", - }, - { - capabilities: {}, - }, - ); - - await newClient.connect(newTransport); - console.log( - "\n🎉 Successfully authenticated with Notion MCP server!", - ); - - await listServerCapabilities(newClient); - await newClient.close(); - } else { - throw new Error("Failed to get authorization code"); - } - } else { - throw error; - } - } + await connectWithOAuthRetry(client, serverUrl, authProvider); + + console.log("\n🎉 Successfully connected to Notion MCP server!"); + await listServerCapabilities(client); + await client.close(); console.log("\n✨ OAuth flow completed successfully!"); process.exit(0); } catch (error) { if (error instanceof Error) { - if ( - error.message.includes("Unauthorized") || - error.message.includes("401") - ) { - console.log( - "\n⚠️ Authorization required. Please check the browser for the authorization page.", - ); - } else { - console.error("\n❌ Failed to authenticate:", error.message); - } + console.error("\n❌ Failed to authenticate:", error.message); } else { console.error("\n❌ Unexpected error:", error); } diff --git a/package.json b/package.json index 66c6a95..f38f19f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oauth-callback", - "version": "2.1.1", + "version": "2.2.0", "description": "Lightweight OAuth 2.0 callback handler for Node.js, Deno, and Bun with built-in browser flow and MCP SDK integration", "keywords": [ "oauth", @@ -104,7 +104,7 @@ }, "scripts": { "build:templates": "bun run templates/build.ts", - "build": "bun run build:templates && bun build ./src/index.ts --outdir=./dist --target=node && bun build ./src/mcp.ts --outdir=./dist --target=node && tsc --declaration --emitDeclarationOnly --outDir ./dist", + "build": "bun run build:templates && bun build ./src/index.ts --outdir=./dist --target=node && bun build ./src/mcp.ts --outdir=./dist --target=node --external=@modelcontextprotocol/sdk && tsc --declaration --emitDeclarationOnly --outDir ./dist", "clean": "rm -rf dist", "test": "bun test", "test:login": "bun run scripts/test-login.ts", diff --git a/src/auth/browser-auth.test.ts b/src/auth/browser-auth.test.ts index 1fcef81..ab3719b 100644 --- a/src/auth/browser-auth.test.ts +++ b/src/auth/browser-auth.test.ts @@ -40,8 +40,7 @@ describe("browserAuth", () => { expect(metadata.redirect_uris).toEqual([ "http://127.0.0.1:8080/oauth/callback", ]); - expect(metadata.grant_types).toContain("authorization_code"); - expect(metadata.grant_types).toContain("refresh_token"); + expect(metadata.grant_types).toEqual(["authorization_code"]); expect(metadata.response_types).toEqual(["code"]); expect(metadata.scope).toBe("read write"); expect(metadata.token_endpoint_auth_method).toBe("client_secret_post"); @@ -79,7 +78,7 @@ describe("browserAuth", () => { let clientInfo = await provider.clientInformation(); expect(clientInfo).toBeUndefined(); - // Save client information + // Save client information (simulates DCR response) await provider.saveClientInformation!({ redirect_uris: ["http://localhost:9999/callback"], client_id: "dynamic-client-id", @@ -93,6 +92,10 @@ describe("browserAuth", () => { client_id: "dynamic-client-id", client_secret: "dynamic-client-secret", }); + + // clientMetadata must remain stable after DCR - auth method stays "none" + // because no clientSecret was provided at construction time + expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none"); }); test("saves and retrieves tokens", async () => { @@ -124,6 +127,21 @@ describe("browserAuth", () => { expect(stored?.refreshToken).toBe("test-refresh-token"); }); + test("returns undefined for expired tokens (triggers SDK re-auth)", async () => { + const store = inMemoryStore(); + const provider = browserAuth({ store, storeKey: "test-tokens" }); + + // Inject expired token directly into store (expiresAt in the past) + await store.set("test-tokens", { + accessToken: "expired-token", + expiresAt: Date.now() - 1000, // 1 second ago + }); + + // Provider should return undefined, signaling SDK to re-authenticate + const tokens = await provider.tokens(); + expect(tokens).toBeUndefined(); + }); + test("saves and retrieves code verifier", async () => { const provider = browserAuth(); @@ -172,90 +190,182 @@ describe("browserAuth", () => { await expect(provider.codeVerifier()).rejects.toThrow(); }); - test("getPendingAuthCode is single-use", () => { - const provider = browserAuth() as any; - - // Initially undefined - expect(provider.getPendingAuthCode()).toBeUndefined(); + test("invalidateCredentials('all') only clears own storeKey, not other keys", async () => { + const store = inMemoryStore(); - // Set pending auth code - provider._pendingAuthCode = "test-code"; - provider._pendingAuthState = "test-state"; + // Store data under a different key (simulating another provider instance) + await store.set("other-provider-tokens", { + accessToken: "other-access-token", + refreshToken: "other-refresh-token", + }); - // First call returns the code - const result = provider.getPendingAuthCode(); - expect(result).toEqual({ - code: "test-code", - state: "test-state", + const provider = browserAuth({ store, storeKey: "my-tokens" }); + await provider.saveTokens({ + access_token: "my-access-token", + token_type: "Bearer", }); - expect(provider._isExchangingCode).toBe(true); - /** Verify single-use security constraint. */ - expect(provider.getPendingAuthCode()).toBeUndefined(); - }); + // Invalidate all credentials for this provider + await provider.invalidateCredentials!("all"); - test("preserves client info during token exchange", async () => { - const provider = browserAuth() as any; + // Own tokens cleared + expect(await store.get("my-tokens")).toBeNull(); - // Set up initial state - await provider.saveClientInformation({ - redirect_uris: ["http://localhost:9999/callback"], - client_id: "test-client", - client_secret: "test-secret", - client_id_issued_at: Date.now(), - }); + // Other provider's data untouched + const otherTokens = await store.get("other-provider-tokens"); + expect(otherTokens?.accessToken).toBe("other-access-token"); + }); +}); + +describe("browserAuth concurrency", () => { + test("serializes concurrent auth attempts - exchanges code once", async () => { + let exchangeCallCount = 0; + let resolveAuth: (v?: unknown) => void; + const authBlocked = new Promise((r) => (resolveAuth = r)); + + const { mock } = await import("bun:test"); + mock.module("../index", () => ({ + getAuthCode: async () => { + await authBlocked; + return { code: "test-code" }; + }, + })); + + mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ + exchangeAuthorization: async () => { + exchangeCallCount++; + return { access_token: "test-token", token_type: "Bearer" }; + }, + discoverAuthorizationServerMetadata: async () => undefined, + })); + + const { browserAuth: mockedBrowserAuth } = await import("./browser-auth"); + const provider = mockedBrowserAuth({ clientId: "test-client" }); await provider.saveCodeVerifier("test-verifier"); - // Simulate token exchange - provider._isExchangingCode = true; + const authUrl = new URL("https://example.com/auth"); - /** Test SDK workaround: invalidate("all") during exchange preserves state. */ - await provider.invalidateCredentials("all"); + // Start concurrent auth attempts + const first = provider.redirectToAuthorization(authUrl); + const second = provider.redirectToAuthorization(authUrl); - // Client info and verifier should be preserved - expect(await provider.clientInformation()).toBeDefined(); - expect(await provider.codeVerifier()).toBe("test-verifier"); + // Unblock auth flow + resolveAuth!(); + + // Both should resolve without error + await Promise.all([first, second]); + + // Contract: token exchange happens exactly once (second call reuses first result) + expect(exchangeCallCount).toBe(1); + + // Tokens are saved + const tokens = await provider.tokens(); + expect(tokens?.access_token).toBe("test-token"); }); }); -describe("browserAuth with retries", () => { - test("handles cleanup of pending auth state on timeout", () => { - const provider = browserAuth() as any; +describe("browserAuth state validation", () => { + test("rejects callback with mismatched state", async () => { + const { mock } = await import("bun:test"); + mock.module("../index", () => ({ + getAuthCode: async () => ({ + code: "test-code", + state: "wrong-state", + }), + })); - // Simulate setting pending auth code - provider._pendingAuthCode = "test-code"; - provider._pendingAuthState = "test-state"; + const { browserAuth: mockedBrowserAuth } = await import("./browser-auth"); + const provider = mockedBrowserAuth({ clientId: "test-client" }); - // Get the code (marks as exchanging) - const result = provider.getPendingAuthCode(); - expect(result).toEqual({ - code: "test-code", - state: "test-state", - }); + const authUrl = new URL("https://example.com/auth?state=expected-state"); - /** Verify cleanup for security. */ - expect(provider._pendingAuthCode).toBeUndefined(); - expect(provider._pendingAuthState).toBeUndefined(); + await expect(provider.redirectToAuthorization(authUrl)).rejects.toThrow( + "OAuth state mismatch", + ); }); - test("ensures only one auth flow at a time", async () => { - const provider = browserAuth() as any; + test("accepts callback with matching state", async () => { + const { mock } = await import("bun:test"); + mock.module("../index", () => ({ + getAuthCode: async () => ({ + code: "test-code", + state: "correct-state", + }), + })); + mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ + exchangeAuthorization: async () => ({ + access_token: "test-token", + token_type: "Bearer", + }), + discoverAuthorizationServerMetadata: async () => undefined, + })); + + const { browserAuth: mockedBrowserAuth } = await import("./browser-auth"); + const provider = mockedBrowserAuth({ clientId: "test-client" }); + await provider.saveCodeVerifier("test-verifier"); - // Mock auth in progress - let resolveAuth: () => void; - provider._authInProgress = new Promise((resolve) => { - resolveAuth = () => resolve(); + const authUrl = new URL("https://example.com/auth?state=correct-state"); + + // Should not throw + await provider.redirectToAuthorization(authUrl); + expect(await provider.tokens()).toBeDefined(); + }); + + test("skips state validation when no state in authorization URL", async () => { + const { mock } = await import("bun:test"); + mock.module("../index", () => ({ + getAuthCode: async () => ({ + code: "test-code", + // No state in callback (legacy flow) + }), + })); + mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ + exchangeAuthorization: async () => ({ + access_token: "test-token", + token_type: "Bearer", + }), + discoverAuthorizationServerMetadata: async () => undefined, + })); + + const { browserAuth: mockedBrowserAuth } = await import("./browser-auth"); + const provider = mockedBrowserAuth({ clientId: "test-client" }); + await provider.saveCodeVerifier("test-verifier"); + + // No state in auth URL + const authUrl = new URL("https://example.com/auth"); + + // Should not throw + await provider.redirectToAuthorization(authUrl); + expect(await provider.tokens()).toBeDefined(); + }); + + test("uses authServerUrl when token endpoint differs from authorization origin", async () => { + let capturedAuthServerUrl: URL | undefined; + + const { mock } = await import("bun:test"); + mock.module("../index", () => ({ + getAuthCode: async () => ({ code: "test-code" }), + })); + mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ + exchangeAuthorization: async (serverUrl: URL) => { + capturedAuthServerUrl = serverUrl; + return { access_token: "test-token", token_type: "Bearer" }; + }, + discoverAuthorizationServerMetadata: async () => undefined, + })); + + const { browserAuth: mockedBrowserAuth } = await import("./browser-auth"); + const provider = mockedBrowserAuth({ + clientId: "test-client", + // Authorization on accounts.example.com, token endpoint on auth.example.com + authServerUrl: "https://auth.example.com", }); + await provider.saveCodeVerifier("test-verifier"); - /** Second attempt should queue behind first. */ - const secondAttempt = provider.redirectToAuthorization( - new URL("https://example.com/auth"), + await provider.redirectToAuthorization( + new URL("https://accounts.example.com/authorize"), ); - // Resolve first auth - resolveAuth!(); - - // Second attempt should complete without error - await expect(secondAttempt).resolves.toBeUndefined(); + expect(capturedAuthServerUrl?.origin).toBe("https://auth.example.com"); }); }); diff --git a/src/auth/browser-auth.ts b/src/auth/browser-auth.ts index 8cf30a1..e97e59d 100644 --- a/src/auth/browser-auth.ts +++ b/src/auth/browser-auth.ts @@ -2,18 +2,22 @@ /* SPDX-License-Identifier: MIT */ import { randomBytes } from "node:crypto"; -import type { - BrowserAuthOptions, - TokenStore, - OAuthStore, - Tokens, - ClientInfo, - OAuthSession, +import { + OAuthStoreBrand, + type BrowserAuthOptions, + type TokenStore, + type OAuthStore, + type Tokens, + type ClientInfo, } from "../mcp-types"; import { calculateExpiry } from "../utils/token"; import { inMemoryStore } from "../storage/memory"; import { getAuthCode } from "../index"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import { + exchangeAuthorization, + discoverAuthorizationServerMetadata, +} from "@modelcontextprotocol/sdk/client/auth.js"; import type { OAuthClientInformation, OAuthClientInformationFull, @@ -56,6 +60,7 @@ class BrowserOAuthProvider implements OAuthClientProvider { private readonly _hostname: string; private readonly _callbackPath: string; private readonly _authTimeout: number; + private readonly _redirectUrl: string; private readonly _launch?: (url: string) => unknown; private readonly _clientId?: string; private readonly _clientSecret?: string; @@ -63,14 +68,13 @@ class BrowserOAuthProvider implements OAuthClientProvider { private readonly _successHtml?: string; private readonly _errorHtml?: string; private readonly _onRequest?: (req: Request) => void; + private readonly _authServerUrl?: URL; /** Mutable OAuth state. Protected by serialization locks. */ private _clientInfo?: OAuthClientInformationFull; private _tokens?: OAuthTokens; + private _expiresAt?: number; // Absolute expiry time in ms private _codeVerifier?: string; - private _pendingAuthCode?: string; - private _pendingAuthState?: string; - private _isExchangingCode = false; private _tokensLoaded = false; private _loadingTokens?: Promise; private _authInProgress?: Promise; @@ -82,6 +86,7 @@ class BrowserOAuthProvider implements OAuthClientProvider { this._hostname = options.hostname ?? "localhost"; this._callbackPath = options.callbackPath ?? "/callback"; this._authTimeout = options.authTimeout ?? 300000; + this._redirectUrl = `http://${this._hostname}:${this._port}${this._callbackPath}`; this._launch = options.launch; this._clientId = options.clientId; this._clientSecret = options.clientSecret; @@ -90,6 +95,9 @@ class BrowserOAuthProvider implements OAuthClientProvider { this._successHtml = options.successHtml; this._errorHtml = options.errorHtml; this._onRequest = options.onRequest; + this._authServerUrl = options.authServerUrl + ? new URL(options.authServerUrl) + : undefined; } private async _ensureTokensLoaded(): Promise { @@ -107,34 +115,36 @@ class BrowserOAuthProvider implements OAuthClientProvider { // Load tokens const stored = await this._store.get(this._storeKey); if (stored) { + this._expiresAt = stored.expiresAt; + // SDK doesn't inspect expires_in from tokens() - we handle expiry via expiresAt this._tokens = { access_token: stored.accessToken, token_type: "Bearer", refresh_token: stored.refreshToken, - expires_in: stored.expiresAt - ? Math.floor((stored.expiresAt - Date.now()) / 1000) - : undefined, scope: stored.scope, }; } - // Load client info if using extended store if (this._isOAuthStore(this._store)) { - const clientInfo = await this._store.getClient(this._storeKey); - if (clientInfo?.clientId) { - this._clientInfo = { - client_id: clientInfo.clientId, - client_secret: clientInfo.clientSecret, - client_id_issued_at: clientInfo.clientIdIssuedAt, - client_secret_expires_at: clientInfo.clientSecretExpiresAt, - redirect_uris: [this.redirectUrl], - }; + // Load DCR client only if no static clientId is configured. + // Static clientId takes precedence; persisted DCR client is ignored. + if (!this._clientId) { + const clientInfo = await this._store.getClient(this._storeKey); + if (clientInfo?.clientId) { + this._clientInfo = { + client_id: clientInfo.clientId, + client_secret: clientInfo.clientSecret, + client_id_issued_at: clientInfo.clientIdIssuedAt, + client_secret_expires_at: clientInfo.clientSecretExpiresAt, + redirect_uris: [this.redirectUrl], + }; + } } - // Load session state - const session = await this._store.getSession(this._storeKey); - if (session?.codeVerifier) { - this._codeVerifier = session.codeVerifier; + // Load PKCE verifier for crash recovery + const verifier = await this._store.getCodeVerifier(this._storeKey); + if (verifier) { + this._codeVerifier = verifier; } } @@ -145,24 +155,22 @@ class BrowserOAuthProvider implements OAuthClientProvider { } } - private _isOAuthStore(store: any): store is OAuthStore { - return ( - typeof store.getClient === "function" && - typeof store.setClient === "function" && - typeof store.getSession === "function" - ); + private _isOAuthStore(store: TokenStore): store is OAuthStore { + return OAuthStoreBrand in store; } get redirectUrl(): string { - return `http://${this._hostname}:${this._port}${this._callbackPath}`; + return this._redirectUrl; } get clientMetadata(): OAuthClientMetadata { + // Auth method is fixed based on whether clientSecret was provided at construction. + // Don't check _clientInfo.client_secret here - metadata must be stable for DCR. return { client_name: "OAuth Callback Handler", client_uri: "https://github.com/kriasoft/oauth-callback", redirect_uris: [this.redirectUrl], - grant_types: ["authorization_code", "refresh_token"], + grant_types: ["authorization_code"], response_types: ["code"], scope: this._scope, token_endpoint_auth_method: this._clientSecret @@ -218,10 +226,8 @@ class BrowserOAuthProvider implements OAuthClientProvider { return undefined; } - // Check expiry using stored expiresAt from initial token response - const stored = await this._store.get(this._storeKey); - if (stored?.expiresAt && Date.now() >= stored.expiresAt - 60000) { - // Token expired (with 60s buffer). Refresh not yet implemented — trigger re-auth. + // Return undefined when expired (with 60s buffer) to signal MCP SDK to re-authenticate + if (this._expiresAt && Date.now() >= this._expiresAt - 60000) { return undefined; } @@ -230,28 +236,41 @@ class BrowserOAuthProvider implements OAuthClientProvider { async saveTokens(tokens: OAuthTokens): Promise { this._tokens = tokens; + this._expiresAt = tokens.expires_in + ? calculateExpiry(tokens.expires_in) + : undefined; this._tokensLoaded = true; const storedTokens: Tokens = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, - expiresAt: tokens.expires_in - ? calculateExpiry(tokens.expires_in) - : undefined, + expiresAt: this._expiresAt, scope: tokens.scope, }; await this._store.set(this._storeKey, storedTokens); } + /** + * Completes the full OAuth authorization flow synchronously. + * + * Despite the name (dictated by the MCP SDK interface), this method does more + * than redirect: it launches the browser, captures the callback, validates state, + * exchanges the authorization code for tokens, and persists them to storage. + * + * Concurrent calls are serialized: subsequent callers wait for and share the + * result (or error) of the in-flight attempt. + * + * @see ADR-002 for rationale on immediate token exchange + */ async redirectToAuthorization(authorizationUrl: URL): Promise { - /** Serialize concurrent auth attempts to prevent race conditions. */ + // Concurrent callers share both success and failure of the in-flight attempt. if (this._authInProgress) { await this._authInProgress; return; } - this._authInProgress = this._doAuthorization(authorizationUrl); + this._authInProgress = this._completeAuthorizationFlow(authorizationUrl); try { await this._authInProgress; } finally { @@ -259,7 +278,9 @@ class BrowserOAuthProvider implements OAuthClientProvider { } } - private async _doAuthorization(authorizationUrl: URL): Promise { + private async _completeAuthorizationFlow( + authorizationUrl: URL, + ): Promise { // Use managed mode (with launch) or headless mode based on _launch presence const baseOptions = { port: this._port, @@ -281,29 +302,97 @@ class BrowserOAuthProvider implements OAuthClientProvider { : baseOptions, ); - /** Cache auth code for SDK's separate token exchange call. */ - this._pendingAuthCode = result.code; - this._pendingAuthState = result.state; + // getAuthCode() throws OAuthError if result.error exists; this is a defensive + // check for the edge case where neither code nor error is present. + if (!result.code) { + throw new Error("No authorization code received"); + } - /** Auto-cleanup stale auth codes after timeout to prevent leaks. */ - setTimeout(() => { - if (this._pendingAuthCode === result.code) { - this._pendingAuthCode = undefined; - this._pendingAuthState = undefined; + // Validate state from callback against the URL we were given (CSRF protection). + // Works regardless of whether state() was used - validates whatever is in the URL. + const expectedState = authorizationUrl.searchParams.get("state"); + if (expectedState && result.state !== expectedState) { + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + + /** + * Exchange auth code for tokens immediately after capture. + * + * The MCP SDK's auth() returns 'REDIRECT' after redirectToAuthorization() + * without re-checking for tokens. By exchanging now, subsequent auth calls + * will find valid tokens and return 'AUTHORIZED'. + * + * This enables synchronous browser flows for CLI/desktop apps where the + * callback is captured in-process rather than via page redirect. + */ + await this._exchangeCodeForTokens(authorizationUrl, result.code); + } + + /** + * Exchange authorization code for tokens and persist them. + * + * The MCP SDK's auth() returns 'REDIRECT' after redirectToAuthorization() without + * re-checking for tokens, causing the transport to throw UnauthorizedError. However, + * tokens are now saved, so a subsequent connect() attempt will succeed. + * + * @see ADR-002 for the recommended retry pattern with a fresh transport + */ + private async _exchangeCodeForTokens( + authorizationUrl: URL, + code: string, + ): Promise { + // Derive auth server URL from authorization endpoint origin. + // If the token endpoint is on a different origin, authServerUrl must be explicitly configured. + const authServerUrl = + this._authServerUrl ?? new URL("/", authorizationUrl.origin); + + // Discover token endpoint; non-fatal if .well-known is unavailable + const metadata = await discoverAuthorizationServerMetadata( + authServerUrl, + ).catch(() => undefined); + + const clientInfo = await this.clientInformation(); + if (!clientInfo) { + throw new Error( + "Client information required for token exchange. " + + "Provide clientId in options or ensure DCR succeeded.", + ); + } + + if (!this._codeVerifier) { + throw new Error("Code verifier required for token exchange"); + } + + let tokens: OAuthTokens; + try { + tokens = await exchangeAuthorization(authServerUrl, { + metadata, + clientInformation: clientInfo, + authorizationCode: code, + codeVerifier: this._codeVerifier, + redirectUri: this.redirectUrl, + }); + } catch (error) { + // Improve error message when discovery failed and authServerUrl wasn't explicitly set. + // This helps users diagnose cases where auth and token endpoints are on different origins. + if (!this._authServerUrl && !metadata) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error( + `Token exchange failed: ${msg}. ` + + `If the token endpoint differs from ${authorizationUrl.origin}, set authServerUrl explicitly.`, + ); } - }, this._authTimeout); + throw error; + } + + await this.saveTokens(tokens); } async saveCodeVerifier(codeVerifier: string): Promise { this._codeVerifier = codeVerifier; - // Persist session state if using extended store if (this._isOAuthStore(this._store)) { - const session: OAuthSession = { - codeVerifier, - state: this._pendingAuthState, - }; - await this._store.setSession(this._storeKey, session); + await this._store.setCodeVerifier(this._storeKey, codeVerifier); } } @@ -317,90 +406,45 @@ class BrowserOAuthProvider implements OAuthClientProvider { async invalidateCredentials( scope: "all" | "client" | "tokens" | "verifier", ): Promise { - /** - * SDK behavioral dependency: The MCP SDK may call invalidate("all") during - * token exchange if the authorization server returns an error. The call - * sequence we protect against: - * - * 1. SDK calls getPendingAuthCode() → sets _isExchangingCode = true - * 2. SDK calls codeVerifier() to build token request - * 3. SDK sends token exchange request to authorization server - * 4. Server returns error → SDK calls invalidateCredentials("all") - * 5. SDK retries from step 2, but verifier is gone → permanent failure - * - * Without this guard, step 4 would clear the verifier needed for step 5. - * The flag is reset when SDK calls invalidate("client") after exchange. - */ - if (scope === "all" && this._isExchangingCode) { - /** Only clear tokens; preserve client and verifier for ongoing exchange. */ - this._tokens = undefined; - await this._store.delete(this._storeKey); - return; - } - - if (this._isExchangingCode && (scope === "client" || scope === "all")) { - this._isExchangingCode = false; - } - switch (scope) { case "all": + // Scoped deletion: only clear data for this storeKey, not the entire store this._clientInfo = undefined; this._tokens = undefined; + this._expiresAt = undefined; this._codeVerifier = undefined; this._tokensLoaded = false; - await this._store.clear(); + await this._store.delete(this._storeKey); + if (this._isOAuthStore(this._store)) { + await this._store.deleteClient(this._storeKey); + await this._store.deleteCodeVerifier(this._storeKey); + } break; case "client": this._clientInfo = undefined; if (this._isOAuthStore(this._store)) { - // Empty clientId signals deletion (OAuthStore has no deleteClient method) - await this._store.setClient(this._storeKey, { clientId: "" }); + await this._store.deleteClient(this._storeKey); } break; case "tokens": this._tokens = undefined; + this._expiresAt = undefined; await this._store.delete(this._storeKey); break; case "verifier": this._codeVerifier = undefined; if (this._isOAuthStore(this._store)) { - // Empty session signals deletion (OAuthStore has no deleteSession method) - await this._store.setSession(this._storeKey, {}); + await this._store.deleteCodeVerifier(this._storeKey); } break; } } + /** Delegates RFC 8707 resource validation to SDK default behavior. */ async validateResourceURL( _serverUrl: string | URL, _resource?: string, ): Promise { return undefined; } - - /** - * Retrieves pending auth code from browser callback. - * @returns Auth code and state, or undefined if none pending - * @sideeffect Marks exchange in progress for invalidate() workaround - * @security Single-use: clears code after retrieval - */ - getPendingAuthCode(): { code?: string; state?: string } | undefined { - if (this._pendingAuthCode) { - const result = { - code: this._pendingAuthCode, - state: this._pendingAuthState, - }; - - /** Protect verifier from SDK's invalidate("all") during exchange. */ - this._isExchangingCode = true; - - this._pendingAuthCode = undefined; - this._pendingAuthState = undefined; - - return result; - } - return undefined; - } - - /** SDK constraint: addClientAuthentication() must not exist on this class. */ } diff --git a/src/mcp-types.ts b/src/mcp-types.ts index 0f380d7..76c904f 100644 --- a/src/mcp-types.ts +++ b/src/mcp-types.ts @@ -23,36 +23,34 @@ export interface ClientInfo { clientSecretExpiresAt?: number; } -/** - * Active OAuth flow state for crash recovery. - * Preserves PKCE verifier and state across process restarts. - */ -export interface OAuthSession { - codeVerifier?: string; - state?: string; -} - /** * Minimal storage interface for OAuth tokens. - * @invariant Implementations must be thread-safe within process. * @invariant Keys are scoped to avoid collisions between multiple OAuth flows. */ export interface TokenStore { get(key: string): Promise; set(key: string, tokens: Tokens): Promise; delete(key: string): Promise; - clear(): Promise; } +/** Brand symbol for OAuthStore type detection. */ +export const OAuthStoreBrand: unique symbol = Symbol("OAuthStore"); + /** - * Full OAuth state storage including client registration and session. - * Enables recovery from crashes mid-flow and reuse of dynamic registration. + * Extended storage with client registration and PKCE verifier persistence. + * Enables crash recovery mid-flow and reuse of dynamic registration. + * @invariant Implementations must include `[OAuthStoreBrand]: true` property. */ export interface OAuthStore extends TokenStore { + readonly [OAuthStoreBrand]: true; + getClient(key: string): Promise; setClient(key: string, client: ClientInfo): Promise; - getSession(key: string): Promise; - setSession(key: string, session: OAuthSession): Promise; + deleteClient(key: string): Promise; + + getCodeVerifier(key: string): Promise; + setCodeVerifier(key: string, verifier: string): Promise; + deleteCodeVerifier(key: string): Promise; } /** @@ -61,8 +59,16 @@ export interface OAuthStore extends TokenStore { * @see https://datatracker.ietf.org/doc/html/rfc8252 */ export interface BrowserAuthOptions { - /** Pre-registered OAuth client credentials. Omit for dynamic registration. */ + /** + * Pre-registered OAuth client ID. Omit to use dynamic client registration. + * When provided, takes precedence over any DCR-obtained client. + */ clientId?: string; + /** + * Pre-registered client secret (for confidential clients). + * Determines auth method for token requests: `client_secret_post` if set, `none` otherwise. + * This is fixed at construction - DCR-obtained secrets don't change the auth method. + */ clientSecret?: string; scope?: string; @@ -87,4 +93,11 @@ export interface BrowserAuthOptions { /** Request inspection callback for debugging OAuth flows. */ onRequest?: (req: Request) => void; + + /** + * Authorization server base URL (issuer) for token endpoint discovery. + * Pass the origin (e.g., `https://auth.example.com`), not `/token`. + * Defaults to the authorization URL origin. Discovery failures are non-fatal. + */ + authServerUrl?: string | URL; } diff --git a/src/mcp.ts b/src/mcp.ts index c561897..1dacb30 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -13,11 +13,11 @@ export { browserAuth } from "./auth/browser-auth"; export { inMemoryStore } from "./storage/memory"; export { fileStore } from "./storage/file"; +export { OAuthStoreBrand } from "./mcp-types"; export type { BrowserAuthOptions, Tokens, TokenStore, ClientInfo, - OAuthSession, OAuthStore, } from "./mcp-types"; diff --git a/src/storage/file.ts b/src/storage/file.ts index 2a55838..c82ff4e 100644 --- a/src/storage/file.ts +++ b/src/storage/file.ts @@ -8,8 +8,8 @@ import type { TokenStore, Tokens } from "../mcp-types"; /** * Persistent file-based token storage. + * Not safe for concurrent access across multiple processes. * Default: ~/.mcp/tokens.json - * WARNING: Not safe for concurrent access across processes. */ export function fileStore(filepath?: string): TokenStore { const file = filepath ?? path.join(os.homedir(), ".mcp", "tokens.json"); @@ -29,8 +29,9 @@ export function fileStore(filepath?: string): TokenStore { async function writeStore(data: Record) { await ensureDir(); - // TODO: Atomic write via temp file + rename - await fs.writeFile(file, JSON.stringify(data, null, 2), "utf-8"); + const tmp = `${file}.tmp.${process.pid}`; + await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf-8"); + await fs.rename(tmp, file); } return { @@ -50,9 +51,5 @@ export function fileStore(filepath?: string): TokenStore { delete store[key]; await writeStore(store); }, - - async clear(): Promise { - await writeStore({}); - }, }; } diff --git a/src/storage/memory.ts b/src/storage/memory.ts index b76b1f3..9b15bd1 100644 --- a/src/storage/memory.ts +++ b/src/storage/memory.ts @@ -5,7 +5,7 @@ import type { TokenStore, Tokens } from "../mcp-types"; /** * Ephemeral in-memory token storage. - * Tokens lost on process restart. Safe for concurrent access within process. + * Tokens lost on process restart. */ export function inMemoryStore(): TokenStore { const store = new Map(); @@ -22,9 +22,5 @@ export function inMemoryStore(): TokenStore { async delete(key: string): Promise { store.delete(key); }, - - async clear(): Promise { - store.clear(); - }, }; }