Skip to main content

OAuth Server & Proxy

The @leanmcp/auth/proxy and @leanmcp/auth/server modules enable you to build OAuth 2.1 authorization servers for your MCP applications. Use them to proxy authentication to external identity providers (Google, GitHub, etc.) while issuing your own tokens.

Features

External Provider Proxy

Authenticate users via Google, GitHub, Azure, and more

RFC 8414 Metadata

Standard OAuth authorization server metadata

RFC 7591 DCR

Dynamic Client Registration for MCP clients

PKCE Required

Enforces PKCE per MCP security requirements

Installation

npm install @leanmcp/auth express

Architecture Overview

The OAuth Proxy:
  1. Receives authorization requests from MCP clients
  2. Redirects users to the external identity provider
  3. Exchanges the IdP’s code for tokens
  4. Maps external tokens/user info to your internal tokens
  5. Returns your tokens to the MCP client

OAuth Proxy

The OAuthProxy class handles the complete OAuth flow with external providers.

Basic Setup

import express from 'express';
import { OAuthProxy, googleProvider, githubProvider } from '@leanmcp/auth/proxy';

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Configure providers
const providers = [
  googleProvider({
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  }),
  githubProvider({
    clientId: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  }),
];

// Create proxy
const proxy = new OAuthProxy({
  baseUrl: 'https://api.example.com/auth',
  sessionSecret: process.env.SESSION_SECRET!,
  providers,
  tokenMapper: async (externalTokens, userInfo, provider) => {
    // Map external tokens to your internal tokens
    return {
      access_token: generateMyToken(userInfo),
      token_type: 'Bearer',
      expires_in: 3600,
    };
  },
});

// Mount routes
const middleware = proxy.createMiddleware();
app.get('/auth/authorize', middleware.authorize);
app.get('/auth/callback', middleware.callback);
app.post('/auth/token', middleware.token);

app.listen(3000);

Configuration

interface OAuthProxyConfig {
  /** Base URL for OAuth endpoints (e.g., https://api.example.com/auth) */
  baseUrl: string;
  
  /** Secret for signing session/state tokens */
  sessionSecret: string;
  
  /** Array of configured OAuth providers */
  providers: OAuthProviderConfig[];
  
  /** Function to map external tokens to your tokens */
  tokenMapper: TokenMapperFunction;
  
  /** Token TTL in seconds (default: 3600) */
  tokenTTL?: number;
}

Token Mapper

The tokenMapper function is called after successful external authentication. Use it to create your internal tokens:
const proxy = new OAuthProxy({
  // ...
  tokenMapper: async (externalTokens, userInfo, provider) => {
    // externalTokens: { access_token, refresh_token, ... } from provider
    // userInfo: { sub, email, name, ... } from provider's userinfo endpoint
    // provider: { id, name, ... } the provider that authenticated
    
    // Look up or create user in your database
    let user = await db.users.findByEmail(userInfo.email);
    if (!user) {
      user = await db.users.create({
        email: userInfo.email,
        name: userInfo.name,
        provider: provider.id,
      });
    }
    
    // Generate your own token
    const token = jwt.sign(
      { sub: user.id, email: user.email },
      process.env.JWT_SECRET!,
      { expiresIn: '1h' }
    );
    
    return {
      access_token: token,
      token_type: 'Bearer',
      expires_in: 3600,
      user_id: user.id,
    };
  },
});

Pre-configured Providers

Import ready-to-use provider configurations:

Google

import { googleProvider } from '@leanmcp/auth/proxy';

googleProvider({
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  scopes: ['openid', 'profile', 'email'], // optional, these are defaults
});

GitHub

import { githubProvider } from '@leanmcp/auth/proxy';

githubProvider({
  clientId: process.env.GITHUB_CLIENT_ID!,
  clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  scopes: ['read:user', 'user:email'], // optional
});

Azure AD

import { azureProvider } from '@leanmcp/auth/proxy';

azureProvider({
  clientId: process.env.AZURE_CLIENT_ID!,
  clientSecret: process.env.AZURE_CLIENT_SECRET!,
  tenant: process.env.AZURE_TENANT_ID!, // or 'common' for multi-tenant
});

GitLab

import { gitlabProvider } from '@leanmcp/auth/proxy';

gitlabProvider({
  clientId: process.env.GITLAB_CLIENT_ID!,
  clientSecret: process.env.GITLAB_CLIENT_SECRET!,
  baseUrl: 'https://gitlab.com', // or self-hosted URL
});

Slack

import { slackProvider } from '@leanmcp/auth/proxy';

slackProvider({
  clientId: process.env.SLACK_CLIENT_ID!,
  clientSecret: process.env.SLACK_CLIENT_SECRET!,
});

Discord

import { discordProvider } from '@leanmcp/auth/proxy';

discordProvider({
  clientId: process.env.DISCORD_CLIENT_ID!,
  clientSecret: process.env.DISCORD_CLIENT_SECRET!,
});

Custom Providers

Use customProvider for any OAuth 2.0 compatible identity provider:
import { customProvider } from '@leanmcp/auth/proxy';

const myProvider = customProvider({
  id: 'my-idp',
  name: 'My Identity Provider',
  authorizationEndpoint: 'https://idp.example.com/oauth/authorize',
  tokenEndpoint: 'https://idp.example.com/oauth/token',
  userInfoEndpoint: 'https://idp.example.com/oauth/userinfo',
  clientId: process.env.MY_IDP_CLIENT_ID!,
  clientSecret: process.env.MY_IDP_CLIENT_SECRET!,
  scopes: ['openid', 'profile', 'email'],
  supportsPkce: true,
});

Provider Configuration

interface OAuthProviderConfig {
  /** Unique identifier for this provider */
  id: string;
  
