/**
* Microsoft Entra OAuth Handler for MCP Server
*
* Routes:
* - GET / - Server homepage
* - GET /authorize - Show approval dialog or redirect to Microsoft
* - POST /authorize - Process approval form submission
* - GET /callback - Handle Microsoft OAuth callback
*
* Supports:
* - Personal Microsoft accounts (consumers)
* - Work/school accounts (organizations)
* - Multi-tenant apps (common)
*/
import type { AuthRequest, OAuthHelpers } from '@cloudflare/workers-oauth-provider';
import { Hono } from 'hono';
import {
buildMicrosoftAuthUrl,
exchangeMicrosoftCode,
fetchMicrosoftUserInfo,
MICROSOFT_IDENTITY_SCOPES,
} from '../auth/identity/microsoft';
import type { Env, Props } from '../types';
import {
addApprovedClient,
bindStateToSession,
createOAuthState,
generateCSRFProtection,
isClientApproved,
OAuthError,
renderApprovalDialog,
validateCSRFToken,
validateOAuthState,
} from './workers-oauth-utils';
import { createAdminSession } from '../admin/session';
import { renderHomepage, formatToolName } from '@jezweb/mcp-ui';
import { getToolsMetadata } from '../tools';
// ===== CUSTOMIZE: Microsoft OAuth scopes =====
// See: https://learn.microsoft.com/en-us/graph/permissions-reference
const MICROSOFT_SCOPES = MICROSOFT_IDENTITY_SCOPES;
const app = new Hono<{
Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers };
}>();
/**
* GET / - MCP Server Homepage
*/
app.get('/', (c) => {
const tools = getToolsMetadata();
return c.html(
renderHomepage({
name: c.env.SERVER_DISPLAY_NAME || 'Google Calendar MCP Server',
description:
c.env.SERVER_DESCRIPTION ||
'Connect Claude to Google Calendar for scheduling and event management.',
tagline: c.env.SERVER_TAGLINE,
tools: tools.map((t) => ({
name: t.name,
displayName: formatToolName(t.name),
description: t.description,
category: t.metadata?.category,
})),
})
);
});
/**
* GET /admin/login - Admin login via Microsoft OAuth
* Separate from MCP client authorization flow
*/
app.get('/admin/login', async (c) => {
console.log('=== ADMIN LOGIN START ===');
if (!c.env.MICROSOFT_CLIENT_ID) {
return c.text('Microsoft OAuth not configured', 500);
}
// Generate state token for admin login
const stateToken = crypto.randomUUID();
console.log('Generated state token:', stateToken);
// Store state with return_to in KV
await c.env.OAUTH_KV.put(
`admin_state:${stateToken}`,
JSON.stringify({ return_to: '/admin' }),
{ expirationTtl: 600 } // 10 minutes
);
const redirectUri = new URL('/admin/callback', c.req.url).href;
console.log('Redirect URI:', redirectUri);
console.log('Tenant:', c.env.MICROSOFT_TENANT_ID || 'common (default)');
// Redirect to Microsoft OAuth
// User.Read is required for Microsoft Graph /me endpoint
const authUrl = buildMicrosoftAuthUrl({
clientId: c.env.MICROSOFT_CLIENT_ID,
redirectUri,
scope: 'openid email profile User.Read',
state: stateToken,
tenant: c.env.MICROSOFT_TENANT_ID, // Use configured tenant or 'common'
prompt: 'select_account', // Let user choose account for admin
});
console.log('Auth URL:', authUrl);
return c.redirect(authUrl);
});
/**
* GET /admin/callback - Handle admin OAuth callback
*/
app.get('/admin/callback', async (c) => {
console.log('=== ADMIN CALLBACK START ===');
console.log('URL:', c.req.url);
if (!c.env.MICROSOFT_CLIENT_ID || !c.env.MICROSOFT_CLIENT_SECRET) {
console.error('Microsoft OAuth not configured');
return c.text('Microsoft OAuth not configured', 500);
}
const code = c.req.query('code');
const state = c.req.query('state');
const error = c.req.query('error');
const errorDescription = c.req.query('error_description');
console.log('Query params:', { code: code?.slice(0, 20) + '...', state, error, errorDescription });
if (error) {
console.error('OAuth error from Microsoft:', error, errorDescription);
return c.text(`OAuth error: ${error} - ${errorDescription || ''}`, 400);
}
if (!code || !state) {
console.error('Missing code or state');
return c.text('Missing code or state', 400);
}
// Validate state
const stateData = await c.env.OAUTH_KV.get(`admin_state:${state}`);
console.log('State lookup result:', stateData ? 'found' : 'not found');
if (!stateData) {
return c.text('Invalid or expired state', 400);
}
// Delete used state
await c.env.OAUTH_KV.delete(`admin_state:${state}`);
// Exchange code for tokens
const redirectUri = new URL('/admin/callback', c.req.url).href;
console.log('Token exchange with redirect_uri:', redirectUri);
const [tokens, errResponse] = await exchangeMicrosoftCode({
clientId: c.env.MICROSOFT_CLIENT_ID,
clientSecret: c.env.MICROSOFT_CLIENT_SECRET,
code,
redirectUri,
tenant: c.env.MICROSOFT_TENANT_ID,
});
if (errResponse) {
console.error('Token exchange failed');
return errResponse;
}
console.log('Token exchange successful, fetching user info...');
// Fetch user info
const user = await fetchMicrosoftUserInfo(tokens.accessToken);
if (!user) {
console.error('Failed to fetch user info - null returned');
return c.text('Failed to fetch user info', 500);
}
console.log('User info fetched:', { email: user.email, name: user.name });
// Create admin session
const { cookie } = await createAdminSession(
{ email: user.email, name: user.name, picture: user.picture },
c.env.OAUTH_KV
);
// Redirect to admin with session cookie
return new Response(null, {
status: 302,
headers: {
Location: '/admin',
'Set-Cookie': cookie,
},
});
});
/**
* GET /authorize - Initial authorization request
* Shows approval dialog or redirects to Microsoft if already approved
*/
app.get('/authorize', async (c) => {
let oauthReqInfo;
try {
oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw);
} catch (error) {
console.error('parseAuthRequest error:', error);
return c.text('Invalid OAuth request. Make sure to connect through Claude.ai.', 400);
}
const { clientId } = oauthReqInfo;
if (!clientId) {
return c.text('Invalid request: missing client_id', 400);
}
// Check if client is already approved (skip consent screen)
if (await isClientApproved(c.req.raw, clientId, c.env.COOKIE_ENCRYPTION_KEY)) {
const { stateToken } = await createOAuthState(oauthReqInfo, c.env.OAUTH_KV);
const { setCookie: sessionBindingCookie } = await bindStateToSession(stateToken);
return redirectToMicrosoft(c.req.raw, stateToken, c.env, { 'Set-Cookie': sessionBindingCookie });
}
// Generate CSRF protection for approval form
const { token: csrfToken, setCookie } = generateCSRFProtection();
// ===== CUSTOMIZE: Server name and description =====
return renderApprovalDialog(c.req.raw, {
client: await c.env.OAUTH_PROVIDER.lookupClient(clientId) as { client_name?: string; client_uri?: string } | null,
csrfToken,
server: {
name: 'My MCP Server',
description: 'Connect your Microsoft account to enable MCP tools',
logo: 'https://www.jezweb.com.au/wp-content/uploads/2020/03/favicon-100x100.png',
},
setCookie,
state: { oauthReqInfo },
});
});
/**
* POST /authorize - Handle approval form submission
*/
app.post('/authorize', async (c) => {
try {
const formData = await c.req.raw.formData();
// Validate CSRF token
validateCSRFToken(formData, c.req.raw);
// Extract state from form data
const encodedState = formData.get('state');
if (!encodedState || typeof encodedState !== 'string') {
return c.text('Missing state in form data', 400);
}
let state: { oauthReqInfo?: AuthRequest };
try {
state = JSON.parse(atob(encodedState));
} catch {
return c.text('Invalid state data', 400);
}
if (!state.oauthReqInfo || !state.oauthReqInfo.clientId) {
return c.text('Invalid request', 400);
}
// Add client to approved list (won't show consent again for 30 days)
const approvedClientCookie = await addApprovedClient(
c.req.raw,
state.oauthReqInfo.clientId,
c.env.COOKIE_ENCRYPTION_KEY
);
// Create OAuth state and bind to session
const { stateToken } = await createOAuthState(state.oauthReqInfo, c.env.OAUTH_KV);
const { setCookie: sessionBindingCookie } = await bindStateToSession(stateToken);
// Set both cookies
const headers = new Headers();
headers.append('Set-Cookie', approvedClientCookie);
headers.append('Set-Cookie', sessionBindingCookie);
return redirectToMicrosoft(c.req.raw, stateToken, c.env, Object.fromEntries(headers));
} catch (error: unknown) {
console.error('POST /authorize error:', error);
if (error instanceof OAuthError) {
return error.toResponse();
}
const message = error instanceof Error ? error.message : 'Unknown error';
return c.text(`Internal server error: ${message}`, 500);
}
});
/**
* Redirect to Microsoft OAuth
*/
function redirectToMicrosoft(
request: Request,
stateToken: string,
env: Env,
headers: Record<string, string> = {}
) {
// This should never happen due to validation in handler-factory.ts
if (!env.MICROSOFT_CLIENT_ID) {
throw new Error('MICROSOFT_CLIENT_ID not configured');
}
const authUrl = buildMicrosoftAuthUrl({
clientId: env.MICROSOFT_CLIENT_ID,
redirectUri: new URL('/callback', request.url).href,
scope: MICROSOFT_SCOPES,
state: stateToken,
tenant: env.MICROSOFT_TENANT_ID,
// Don't set prompt here - buildMicrosoftAuthUrl handles it based on scope
});
return new Response(null, {
headers: {
...headers,
location: authUrl,
},
status: 302,
});
}
/**
* GET /callback - Handle Microsoft OAuth callback
*/
app.get('/callback', async (c) => {
if (!c.env.MICROSOFT_CLIENT_ID || !c.env.MICROSOFT_CLIENT_SECRET) {
return c.text('Microsoft OAuth not configured', 500);
}
let oauthReqInfo: AuthRequest;
let clearSessionCookie: string;
try {
const result = await validateOAuthState(c.req.raw, c.env.OAUTH_KV);
oauthReqInfo = result.oauthReqInfo;
clearSessionCookie = result.clearCookie;
} catch (error: unknown) {
if (error instanceof OAuthError) {
return error.toResponse();
}
return c.text('Internal server error', 500);
}
if (!oauthReqInfo.clientId) {
return c.text('Invalid OAuth request data', 400);
}
// Exchange code for tokens (access + refresh)
const [tokens, errResponse] = await exchangeMicrosoftCode({
clientId: c.env.MICROSOFT_CLIENT_ID,
clientSecret: c.env.MICROSOFT_CLIENT_SECRET,
code: c.req.query('code') || '',
redirectUri: new URL('/callback', c.req.url).href,
tenant: c.env.MICROSOFT_TENANT_ID,
});
if (errResponse) return errResponse;
// Fetch user info from Microsoft Graph
const user = await fetchMicrosoftUserInfo(tokens.accessToken);
if (!user) {
return c.text('Failed to fetch user info', 500);
}
const { id, email, name, picture } = user;
// Log refresh token status
if (tokens.refreshToken) {
console.log(`OAuth: Microsoft refresh token obtained for ${email}`);
} else {
console.warn(`OAuth: No Microsoft refresh token received for ${email}`);
}
// Complete authorization and return token to MCP client
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
metadata: {
label: name || email,
},
props: {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenExpiresAt: tokens.expiresAt,
email,
id,
name,
picture,
identityProvider: 'microsoft',
} as Props,
request: oauthReqInfo,
scope: oauthReqInfo.scope,
userId: id,
});
// Clear session binding cookie and redirect
const headers = new Headers({ Location: redirectTo });
if (clearSessionCookie) {
headers.set('Set-Cookie', clearSessionCookie);
}
return new Response(null, {
status: 302,
headers,
});
});
export { app as MicrosoftHandler };