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 and token storage through a browser-based authorization flow. Expired tokens trigger re-authentication; refresh tokens are not used.
Function Signature
function browserAuth(options?: BrowserAuthOptions): OAuthClientProvider;Parameters
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. 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
Returns an OAuthClientProvider instance that implements the MCP SDK authentication interface:
interface OAuthClientProvider {
// Completes full OAuth flow: browser → callback → token exchange → persist
redirectToAuthorization(authorizationUrl: URL): Promise<void>;
// Token storage
tokens(): Promise<OAuthTokens | undefined>;
saveTokens(tokens: OAuthTokens): Promise<void>;
// Dynamic Client Registration support
clientInformation(): Promise<OAuthClientInformation | undefined>;
saveClientInformation(info: OAuthClientInformationFull): Promise<void>;
// PKCE support
codeVerifier(): Promise<string>;
saveCodeVerifier(verifier: string): Promise<void>;
// State management
state(): Promise<string>;
invalidateCredentials(
scope: "all" | "client" | "tokens" | "verifier",
): Promise<void>;
}Basic Usage
Simple MCP Client
The simplest usage with default settings:
import open from "open";
import { browserAuth } from "oauth-callback/mcp";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
// Create OAuth provider - pass open to launch browser
const authProvider = browserAuth({ launch: open });
// Use with MCP transport
const transport = new StreamableHTTPClientTransport(
new URL("https://mcp.example.com"),
{ authProvider },
);
const client = new Client(
{ name: "my-app", version: "1.0.0" },
{ capabilities: {} },
);
await client.connect(transport);With Token Persistence
Store tokens across sessions:
import open from "open";
import { browserAuth, fileStore } from "oauth-callback/mcp";
const authProvider = browserAuth({
launch: open,
store: fileStore(), // Persists to ~/.mcp/tokens.json
scope: "read write",
});Advanced Usage
Pre-Registered OAuth Clients
If you have pre-registered OAuth credentials:
import open from "open";
const authProvider = browserAuth({
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
scope: "read write admin",
launch: open,
store: fileStore(),
});Custom Storage Location
Store tokens in a specific location:
import open from "open";
import { browserAuth, fileStore } from "oauth-callback/mcp";
const authProvider = browserAuth({
launch: open,
store: fileStore("/path/to/my-tokens.json"),
storeKey: "my-app-production", // Namespace for multiple environments
});Custom Port and Callback Path
Configure the callback server:
import open from "open";
const authProvider = browserAuth({
port: 8080,
hostname: "127.0.0.1",
callbackPath: "/oauth/callback",
launch: open,
store: fileStore(),
});Redirect URI Configuration
Ensure your OAuth app's redirect URI matches your configuration:
- Configuration:
port: 8080,callbackPath: "/oauth/callback" - Redirect URI:
http://localhost:8080/oauth/callback
Custom HTML Pages
Provide branded callback pages:
import open from "open";
const authProvider = browserAuth({
launch: open,
successHtml: `
<!DOCTYPE html>
<html>
<head>
<title>Success!</title>
<style>
body {
font-family: -apple-system, system-ui, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
margin: 0;
}
.container {
text-align: center;
padding: 2rem;
}
h1 { font-size: 3rem; margin-bottom: 1rem; }
p { font-size: 1.2rem; opacity: 0.9; }
</style>
</head>
<body>
<div class="container">
<h1>🎉 Success!</h1>
<p>Authorization complete. You can close this window.</p>
</div>
</body>
</html>
`,
errorHtml: `
<!DOCTYPE html>
<html>
<body>
<h1>Authorization Failed</h1>
<p>Error: {{error}}</p>
<p>{{error_description}}</p>
</body>
</html>
`,
});Request Logging
Monitor OAuth flow for debugging:
import open from "open";
const authProvider = browserAuth({
launch: open,
onRequest: (req) => {
const url = new URL(req.url);
console.log(`[OAuth] ${req.method} ${url.pathname}`);
if (url.pathname === "/callback") {
console.log("[OAuth] Callback params:", url.searchParams.toString());
}
},
store: fileStore(),
});Headless/CI Environment
Disable browser opening for automated environments:
const authProvider = browserAuth({
launch: () => {}, // Noop - no browser opening
authTimeout: 10000, // Shorter timeout for CI
store: inMemoryStore(),
});Dynamic Client Registration
OAuth Callback supports RFC 7591 Dynamic Client Registration, allowing automatic OAuth client registration:
How It Works
DCR Example
No pre-registration needed:
import open from "open";
// No clientId or clientSecret required!
const authProvider = browserAuth({
scope: "read write",
launch: open,
store: fileStore(), // Persist dynamically registered client
});
// The provider will automatically:
// 1. Register a new OAuth client on first use
// 2. Store the client credentials
// 3. Reuse them for future sessionsBenefits of DCR
- Zero Configuration: Users don't need to manually register OAuth apps
- Automatic Setup: Client registration happens transparently
- Credential Persistence: Registered clients are reused across sessions
- Simplified Distribution: Ship MCP clients without OAuth setup instructions
Token Storage
Storage Interfaces
OAuth Callback provides two storage interfaces:
TokenStore (Basic)
interface TokenStore {
get(key: string): Promise<Tokens | null>;
set(key: string, tokens: Tokens): Promise<void>;
delete(key: string): Promise<void>;
}OAuthStore (Extended)
interface OAuthStore extends TokenStore {
getClient(key: string): Promise<ClientInfo | null>;
setClient(key: string, client: ClientInfo): Promise<void>;
deleteClient(key: string): Promise<void>;
getCodeVerifier(key: string): Promise<string | null>;
setCodeVerifier(key: string, verifier: string): Promise<void>;
deleteCodeVerifier(key: string): Promise<void>;
}Built-in Implementations
In-Memory Store
Ephemeral storage (tokens lost on restart):
import open from "open";
import { browserAuth, inMemoryStore } from "oauth-callback/mcp";
const authProvider = browserAuth({
launch: open,
store: inMemoryStore(),
});Use cases:
- Development and testing
- Short-lived CLI sessions
- Maximum security (no persistence)
File Store
Persistent storage to JSON file:
import open from "open";
import { browserAuth, fileStore } from "oauth-callback/mcp";
// Default location: ~/.mcp/tokens.json
const authProvider = browserAuth({
launch: open,
store: fileStore(),
});
// Custom location
const customAuth = browserAuth({
launch: open,
store: fileStore("/path/to/tokens.json"),
});Use cases:
- Desktop applications
- Long-running services
- Multi-session authentication
Custom Storage Implementation
Implement your own storage backend:
import { TokenStore, Tokens } from "oauth-callback/mcp";
class RedisStore implements TokenStore {
constructor(private redis: RedisClient) {}
async get(key: string): Promise<Tokens | null> {
const data = await this.redis.get(key);
return data ? JSON.parse(data) : null;
}
async set(key: string, tokens: Tokens): Promise<void> {
await this.redis.set(key, JSON.stringify(tokens));
}
async delete(key: string): Promise<void> {
await this.redis.del(key);
}
}
// Use custom store
const authProvider = browserAuth({
launch: open,
store: new RedisStore(redisClient),
});Security Features
PKCE (Proof Key for Code Exchange)
PKCE is always enabled for enhanced security. The MCP SDK handles PKCE automatically through the provider's saveCodeVerifier() and codeVerifier() methods.
PKCE prevents authorization code interception attacks by:
- Generating a cryptographic code verifier
- Sending a hashed challenge with the authorization request
- Proving possession of the verifier during token exchange
State Parameter
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.
For localhost flows, RFC 8252 considers loopback interface binding sufficient for security. State validation adds defense-in-depth.
Token Expiry Management
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:
// 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 expirySecure Storage
File storage uses restrictive permissions:
import open from "open";
// Files are created with mode 0600 (owner read/write only)
const authProvider = browserAuth({
launch: open,
store: fileStore(), // Secure file permissions
});Error Handling
OAuth Errors
The provider handles OAuth-specific errors:
try {
await client.connect(transport);
} catch (error) {
if (error.message.includes("access_denied")) {
console.log("User cancelled authorization");
} else if (error.message.includes("invalid_scope")) {
console.log("Requested scope not available");
} else {
console.error("Connection failed:", error);
}
}Timeout Handling
Configure timeout for different scenarios:
import open from "open";
const authProvider = browserAuth({
launch: open,
authTimeout: 600000, // 10 minutes for first-time setup
});Complete Examples
Notion MCP Integration
Full example with Dynamic Client Registration:
import open from "open";
import { browserAuth, fileStore } from "oauth-callback/mcp";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
async function connectToNotion() {
// No client credentials needed - uses DCR!
const authProvider = browserAuth({
launch: open, // Opens browser for OAuth consent
store: fileStore(), // Persist tokens and client registration
scope: "read write",
onRequest: (req) => {
console.log(`[Notion OAuth] ${new URL(req.url).pathname}`);
},
});
const transport = new StreamableHTTPClientTransport(
new URL("https://mcp.notion.com/mcp"),
{ authProvider },
);
const client = new Client(
{ name: "notion-client", version: "1.0.0" },
{ capabilities: {} },
);
try {
await client.connect(transport);
console.log("Connected to Notion MCP!");
// List available tools
const tools = await client.listTools();
console.log("Available tools:", tools);
// Use a tool
const result = await client.callTool("search", {
query: "meeting notes",
});
console.log("Search results:", result);
} catch (error) {
console.error("Failed to connect:", error);
} finally {
await client.close();
}
}
connectToNotion();Multi-Environment Configuration
Support development, staging, and production:
import open from "open";
import { browserAuth, fileStore, inMemoryStore } from "oauth-callback/mcp";
function createAuthProvider(environment: "dev" | "staging" | "prod") {
const configs = {
dev: {
port: 3000,
launch: open,
store: inMemoryStore(), // No persistence in dev
authTimeout: 60000,
onRequest: (req: Request) => console.log("[DEV]", req.url),
},
staging: {
port: 3001,
launch: open,
store: fileStore("~/.mcp/staging-tokens.json"),
storeKey: "staging",
authTimeout: 120000,
},
prod: {
port: 3002,
launch: open,
store: fileStore("~/.mcp/prod-tokens.json"),
storeKey: "production",
authTimeout: 300000,
clientId: process.env.PROD_CLIENT_ID,
clientSecret: process.env.PROD_CLIENT_SECRET,
},
};
return browserAuth(configs[environment]);
}
// Use appropriate environment
const authProvider = createAuthProvider(
process.env.NODE_ENV as "dev" | "staging" | "prod",
);Handling Expired 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.
For explicit control over re-authentication:
// Force re-authentication by clearing tokens
await authProvider.invalidateCredentials("tokens");Testing
Unit Testing
Mock the OAuth provider for tests:
import { vi, describe, it, expect } from "vitest";
// Create mock provider
const mockAuthProvider = {
redirectToAuthorization: vi.fn(),
tokens: vi.fn().mockResolvedValue({
access_token: "test_token",
token_type: "Bearer",
}),
saveTokens: vi.fn(),
clientInformation: vi.fn(),
saveClientInformation: vi.fn(),
state: vi.fn().mockResolvedValue("test_state"),
codeVerifier: vi.fn().mockResolvedValue("test_verifier"),
saveCodeVerifier: vi.fn(),
invalidateCredentials: vi.fn(),
};
describe("MCP Client", () => {
it("should authenticate with OAuth", async () => {
const transport = new StreamableHTTPClientTransport(
new URL("https://test.example.com"),
{ authProvider: mockAuthProvider },
);
const client = new Client(
{ name: "test", version: "1.0.0" },
{ capabilities: {} },
);
await client.connect(transport);
expect(mockAuthProvider.tokens).toHaveBeenCalled();
});
});Integration Testing
Test with a mock OAuth server:
import { browserAuth, inMemoryStore } from "oauth-callback/mcp";
import { createMockOAuthServer } from "./test-utils";
describe("OAuth Flow Integration", () => {
let mockServer: MockOAuthServer;
beforeAll(async () => {
mockServer = createMockOAuthServer();
await mockServer.start();
});
afterAll(async () => {
await mockServer.stop();
});
it("should complete full OAuth flow", async () => {
const authProvider = browserAuth({
port: 3001,
launch: () => {}, // Noop - don't open browser in tests
store: inMemoryStore(),
});
// Simulate OAuth flow
await authProvider.redirectToAuthorization(
new URL(`http://localhost:${mockServer.port}/authorize`),
);
const tokens = await authProvider.tokens();
expect(tokens?.access_token).toBeDefined();
});
});Troubleshooting
Common Issues
Port Already in Use
import open from "open";
// Use a different port
const authProvider = browserAuth({
launch: open,
port: 8080, // Try alternative port
});Tokens Not Persisting
import open from "open";
// Ensure you're using file store, not in-memory
const authProvider = browserAuth({
launch: open,
store: fileStore(), // ✅ Persistent
// store: inMemoryStore() // ❌ Lost on restart
});DCR Not Working
Some servers may not support Dynamic Client Registration:
import open from "open";
// Fallback to pre-registered credentials
const authProvider = browserAuth({
launch: open,
clientId: "your-client-id",
clientSecret: "your-client-secret",
});Browser Not Opening
import open from "open";
// Conditionally open browser based on environment
const authProvider = browserAuth({
launch: process.env.CI ? () => {} : open,
});API Compatibility
The browserAuth provider implements the MCP SDK's OAuthClientProvider interface:
| 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
From Manual OAuth to browserAuth
// Before: Manual OAuth implementation
const code = await getAuthCode(authUrl);
const tokens = await exchangeCodeForTokens(code);
// Manual token storage and refresh...
// After: Using browserAuth
const authProvider = browserAuth({
launch: open,
store: fileStore(),
});
// Automatic handling of entire OAuth flow!From In-Memory to Persistent Storage
// Before: Tokens lost on restart
const authProvider = browserAuth({ launch: open });
// After: Tokens persist across sessions
const authProvider = browserAuth({
launch: open,
store: fileStore(),
});Related APIs
getAuthCode- Low-level OAuth code captureTokenStore- Storage interface documentationOAuthError- OAuth error handling