/**
* OAuth utilities for Cloudflare Workers OAuth Provider
* Handles CSRF protection, state management, and approval dialogs
*/
import type { AuthRequest } from '@cloudflare/workers-oauth-provider';
// State stored in KV during OAuth flow
interface OAuthState {
oauthReqInfo: AuthRequest;
createdAt: number;
}
/**
* OAuth error response
*/
export class OAuthError extends Error {
constructor(
message: string,
public statusCode: number = 400
) {
super(message);
this.name = 'OAuthError';
}
toResponse(): Response {
return new Response(this.message, { status: this.statusCode });
}
}
/**
* Generate CSRF token and cookie
*/
export function generateCSRFProtection(): { token: string; setCookie: string } {
const token = crypto.randomUUID();
const setCookie = `csrf_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=600`;
return { token, setCookie };
}
/**
* Validate CSRF token from form submission
*/
export function validateCSRFToken(formData: FormData, request: Request): void {
const formToken = formData.get('csrf_token');
const cookieHeader = request.headers.get('Cookie') || '';
const cookies = Object.fromEntries(
cookieHeader.split(';').map((c) => {
const [key, ...val] = c.trim().split('=');
return [key, val.join('=')];
})
);
const cookieToken = cookies['csrf_token'];
if (!formToken || !cookieToken || formToken !== cookieToken) {
throw new OAuthError('Invalid CSRF token', 403);
}
}
/**
* Create OAuth state and store in KV
*/
export async function createOAuthState(
oauthReqInfo: AuthRequest,
kv: KVNamespace
): Promise<{ stateToken: string }> {
const stateToken = crypto.randomUUID();
const state: OAuthState = {
oauthReqInfo,
createdAt: Date.now(),
};
// Store state in KV with 10-minute TTL
await kv.put(`oauth_state:${stateToken}`, JSON.stringify(state), {
expirationTtl: 600,
});
return { stateToken };
}
/**
* Bind state token to session via cookie
*/
export async function bindStateToSession(
stateToken: string
): Promise<{ setCookie: string }> {
const setCookie = `oauth_state=${stateToken}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`;
return { setCookie };
}
/**
* Validate OAuth state from callback
*/
export async function validateOAuthState(
request: Request,
kv: KVNamespace
): Promise<{ oauthReqInfo: AuthRequest; clearCookie: string }> {
const url = new URL(request.url);
const stateToken = url.searchParams.get('state');
if (!stateToken) {
throw new OAuthError('Missing state parameter');
}
// Verify state token matches session cookie
const cookieHeader = request.headers.get('Cookie') || '';
const cookies = Object.fromEntries(
cookieHeader.split(';').map((c) => {
const [key, ...val] = c.trim().split('=');
return [key, val.join('=')];
})
);
const sessionState = cookies['oauth_state'];
if (sessionState !== stateToken) {
throw new OAuthError('State mismatch - possible CSRF attack', 403);
}
// Retrieve and delete state from KV
const stateJson = await kv.get(`oauth_state:${stateToken}`);
if (!stateJson) {
throw new OAuthError('OAuth state expired or invalid');
}
await kv.delete(`oauth_state:${stateToken}`);
const state = JSON.parse(stateJson) as OAuthState;
// Clear session cookie
const clearCookie = 'oauth_state=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0';
return { oauthReqInfo: state.oauthReqInfo, clearCookie };
}
/**
* Check if client is already approved (via encrypted cookie)
*/
export async function isClientApproved(
request: Request,
clientId: string,
encryptionKey: string
): Promise<boolean> {
const cookieHeader = request.headers.get('Cookie') || '';
const cookies = Object.fromEntries(
cookieHeader.split(';').map((c) => {
const [key, ...val] = c.trim().split('=');
return [key, val.join('=')];
})
);
const approvedCookie = cookies['approved_clients'];
if (!approvedCookie) return false;
try {
const decrypted = await decryptValue(approvedCookie, encryptionKey);
const approved = JSON.parse(decrypted) as string[];
return approved.includes(clientId);
} catch {
return false;
}
}
/**
* Add client to approved list
*/
export async function addApprovedClient(
request: Request,
clientId: string,
encryptionKey: string
): Promise<string> {
const cookieHeader = request.headers.get('Cookie') || '';
const cookies = Object.fromEntries(
cookieHeader.split(';').map((c) => {
const [key, ...val] = c.trim().split('=');
return [key, val.join('=')];
})
);
let approved: string[] = [];
const approvedCookie = cookies['approved_clients'];
if (approvedCookie) {
try {
const decrypted = await decryptValue(approvedCookie, encryptionKey);
approved = JSON.parse(decrypted) as string[];
} catch {
// Invalid cookie, start fresh
}
}
if (!approved.includes(clientId)) {
approved.push(clientId);
}
const encrypted = await encryptValue(JSON.stringify(approved), encryptionKey);
return `approved_clients=${encrypted}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=31536000`;
}
/**
* Render approval dialog HTML
*/
export function renderApprovalDialog(
request: Request,
options: {
client: { client_name?: string; client_uri?: string } | null;
clientId?: string;
csrfToken: string;
server: { name: string; description: string; logo?: string };
setCookie: string;
state: { oauthReqInfo: AuthRequest };
}
): Response {
const { client, clientId, csrfToken, server, setCookie, state } = options;
// Use client_name if available, otherwise show a friendly version of client ID
const clientName = client?.client_name || formatClientId(clientId);
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authorize ${server.name}</title>
<link rel="icon" href="https://www.jezweb.com.au/wp-content/uploads/2020/03/favicon-100x100.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: #09090b;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
position: relative;
overflow: hidden;
}
/* Background effects */
body::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(ellipse at 20% 20%, rgba(20, 184, 166, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 80%, rgba(59, 130, 246, 0.1) 0%, transparent 50%);
pointer-events: none;
}
/* Grid overlay */
body::after {
content: '';
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 64px 64px;
pointer-events: none;
}
.card {
background: rgba(24, 24, 27, 0.95);
border: 1px solid rgba(63, 63, 70, 0.5);
border-radius: 16px;
padding: 48px 40px;
max-width: 420px;
width: 100%;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 1;
}
.logo {
width: 56px;
height: 56px;
margin: 0 auto 24px;
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.logo img {
width: 56px;
height: 56px;
object-fit: cover;
}
.logo svg {
width: 28px;
height: 28px;
fill: white;
}
h1 {
color: #fafafa;
font-size: 1.75rem;
font-weight: 700;
text-align: center;
margin-bottom: 8px;
}
.subtitle {
color: #a1a1aa;
text-align: center;
margin-bottom: 32px;
font-size: 0.95rem;
line-height: 1.5;
}
.client-info {
background: rgba(20, 184, 166, 0.08);
border: 1px solid rgba(20, 184, 166, 0.2);
border-radius: 10px;
padding: 16px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
}
.client-icon {
width: 40px;
height: 40px;
background: rgba(20, 184, 166, 0.15);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.client-icon svg {
width: 20px;
height: 20px;
color: #14b8a6;
}
.client-text {
color: #e4e4e7;
font-size: 0.9rem;
line-height: 1.5;
}
.client-name {
font-weight: 600;
color: #fafafa;
}
.permissions {
margin: 24px 0;
}
.permissions h3 {
font-size: 0.85rem;
color: #71717a;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.permission {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
color: #d4d4d8;
font-size: 0.9rem;
border-bottom: 1px solid rgba(63, 63, 70, 0.3);
}
.permission:last-child {
border-bottom: none;
}
.permission-icon {
width: 20px;
height: 20px;
color: #14b8a6;
flex-shrink: 0;
}
.buttons {
display: flex;
gap: 12px;
margin-top: 32px;
}
button {
flex: 1;
padding: 14px 24px;
border-radius: 10px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.deny {
background: rgba(63, 63, 70, 0.5);
border: 1px solid rgba(63, 63, 70, 0.8);
color: #a1a1aa;
}
.deny:hover {
background: rgba(63, 63, 70, 0.7);
color: #d4d4d8;
}
.approve {
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
border: none;
color: white;
}
.approve:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px -8px rgba(20, 184, 166, 0.4);
}
.approve:active {
transform: translateY(0);
}
.footer {
text-align: center;
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #27272a;
color: #71717a;
font-size: 0.8rem;
}
.footer a {
color: #14b8a6;
text-decoration: none;
transition: color 0.2s;
}
.footer a:hover {
color: #2dd4bf;
text-decoration: underline;
}
@media (max-width: 480px) {
.card {
padding: 32px 24px;
}
h1 {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
<div class="card">
<div class="logo">
${server.logo
? `<img src="${server.logo}" alt="${server.name}">`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>`
}
</div>
<h1>Authorize Access</h1>
<p class="subtitle">${server.description}</p>
<div class="client-info">
<div class="client-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
<div class="client-text">
<span class="client-name">${escapeHtml(clientName)}</span> wants to connect to your account
</div>
</div>
<div class="permissions">
<h3>This will allow the application to:</h3>
<div class="permission">
<svg class="permission-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Access your account information
</div>
<div class="permission">
<svg class="permission-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
Use ${escapeHtml(server.name)} tools on your behalf
</div>
</div>
<form method="POST" action="/authorize">
<input type="hidden" name="csrf_token" value="${csrfToken}">
<input type="hidden" name="state" value="${btoa(JSON.stringify(state))}">
<div class="buttons">
<button type="button" class="deny" onclick="window.close()">Deny</button>
<button type="submit" class="approve">Approve</button>
</div>
</form>
<div class="footer">
<p>Powered by <a href="https://jezweb.com.au" target="_blank">Jezweb</a></p>
</div>
</div>
</body>
</html>`;
return new Response(html, {
headers: {
'Content-Type': 'text/html',
'Set-Cookie': setCookie,
},
});
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Format client ID for display when no client_name is available
*/
function formatClientId(clientId?: string): string {
if (!clientId) return 'MCP Client';
// Check if it looks like a UUID (common for dynamically registered clients)
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (uuidPattern.test(clientId)) {
return 'MCP Client';
}
// If it's a URL-like client ID, extract the hostname
if (clientId.startsWith('http://') || clientId.startsWith('https://')) {
try {
const url = new URL(clientId);
return url.hostname;
} catch {
// Fall through to default
}
}
// If it's short enough, show it directly
if (clientId.length <= 30) {
return clientId;
}
// Truncate long client IDs
return clientId.slice(0, 27) + '...';
}
// Simple encryption helpers using Web Crypto API
async function encryptValue(value: string, key: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(value);
const keyData = encoder.encode(key.padEnd(32, '0').slice(0, 32));
const iv = crypto.getRandomValues(new Uint8Array(12));
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'AES-GCM' },
false,
['encrypt']
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
data
);
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return btoa(String.fromCharCode(...combined));
}
async function decryptValue(encrypted: string, key: string): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(key.padEnd(32, '0').slice(0, 32));
const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const data = combined.slice(12);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
data
);
return new TextDecoder().decode(decrypted);
}