Skip to content

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

typescript
function browserAuth(options?: BrowserAuthOptions): OAuthClientProvider;

Parameters

BrowserAuthOptions

PropertyTypeDefaultDescription
clientIdstringnonePre-registered OAuth client ID
clientSecretstringnonePre-registered OAuth client secret
scopestringnoneOAuth scopes to request. When omitted, the auth server uses its default scope.
portnumber3000Port for local callback server
hostnamestring"localhost"Hostname to bind server to
callbackPathstring"/callback"URL path for OAuth callback
storeTokenStoreinMemoryStore()Token storage implementation
storeKeystring"mcp-tokens"Storage key for token isolation
launch(url: string) => unknownnoneCallback to launch auth URL
authTimeoutnumber300000Auth timeout in ms (5 min)
successHtmlstringbuilt-inCustom success page HTML
errorHtmlstringbuilt-inCustom error page HTML
onRequest(req: Request) => voidnoneRequest logging callback
authServerUrlstring | URLautoBase 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 sessions

Benefits 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)

typescript
interface TokenStore {
  get(key: string): Promise<Tokens | null>;
  set(key: string, tokens: Tokens): Promise<void>;
  delete(key: string): Promise<void>;
}

OAuthStore (Extended)

typescript
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):

typescript
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:

typescript
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:

typescript
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:

  1. Generating a cryptographic code verifier
  2. Sending a hashed challenge with the authorization request
  3. 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:

typescript
// 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

File storage uses restrictive permissions:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
// Force re-authentication by clearing tokens
await authProvider.invalidateCredentials("tokens");

Testing

Unit Testing

Mock the OAuth provider for tests:

typescript
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:

typescript
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
typescript
import open from "open";

// Use a different port
const authProvider = browserAuth({
  launch: open,
  port: 8080, // Try alternative port
});
Tokens Not Persisting
typescript
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:

typescript
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
typescript
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:

MethodStatusNotes
redirectToAuthorization✅ Fully supportedCompletes full OAuth flow (browser → callback → token exchange → persist)
tokens✅ Fully supportedReturns current tokens
saveTokens✅ Fully supportedPersists to storage
clientInformation✅ Fully supportedReturns client credentials
saveClientInformation✅ Fully supportedStores DCR results
state✅ Fully supportedGenerates secure state
codeVerifier✅ Fully supportedPKCE verifier
saveCodeVerifier✅ Fully supportedStores PKCE verifier
invalidateCredentials✅ Fully supportedClears stored data
validateResourceURL✅ Returns undefinedNot applicable

Migration Guide

From Manual OAuth to browserAuth

typescript
// 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

typescript
// Before: Tokens lost on restart
const authProvider = browserAuth({ launch: open });

// After: Tokens persist across sessions
const authProvider = browserAuth({
  launch: open,
  store: fileStore(),
});