workers-oauth-utils.ts•24 kB
import { z } from 'zod'
import type { AuthRequest, ClientInfo } from '@cloudflare/workers-oauth-provider'
const COOKIE_NAME = '__Host-MCP_APPROVED_CLIENTS'
const ONE_YEAR_IN_SECONDS = 31536000
/**
 * OAuth error class for handling OAuth-specific errors
 */
export class OAuthError extends Error {
	constructor(
		public code: string,
		public description: string,
		public statusCode = 400
	) {
		super(description)
		this.name = 'OAuthError'
	}
	toResponse(): Response {
		return new Response(
			JSON.stringify({
				error: this.code,
				error_description: this.description,
			}),
			{
				status: this.statusCode,
				headers: { 'Content-Type': 'application/json' },
			}
		)
	}
}
/**
 * Imports a secret key string for HMAC-SHA256 signing.
 * @param secret - The raw secret key string.
 * @returns A promise resolving to the CryptoKey object.
 */
async function importKey(secret: string): Promise<CryptoKey> {
	if (!secret) {
		throw new Error('COOKIE_SECRET is not defined. A secret key is required for signing cookies.')
	}
	const enc = new TextEncoder()
	return crypto.subtle.importKey(
		'raw',
		enc.encode(secret),
		{ hash: 'SHA-256', name: 'HMAC' },
		false, // not extractable
		['sign', 'verify'] // key usages
	)
}
/**
 * Signs data using HMAC-SHA256.
 * @param key - The CryptoKey for signing.
 * @param data - The string data to sign.
 * @returns A promise resolving to the signature as a hex string.
 */
async function signData(key: CryptoKey, data: string): Promise<string> {
	const enc = new TextEncoder()
	const signatureBuffer = await crypto.subtle.sign('HMAC', key, enc.encode(data))
	// Convert ArrayBuffer to hex string
	return Array.from(new Uint8Array(signatureBuffer))
		.map((b) => b.toString(16).padStart(2, '0'))
		.join('')
}
/**
 * Verifies an HMAC-SHA256 signature.
 * @param key - The CryptoKey for verification.
 * @param signatureHex - The signature to verify (hex string).
 * @param data - The original data that was signed.
 * @returns A promise resolving to true if the signature is valid, false otherwise.
 */
async function verifySignature(
	key: CryptoKey,
	signatureHex: string,
	data: string
): Promise<boolean> {
	const enc = new TextEncoder()
	try {
		const signatureBytes = new Uint8Array(
			signatureHex.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16))
		)
		return await crypto.subtle.verify('HMAC', key, signatureBytes.buffer, enc.encode(data))
	} catch (e) {
		console.error('Error verifying signature:', e)
		return false
	}
}
/**
 * Parses the signed cookie and verifies its integrity.
 * @param cookieHeader - The value of the Cookie header from the request.
 * @param secret - The secret key used for signing.
 * @returns A promise resolving to the list of approved client IDs if the cookie is valid, otherwise null.
 */
async function getApprovedClientsFromCookie(
	cookieHeader: string | null,
	secret: string
): Promise<string[] | null> {
	if (!cookieHeader) return null
	const cookies = cookieHeader.split(';').map((c) => c.trim())
	const targetCookie = cookies.find((c) => c.startsWith(`${COOKIE_NAME}=`))
	if (!targetCookie) return null
	const cookieValue = targetCookie.substring(COOKIE_NAME.length + 1)
	const parts = cookieValue.split('.')
	if (parts.length !== 2) {
		console.warn('Invalid cookie format received.')
		return null // Invalid format
	}
	const [signatureHex, base64Payload] = parts
	const payload = atob(base64Payload) // Assuming payload is base64 encoded JSON string
	const key = await importKey(secret)
	const isValid = await verifySignature(key, signatureHex, payload)
	if (!isValid) {
		console.warn('Cookie signature verification failed.')
		return null // Signature invalid
	}
	try {
		const approvedClients = JSON.parse(payload)
		if (!Array.isArray(approvedClients)) {
			console.warn('Cookie payload is not an array.')
			return null // Payload isn't an array
		}
		// Ensure all elements are strings
		if (!approvedClients.every((item) => typeof item === 'string')) {
			console.warn('Cookie payload contains non-string elements.')
			return null
		}
		return approvedClients as string[]
	} catch (e) {
		console.error('Error parsing cookie payload:', e)
		return null // JSON parsing failed
	}
}
/**
 * Checks if a given client ID has already been approved by the user,
 * based on a signed cookie.
 *
 * @param request - The incoming Request object to read cookies from.
 * @param clientId - The OAuth client ID to check approval for.
 * @param cookieSecret - The secret key used to sign/verify the approval cookie.
 * @returns A promise resolving to true if the client ID is in the list of approved clients in a valid cookie, false otherwise.
 */
