@clastines/klasto-mcp-oauth
v0.1.0
Published
OAuth library for connecting to OAuth-protected MCP servers in web/browser environments
Maintainers
Readme
klasto-mcp-oauth
OAuth library for connecting to OAuth-protected MCP servers in web/browser environments
klasto-mcp-oauth is a TypeScript library that enables web applications (Next.js, React, vanilla browser apps) to securely connect to multiple OAuth-protected Model Context Protocol (MCP) servers. It implements the complete OAuth 2.0 Authorization Code flow with PKCE, automatic token refresh, and handles complex scenarios like step-up authorization.
Features
- ✅ OAuth 2.0 Authorization Code + PKCE (S256) - Secure public client flow
- ✅ Protected Resource Metadata (PRM) Discovery - Auto-discover auth requirements
- ✅ Authorization Server Metadata Discovery - Support OAuth 2.0 and OIDC patterns
- ✅ Dynamic Client Registration - Automatic public client registration
- ✅ Token Bucket Storage - Manage tokens per MCP server + issuer
- ✅ Auto-Refresh with Singleflight - Prevent token refresh storms
- ✅ 401/403 Handling - Auto-retry on 401, step-up auth on insufficient scope
- ✅ SSE Support - Parse
text/event-streamresponses into JSON - ✅ Web-Only - Uses WebCrypto, fetch, IndexedDB/localStorage (no Node dependencies)
Installation
npm install klasto-mcp-oauthQuick Start
1. Initialize the OAuth Handler
import {
OAuthHandler,
IndexedDbBucketStore,
LocalStorageRegistryStore,
} from "klasto-mcp-oauth";
const handler = new OAuthHandler({
bucketStore: new IndexedDbBucketStore(),
registry: new LocalStorageRegistryStore(),
redirectUri: "http://localhost:3000/auth/callback",
clientName: "My MCP Client",
});2. Start Authorization Flow
When the user wants to connect to an MCP server:
// In your connect handler
async function connectToMcpServer() {
const result = await handler.beginAuthorization({
serverName: "My MCP Server",
mcpUrl: "https://mcp.example.com/api",
scopes: ["read", "write"], // Optional
});
// Redirect the user to the authorization URL
window.location.href = result.authorizationUrl;
}3. Handle OAuth Callback
Create a callback route/page (e.g., /auth/callback):
// In your callback page (Next.js example)
import { useEffect } from "react";
import { useRouter } from "next/router";
export default function AuthCallback() {
const router = useRouter();
useEffect(() => {
async function finishAuth() {
try {
const key = await handler.finishAuthorizationFromUrl({
serverName: "My MCP Server",
mcpUrl: "https://mcp.example.com/api",
callbackUrl: window.location.href,
});
console.log("Connected!", key);
router.push("/dashboard");
} catch (error) {
console.error("Auth failed:", error);
router.push("/error");
}
}
finishAuth();
}, []);
return <div>Completing authentication...</div>;
}4. Make Authenticated Requests
Once connected, use authenticatedFetch to make requests:
import { StepUpRequiredError } from "klasto-mcp-oauth";
async function callMcpApi(key) {
try {
const response = await handler.authenticatedFetch({
key,
url: "https://mcp.example.com/api/resources",
acceptSse: true, // For SSE responses
});
if (response.ok) {
const data = await response.json();
console.log(data);
}
} catch (error) {
if (error instanceof StepUpRequiredError) {
// User needs to grant additional scopes
console.log("Step-up required:", error.requiredScopes);
window.location.href = error.authorizationUrl;
} else {
console.error("Request failed:", error);
}
}
}Multi-Server Support
The library manages separate token buckets for each combination of:
serverName- Human-readable labelresource- MCP resource URI (from PRM)issuer- Authorization server issuer
You can connect to multiple MCP servers simultaneously:
// Connect to Server A
const keyA = await handler.beginAuthorization({
serverName: "Server A",
mcpUrl: "https://server-a.example.com/api",
});
// Connect to Server B (different server, same or different issuer)
const keyB = await handler.beginAuthorization({
serverName: "Server B",
mcpUrl: "https://server-b.example.com/api",
});
// List all connected servers
const connections = await handler.listConnections();
console.log(connections);API Reference
OAuthHandler
Main class for managing OAuth flows.
Constructor Options
new OAuthHandler({
bucketStore: TokenBucketStore, // Required: Token storage
registry: RegistryStore, // Required: Connection registry
redirectUri: string, // Required: OAuth redirect URI
clientName?: string, // Optional: Client name for registration
fetchFn?: typeof fetch, // Optional: Custom fetch
expiryLeewayMs?: number, // Optional: Token expiry leeway (default: 5min)
refreshSingleflight?: boolean, // Optional: Enable singleflight (default: true)
includeResourceInAuthorize?: boolean, // Optional: Send resource in authz (default: true)
maxAuthRetries?: number, // Optional: Max retries (default: 2)
})Methods
beginAuthorization(args)
Start the OAuth flow. Returns an authorization URL.
const { authorizationUrl, keyHint } = await handler.beginAuthorization({
serverName: "My Server",
mcpUrl: "https://mcp.example.com/api",
scopes?: ["read", "write"],
});finishAuthorizationFromUrl(args)
Complete the OAuth flow from the callback URL.
const key = await handler.finishAuthorizationFromUrl({
serverName: "My Server",
mcpUrl: "https://mcp.example.com/api",
callbackUrl: window.location.href,
});authenticatedFetch(args)
Make an authenticated request with auto-refresh and retry.
const response = await handler.authenticatedFetch({
key: TokenBucketKey,
url: string,
init?: RequestInit,
acceptSse?: boolean, // Add SSE Accept header
retryOn401?: boolean, // Auto-retry on 401 (default: true)
retryOnInsufficientScope?: boolean, // Throw StepUpRequiredError (default: true)
});prepareHeaders(args)
Get authorization headers for a request.
const headers = await handler.prepareHeaders({ key });
// { Authorization: "Bearer <token>" }listConnections()
List all connected servers.
const connections = await handler.listConnections();
// [{ key: TokenBucketKey, meta: {...} }, ...]logout(args)
Disconnect from a server and optionally revoke tokens.
await handler.logout({
key: TokenBucketKey,
revoke: true, // Revoke tokens at AS
});clear(args)
Remove tokens and registration without revocation.
await handler.clear({ key: TokenBucketKey });Storage Implementations
IndexedDbBucketStore (Recommended)
Uses IndexedDB for token storage.
import { IndexedDbBucketStore } from "klasto-mcp-oauth";
const store = new IndexedDbBucketStore();LocalStorageBucketStore (Fallback)
Uses localStorage for token storage.
import { LocalStorageBucketStore } from "klasto-mcp-oauth";
const store = new LocalStorageBucketStore();LocalStorageRegistryStore
Uses localStorage for connection metadata.
import { LocalStorageRegistryStore } from "klasto-mcp-oauth";
const registry = new LocalStorageRegistryStore();Error Handling
StepUpRequiredError
Thrown when a request fails with 403 insufficient_scope. Contains the authorization URL for step-up.
import { StepUpRequiredError } from "klasto-mcp-oauth";
try {
await handler.authenticatedFetch({ ... });
} catch (error) {
if (error instanceof StepUpRequiredError) {
console.log("Required scopes:", error.requiredScopes);
window.location.href = error.authorizationUrl;
}
}Other errors: OAuthFlowError, TokenError, DiscoveryError
SSE Utilities
parseSseJson(input)
Parse SSE text into JSON.
import { parseSseJson } from "klasto-mcp-oauth";
const data = parseSseJson("data: {\"message\":\"hello\"}\n\n");parseSseResponse(response)
Parse an SSE Response object.
import { parseSseResponse } from "klasto-mcp-oauth";
const response = await fetch(...);
const data = await parseSseResponse(response);streamSseEvents(response)
Stream SSE events as async iterator.
import { streamSseEvents } from "klasto-mcp-oauth";
const response = await fetch(...);
for await (const event of streamSseEvents(response)) {
console.log(event);
}Security Considerations
⚠️ Browser Token Storage is Risky
- Tokens stored in IndexedDB/localStorage are accessible to JavaScript running on the same origin
- XSS attacks can steal tokens
- Use Content Security Policy (CSP) to mitigate XSS
- Request minimal scopes (least privilege principle)
- Consider token rotation and short expiry times
- For highly sensitive applications, consider backend-for-frontend (BFF) pattern
Best Practices:
- Use HTTPS - Always use HTTPS in production
- Validate Redirect URIs - Ensure redirect URIs are registered and validated
- Short-Lived Tokens - Prefer short-lived access tokens with refresh tokens
- Minimal Scopes - Only request scopes you need
- Content Security Policy - Use strict CSP headers
- Regular Audits - Review connected servers and revoke unused tokens
Next.js Example
// lib/oauth.ts
import {
OAuthHandler,
IndexedDbBucketStore,
LocalStorageRegistryStore,
} from "klasto-mcp-oauth";
export const oauthHandler = new OAuthHandler({
bucketStore: new IndexedDbBucketStore(),
registry: new LocalStorageRegistryStore(),
redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/auth/callback`,
clientName: "My Next.js App",
});
// pages/connect.tsx
export default function ConnectPage() {
async function handleConnect() {
const result = await oauthHandler.beginAuthorization({
serverName: "MCP Server",
mcpUrl: "https://mcp.example.com/api",
});
window.location.href = result.authorizationUrl;
}
return <button onClick={handleConnect}>Connect to MCP Server</button>;
}
// pages/auth/callback.tsx
import { useEffect } from "react";
import { useRouter } from "next/router";
import { oauthHandler } from "../../lib/oauth";
export default function AuthCallback() {
const router = useRouter();
useEffect(() => {
async function finish() {
try {
await oauthHandler.finishAuthorizationFromUrl({
serverName: "MCP Server",
mcpUrl: "https://mcp.example.com/api",
callbackUrl: window.location.href,
});
router.push("/dashboard");
} catch (error) {
console.error(error);
router.push("/error");
}
}
finish();
}, [router]);
return <div>Completing authentication...</div>;
}How It Works
Discovery Phase
- Fetch Protected Resource Metadata from MCP server
- Discover Authorization Server metadata
- Validate PKCE S256 support
Registration Phase (if needed)
- Register as public client via Dynamic Client Registration
- Store client_id for reuse
Authorization Phase
- Generate PKCE challenge
- Redirect to authorization endpoint
- User authorizes the app
Token Exchange
- Exchange authorization code for tokens
- Store tokens in bucket (keyed by server + issuer)
Authenticated Requests
- Auto-refresh expired tokens (with singleflight)
- Retry on 401
- Step-up on 403 insufficient_scope
Requirements
- Modern browser with:
- WebCrypto API
- fetch API
- IndexedDB or localStorage
- TypeScript 5.0+ (for type definitions)
- Bundler: Webpack, Vite, Next.js, etc.
License
MIT
Contributing
Contributions welcome! Please open an issue or PR.
Support
For issues or questions, please open a GitHub issue.