  /** Display name */
  name: string;
  
  /** OAuth authorization endpoint */
  authorizationEndpoint: string;
  
  /** OAuth token endpoint */
  tokenEndpoint: string;
  
  /** OpenID Connect userinfo endpoint (optional) */
  userInfoEndpoint?: string;
  
  /** Client ID */
  clientId: string;
  
  /** Client secret */
  clientSecret: string;
  
  /** Scopes to request */
  scopes?: string[];
  
  /** Whether provider supports PKCE */
  supportsPkce?: boolean;
}

OAuth Authorization Server

For full MCP OAuth compliance, use OAuthAuthorizationServer which adds RFC 8414 metadata and RFC 7591 Dynamic Client Registration:
import express from 'express';
import { OAuthAuthorizationServer } from '@leanmcp/auth/server';
import { googleProvider } from '@leanmcp/auth/proxy';

const app = express();

const authServer = new OAuthAuthorizationServer({
  issuer: 'https://api.example.com',
  
  // Upstream provider for authentication
  upstreamProvider: googleProvider({
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  }),
  
  // JWT signing secret
  signingSecret: process.env.JWT_SECRET!,
  
  // Enable Dynamic Client Registration
  enableDCR: true,
  
  // Token TTL in seconds
  tokenTTL: 3600,
  
  // Optional: Custom token mapper
  tokenMapper: async (upstreamTokens, userInfo) => {
    return {
      sub: userInfo.sub,
      email: userInfo.email,
      name: userInfo.name,
    };
  },
});

// Mount OAuth routes
app.use(authServer.createRouter());

// Serves:
// GET  /.well-known/oauth-authorization-server  (RFC 8414 metadata)
// POST /oauth/register                          (RFC 7591 DCR)
// GET  /oauth/authorize                         (Authorization endpoint)
// GET  /oauth/callback                          (Provider callback)
// POST /oauth/token                             (Token endpoint)

app.listen(3000);

Server Metadata (RFC 8414)

The server automatically exposes OAuth metadata at /.well-known/oauth-authorization-server:
{
  "issuer": "https://api.example.com",
  "authorization_endpoint": "https://api.example.com/oauth/authorize",
  "token_endpoint": "https://api.example.com/oauth/token",
  "registration_endpoint": "https://api.example.com/oauth/register",
  "scopes_supported": ["openid", "profile", "email"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"]
}

Dynamic Client Registration (RFC 7591)

MCP clients can register dynamically:
curl -X POST https://api.example.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My MCP Client",
    "redirect_uris": ["http://localhost:3001/callback"]
  }'
Response:
{
  "client_id": "abc123",
  "client_secret": "secret456",
  "client_name": "My MCP Client",
  "redirect_uris": ["http://localhost:3001/callback"]
}

MCP Auth Error Responses

Use createAuthError from @leanmcp/core to return MCP-compliant authentication errors that trigger ChatGPT’s OAuth linking UI:
import { Tool, createAuthError } from '@leanmcp/core';

export class MyService {
  @Tool({ description: 'Get private data' })
  async getPrivateData(args: any, meta?: any) {
    const token = meta?.authorization?.token;
    
    if (!token) {
      return createAuthError('Authentication required', {
        resourceMetadataUrl: `${process.env.PUBLIC_URL}/.well-known/oauth-protected-resource`,
        error: 'invalid_token',
        errorDescription: 'No access token provided',
      });
    }
    
    // ... proceed with authenticated request
  }
}
The createAuthError function returns a response with _meta["mcp/www_authenticate"] that signals to MCP clients (including ChatGPT) to initiate OAuth authentication.

Complete Example

Here’s a complete OAuth proxy server:
import 'dotenv/config';
import express from 'express';
import { OAuthProxy, googleProvider, githubProvider } from '@leanmcp/auth/proxy';
import jwt from 'jsonwebtoken';

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const PORT = 3000;
const BASE_URL = `http://localhost:${PORT}`;

// In-memory user database (use a real database in production)
const users = new Map();

const proxy = new OAuthProxy({
  baseUrl: `${BASE_URL}/auth`,
  sessionSecret: process.env.SESSION_SECRET!,
  providers: [
    googleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    githubProvider({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
  ],
  tokenMapper: async (externalTokens, userInfo, provider) => {
    // Find or create user
    const key = `${provider.id}:${userInfo.email}`;
    let user = users.get(key);
    
    if (!user) {
      user = {
        id: crypto.randomUUID(),
        email: userInfo.email,
        name: userInfo.name,
        provider: provider.id,
        createdAt: new Date(),
      };
      users.set(key, user);
      console.log('Created user:', user.email);
    }
    
    // Generate JWT
    const token = jwt.sign(
      { sub: user.id, email: user.email, name: user.name },
      process.env.JWT_SECRET!,
      { expiresIn: '1h' }
    );
    
    return {
      access_token: token,
      token_type: 'Bearer',
      expires_in: 3600,
    };
  },
});

// OAuth routes
const middleware = proxy.createMiddleware();
app.get('/auth/authorize', middleware.authorize);
app.get('/auth/callback', middleware.callback);
app.post('/auth/token', middleware.token);

// List available providers
app.get('/auth/providers', (req, res) => {
  res.json({
    providers: [
      { id: 'google', name: 'Google' },
      { id: 'github', name: 'GitHub' },
    ],
  });
});

// Protected API endpoint
app.get('/api/me', (req, res) => {
  const auth = req.headers.authorization;
  if (!auth?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  
  try {
    const payload = jwt.verify(auth.slice(7), process.env.JWT_SECRET!);
    res.json({ user: payload });
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
});

app.listen(PORT, () => {
  console.log(`OAuth server running at ${BASE_URL}`);
});