export async function clientIdAlreadyApproved(
	request: Request,
	clientId: string,
	cookieSecret: string
): Promise<boolean> {
	if (!clientId) return false
	const cookieHeader = request.headers.get('Cookie')
	const approvedClients = await getApprovedClientsFromCookie(cookieHeader, cookieSecret)
	return approvedClients?.includes(clientId) ?? false
}
/**
 * Configuration for the approval dialog
 */
export interface ApprovalDialogOptions {
	/**
	 * Client information to display in the approval dialog
	 */
	client: ClientInfo | null
	/**
	 * Server information to display in the approval dialog
	 */
	server: {
		name: string
		logo?: string
		description?: string
	}
	/**
	 * Arbitrary state data to pass through the approval flow
	 * Will be encoded in the form and returned when approval is complete
	 */
	state: Record<string, any>
	/**
	 * CSRF token to include in the approval form
	 */
	csrfToken: string
	/**
	 * Set-Cookie header to include in the approval response
	 */
	setCookie: string
}
/**
 * Renders an approval dialog for OAuth authorization
 * The dialog displays information about the client and server
 * and includes a form to submit approval
 *
 * @param request - The HTTP request
 * @param options - Configuration for the approval dialog
 * @returns A Response containing the HTML approval dialog
 */
export function renderApprovalDialog(request: Request, options: ApprovalDialogOptions): Response {
	const { client, server, state, csrfToken, setCookie } = options
	const encodedState = btoa(JSON.stringify(state))
	const serverName = sanitizeHtml(server.name)
	const clientName = client?.clientName ? sanitizeHtml(client.clientName) : 'Unknown MCP Client'
	const serverDescription = server.description ? sanitizeHtml(server.description) : ''
	const logoUrl = server.logo ? sanitizeHtml(server.logo) : ''
	const clientUri = client?.clientUri ? sanitizeHtml(client.clientUri) : ''
	const policyUri = client?.policyUri ? sanitizeHtml(client.policyUri) : ''
	const tosUri = client?.tosUri ? sanitizeHtml(client.tosUri) : ''
	const contacts =
		client?.contacts && client.contacts.length > 0 ? sanitizeHtml(client.contacts.join(', ')) : ''
	const redirectUris =
		client?.redirectUris && client.redirectUris.length > 0
			? client.redirectUris.map((uri) => sanitizeHtml(uri)).filter((uri) => uri !== '')
			: []
	const htmlContent = `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>${clientName} | Authorization Request</title>
        <style>
          /* Modern, responsive styling with system fonts */
          :root {
            --primary-color: #0070f3;
            --error-color: #f44336;
            --border-color: #e5e7eb;
            --text-color: #333;
            --background-color: #fff;
            --card-shadow: 0 8px 36px 8px rgba(0, 0, 0, 0.1);
          }
          
          body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 
                         Helvetica, Arial, sans-serif, "Apple Color Emoji", 
                         "Segoe UI Emoji", "Segoe UI Symbol";
            line-height: 1.6;
            color: var(--text-color);
            background-color: #f9fafb;
            margin: 0;
            padding: 0;
          }
          
          .container {
            max-width: 600px;
            margin: 2rem auto;
            padding: 1rem;
          }
          
          .precard {
            padding: 2rem;
            text-align: center;
          }
          
          .card {
            background-color: var(--background-color);
            border-radius: 8px;
            box-shadow: var(--card-shadow);
            padding: 2rem;
          }
          
          .header {
            display: flex;
            align-items: center;
            justify-content: center;
            margin-bottom: 1.5rem;
          }
          
          .logo {
            width: 48px;
            height: 48px;
            margin-right: 1rem;
            border-radius: 8px;
            object-fit: contain;
          }
          
          .title {
            margin: 0;
            font-size: 1.3rem;
            font-weight: 400;
          }
          
          .alert {
            margin: 0;
            font-size: 1.5rem;
            font-weight: 400;
            margin: 1rem 0;
            text-align: center;
          }
          
          .description {
            color: #555;
          }
          
          .client-info {
            border: 1px solid var(--border-color);
            border-radius: 6px;
            padding: 1rem 1rem 0.5rem;
            margin-bottom: 1.5rem;
          }
          
          .client-name {
            font-weight: 600;
            font-size: 1.2rem;
            margin: 0 0 0.5rem 0;
          }
          
          .client-detail {
            display: flex;
            margin-bottom: 0.5rem;
            align-items: baseline;
          }
          
          .detail-label {
            font-weight: 500;
            min-width: 120px;
          }
          
          .detail-value {
            font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
            word-break: break-all;
          }
          
          .detail-value a {
            color: inherit;
            text-decoration: underline;
          }
          
          .detail-value.small {
            font-size: 0.8em;
          }
          
          .external-link-icon {
            font-size: 0.75em;
            margin-left: 0.25rem;
            vertical-align: super;
          }
          
          .actions {
            display: flex;
            justify-content: flex-end;
            gap: 1rem;
            margin-top: 2rem;
          }
          
          .button {
            padding: 0.75rem 1.5rem;
            border-radius: 6px;
            font-weight: 500;
            cursor: pointer;
            border: none;
            font-size: 1rem;
          }
          
          .button-primary {
            background-color: var(--primary-color);
            color: white;
          }
          
          .button-secondary {
            background-color: transparent;
            border: 1px solid var(--border-color);
            color: var(--text-color);
          }
          
          /* Responsive adjustments */
          @media (max-width: 640px) {
            .container {
              margin: 1rem auto;
              padding: 0.5rem;
            }
            
            .card {
              padding: 1.5rem;
            }
            
            .client-detail {
              flex-direction: column;
            }
            
            .detail-label {
              min-width: unset;
              margin-bottom: 0.25rem;
            }
            
            .actions {
              flex-direction: column;
            }
            
            .button {
              width: 100%;
            }
          }
        </style>
      </head>
      <body>
        <div class="container">
          <div class="precard">
            <div class="header">
              ${logoUrl ? `<img src="${logoUrl}" alt="${serverName} Logo" class="logo">` : ''}
            <h1 class="title"><strong>${serverName}</strong></h1>
            </div>
            
            ${serverDescription ? `<p class="description">${serverDescription}</p>` : ''}
          </div>
            
          <div class="card">
            
            <h2 class="alert"><strong>${clientName || 'A new MCP Client'}</strong> is requesting access</h1>
            
            <div class="client-info">
              <div class="client-detail">
                <div class="detail-label">Name:</div>
                <div class="detail-value">
                  ${clientName}
                </div>
              </div>
              
              ${
								clientUri
									? `
                <div class="client-detail">
                  <div class="detail-label">Website:</div>
                  <div class="detail-value small">
                    <a href="${clientUri}" target="_blank" rel="noopener noreferrer">
                      ${clientUri}
                    </a>
                  </div>
                </div>
              `
									: ''
							}
              
              ${
								policyUri
									? `
                <div class="client-detail">
                  <div class="detail-label">Privacy Policy:</div>
                  <div class="detail-value">
                    <a href="${policyUri}" target="_blank" rel="noopener noreferrer">
                      ${policyUri}
                    </a>
                  </div>
                </div>
              `
									: ''
							}
              
              ${
								tosUri
									? `
                <div class="client-detail">
                  <div class="detail-label">Terms of Service:</div>
                  <div class="detail-value">
                    <a href="${tosUri}" target="_blank" rel="noopener noreferrer">
                      ${tosUri}
                    </a>
                  </div>
                </div>
              `
									: ''
							}
              
              ${
								redirectUris.length > 0
									? `
                <div class="client-detail">
                  <div class="detail-label">Redirect URIs:</div>
                  <div class="detail-value small">
                    ${redirectUris.map((uri) => `<div>${uri}</div>`).join('')}
                  </div>
                </div>
              `
									: ''
							}
              
              ${
								contacts
									? `
                <div class="client-detail">
                  <div class="detail-label">Contact:</div>
                  <div class="detail-value">${contacts}</div>
                </div>
              `
									: ''
							}
            </div>
            
            <p>This MCP Client is requesting to be authorized on ${serverName}. If you approve, you will be redirected to complete authentication.</p>
            
            <form method="post" action="${new URL(request.url).pathname}">
              <input type="hidden" name="state" value="${encodedState}">
              <input type="hidden" name="csrf_token" value="${csrfToken}">
              <div class="actions">
                <button type="button" class="button button-secondary" onclick="window.history.back()">Cancel</button>
                <button type="submit" class="button button-primary">Approve</button>
              </div>
            </form>
          </div>
        </div>
      </body>
    </html>
  `
	return new Response(htmlContent, {
		headers: {
			'Content-Security-Policy': "frame-ancestors 'none'",
			'Content-Type': 'text/html; charset=utf-8',
			'Set-Cookie': setCookie,
			'X-Frame-Options': 'DENY',
		},
	})
}
/**
 * Result of parsing the approval form submission.
 */
export interface ParsedApprovalResult {
	/** The original state object containing the OAuth request information. */
	state: { oauthReqInfo?: AuthRequest }
	/** Headers to set on the redirect response, including the Set-Cookie header. */
	headers: Record<string, string>
}
/**
 * Parses the form submission from the approval dialog, extracts the state,
 * and generates Set-Cookie headers to mark the client as approved.
 *
 * @param request - The incoming POST Request object containing the form data.
 * @param cookieSecret - The secret key used to sign the approval cookie.
 * @returns A promise resolving to an object containing the parsed state and necessary headers.
 * @throws If the request method is not POST, form data is invalid, or state is missing.
 */
export async function parseRedirectApproval(
	request: Request,
	cookieSecret: string
): Promise<ParsedApprovalResult> {
	if (request.method !== 'POST') {
		throw new Error('Invalid request method. Expected POST.')
	}
	const formData = await request.formData()
	const tokenFromForm = formData.get('csrf_token')
	if (!tokenFromForm || typeof tokenFromForm !== 'string') {
		throw new Error('Missing CSRF token in form data')
	}
	const cookieHeader = request.headers.get('Cookie') || ''
	const cookies = cookieHeader.split(';').map((c) => c.trim())
	const csrfCookie = cookies.find((c) => c.startsWith('__Host-CSRF_TOKEN='))
	const tokenFromCookie = csrfCookie ? csrfCookie.substring('__Host-CSRF_TOKEN='.length) : null
	if (!tokenFromCookie || tokenFromForm !== tokenFromCookie) {
		throw new Error('CSRF token mismatch')
	}
	const encodedState = formData.get('state')
	if (!encodedState || typeof encodedState !== 'string') {
		throw new Error('Missing state in form data')
	}
	const state = JSON.parse(atob(encodedState))
	if (!state.oauthReqInfo || !state.oauthReqInfo.clientId) {
		throw new Error('Invalid state data')
	}
	const existingApprovedClients =
		(await getApprovedClientsFromCookie(request.headers.get('Cookie'), cookieSecret)) || []
	const updatedApprovedClients = Array.from(
		new Set([...existingApprovedClients, state.oauthReqInfo.clientId])
	)
	const payload = JSON.stringify(updatedApprovedClients)
	const key = await importKey(cookieSecret)
	const signature = await signData(key, payload)
	const newCookieValue = `${signature}.${btoa(payload)}` // signature.base64(payload)
	const headers: Record<string, string> = {
		'Set-Cookie': `${COOKIE_NAME}=${newCookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=${ONE_YEAR_IN_SECONDS}`,
	}
	return { headers, state }
}
/**
 * Result from bindStateToSession containing the cookie to set
 */
export interface BindStateResult {
	/**
	 * Set-Cookie header value to bind the state to the user's session
	 */
	setCookie: string
}
/**
 * Result from validateOAuthState containing the original OAuth request info and cookie to clear
 */
export interface ValidateStateResult {
	/**
	 * The original OAuth request information that was stored with the state token
	 */
	oauthReqInfo: AuthRequest
	/**
	 * The PKCE code verifier retrieved from server-side storage (never transmitted to client)
	 */
	codeVerifier: string
	/**
	 * Set-Cookie header value to clear the state cookie
	 */
	clearCookie: string
}
export function generateCSRFProtection(): { token: string; setCookie: string } {
	const token = crypto.randomUUID()
	const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`
	return { token, setCookie }
}
export async function createOAuthState(
	oauthReqInfo: AuthRequest,
	kv: KVNamespace,
	codeVerifier: string
): Promise<string> {
	const stateToken = crypto.randomUUID()
	const stateData = { oauthReqInfo, codeVerifier } satisfies {
		oauthReqInfo: AuthRequest
		codeVerifier: string
	}
	await kv.put(`oauth:state:${stateToken}`, JSON.stringify(stateData), {
		expirationTtl: 600,
	})
	return stateToken
}
/**
 * Binds an OAuth state token to the user's browser session using a secure cookie.
 *
 * @param stateToken - The state token to bind to the session
 * @returns Object containing the Set-Cookie header to send to the client
 */
export async function bindStateToSession(stateToken: string): Promise<BindStateResult> {
	const consentedStateCookieName = '__Host-CONSENTED_STATE'
	// Hash the state token to provide defense-in-depth
	const encoder = new TextEncoder()
	const data = encoder.encode(stateToken)
	const hashBuffer = await crypto.subtle.digest('SHA-256', data)
	const hashArray = Array.from(new Uint8Array(hashBuffer))
	const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
	const setCookie = `${consentedStateCookieName}=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`
	return { setCookie }
}
/**
 * Validates OAuth state from the request, ensuring:
 * 1. The state parameter exists in KV (proves it was created by our server)
 * 2. The state hash matches the session cookie (proves this browser consented to it)
 *
 * This prevents attacks where an attacker's valid state token is injected into
 * a victim's OAuth flow.
 *
 * @param request - The HTTP request containing state parameter and cookies
 * @param kv - Cloudflare KV namespace for storing OAuth state data
 * @returns Object containing the original OAuth request info and cookie to clear
 * @throws If state is missing, mismatched, or expired
 */
export async function validateOAuthState(
	request: Request,
	kv: KVNamespace
): Promise<ValidateStateResult> {
	const consentedStateCookieName = '__Host-CONSENTED_STATE'
	const url = new URL(request.url)
	const stateFromQuery = url.searchParams.get('state')
	if (!stateFromQuery) {
		throw new Error('Missing state parameter')
	}
	// Decode the state parameter to extract the embedded stateToken
	let stateToken: string
	try {
		const decodedState = JSON.parse(atob(stateFromQuery))
		stateToken = decodedState.state
		if (!stateToken) {
			throw new Error('State token not found in decoded state')
		}
	} catch (e) {
		throw new Error('Failed to decode state parameter')
	}
	const storedDataJson = await kv.get(`oauth:state:${stateToken}`)
	if (!storedDataJson) {
		throw new Error('Invalid or expired state')
	}
	const cookieHeader = request.headers.get('Cookie') || ''
	const cookies = cookieHeader.split(';').map((c) => c.trim())
	const consentedStateCookie = cookies.find((c) => c.startsWith(`${consentedStateCookieName}=`))
	const consentedStateHash = consentedStateCookie
		? consentedStateCookie.substring(consentedStateCookieName.length + 1)
		: null
	if (!consentedStateHash) {
		throw new Error('Missing session binding cookie - authorization flow must be restarted')
	}
	const encoder = new TextEncoder()
	const data = encoder.encode(stateToken)
	const hashBuffer = await crypto.subtle.digest('SHA-256', data)
	const hashArray = Array.from(new Uint8Array(hashBuffer))
	const stateHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
	if (stateHash !== consentedStateHash) {
		throw new Error('State token does not match session - possible CSRF attack detected')
	}
	// Parse and validate stored OAuth state data
	const StoredOAuthStateSchema = z.object({
		oauthReqInfo: z
			.object({
				clientId: z.string(),
				scope: z.array(z.string()),
				state: z.string(),
				responseType: z.string(),
				redirectUri: z.string(),
			})
			.passthrough(), // preserve any other fields from oauth-provider
		codeVerifier: z.string().min(1), // Our code verifier for Cloudflare OAuth
	})
	const parseResult = StoredOAuthStateSchema.safeParse(JSON.parse(storedDataJson))
	if (!parseResult.success) {
		throw new Error('Invalid OAuth state data format - PKCE security violation')
	}
	await kv.delete(`oauth:state:${stateToken}`)
	const clearCookie = `${consentedStateCookieName}=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`
	return {
		oauthReqInfo: parseResult.data.oauthReqInfo,
		codeVerifier: parseResult.data.codeVerifier,
		clearCookie,
	}
}
/**
 * Sanitizes HTML content to prevent XSS attacks
 * @param unsafe - The unsafe string that might contain HTML
 * @returns A safe string with HTML special characters escaped
 */
function sanitizeHtml(unsafe: string): string {
	return unsafe
		.replace(/&/g, '&')
		.replace(/</g, '<')
		.replace(/>/g, '>')
		.replace(/"/g, '"')
		.replace(/'/g, ''')
}