/**
* Google OAuth Handler for MCP Server
*
* Routes:
* - GET / - Server homepage
* - GET /authorize - Show approval dialog or redirect to Google
* - POST /authorize - Process approval form submission
* - GET /callback - Handle Google OAuth callback
*/
import type { AuthRequest, OAuthHelpers } from '@cloudflare/workers-oauth-provider';
import { Hono } from 'hono';
import {
fetchUpstreamAuthToken,
fetchGoogleUserInfo,
getUpstreamAuthorizeUrl,
} from '../auth/identity/google';
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';
import { createTokenManager } from '../lib/token-manager';
// ===== CUSTOMIZE: Google OAuth scopes for your API =====
// Google Calendar MCP Server - full calendar access
const GOOGLE_SCOPES = 'openid email profile https://www.googleapis.com/auth/calendar';
const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>();
/**
* GET / - MCP Server Homepage
*/
app.get('/', (c) => {
const tools = getToolsMetadata();
// Get server URL from request for connection details
const serverUrl = new URL(c.req.url).origin;
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,
})),
connection: {
serverUrl,
serverName: 'google-calendar',
},
})
);
});
/**
* GET /admin/login - Admin login via Google OAuth
* Separate from MCP client authorization flow
*/
app.get('/admin/login', async (c) => {
// Generate state token for admin login
const stateToken = crypto.randomUUID();
// 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
);
// Redirect to Google OAuth
const googleAuthUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
googleAuthUrl.searchParams.set('client_id', c.env.GOOGLE_CLIENT_ID);
googleAuthUrl.searchParams.set('redirect_uri', new URL('/admin/callback', c.req.url).href);
googleAuthUrl.searchParams.set('response_type', 'code');
googleAuthUrl.searchParams.set('scope', 'openid email profile');
googleAuthUrl.searchParams.set('state', stateToken);
googleAuthUrl.searchParams.set('access_type', 'online'); // No refresh token needed for admin
return c.redirect(googleAuthUrl.toString());
});
/**
* GET /admin/callback - Handle admin OAuth callback
*/
app.get('/admin/callback', async (c) => {
const code = c.req.query('code');
const state = c.req.query('state');
const error = c.req.query('error');
if (error) {
return c.text(`OAuth error: ${error}`, 400);
}
if (!code || !state) {
return c.text('Missing code or state', 400);
}
// Validate state
const stateData = await c.env.OAUTH_KV.get(`admin_state:${state}`);
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 tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: c.env.GOOGLE_CLIENT_ID,
client_secret: c.env.GOOGLE_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: new URL('/admin/callback', c.req.url).href,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
console.error('Token exchange failed:', error);
return c.text('Token exchange failed', 500);
}
const tokens = await tokenResponse.json() as { access_token: string };
// Fetch user info
const userResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (!userResponse.ok) {
return c.text('Failed to fetch user info', 500);
}
const user = await userResponse.json() as { email: string; name: string; picture?: string };
// 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 Google 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 redirectToGoogle(c.req.raw, stateToken, c.env.GOOGLE_CLIENT_ID, { '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 Google 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 redirectToGoogle(c.req.raw, stateToken, c.env.GOOGLE_CLIENT_ID, 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 Google OAuth
*/
function redirectToGoogle(
request: Request,
stateToken: string,
googleClientId: string,
headers: Record<string, string> = {}
) {
const googleAuthUrl = getUpstreamAuthorizeUrl({
client_id: googleClientId,
redirect_uri: new URL('/callback', request.url).href,
scope: GOOGLE_SCOPES,
state: stateToken,
upstream_url: 'https://accounts.google.com/o/oauth2/v2/auth',
});
return new Response(null, {
headers: {
...headers,
location: googleAuthUrl,
},
status: 302,
});
}
/**
* GET /callback - Handle Google OAuth callback
*/
app.get('/callback', async (c) => {
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 fetchUpstreamAuthToken({
client_id: c.env.GOOGLE_CLIENT_ID,
client_secret: c.env.GOOGLE_CLIENT_SECRET,
code: c.req.query('code'),
redirect_uri: new URL('/callback', c.req.url).href,
upstream_url: 'https://oauth2.googleapis.com/token',
});
if (errResponse) return errResponse;
// Fetch user info from Google
const user = await fetchGoogleUserInfo(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: Refresh token obtained for ${email}`);
} else {
console.warn(`OAuth: No refresh token received for ${email}`);
}
// Store token via TokenManager for encrypted persistence
// This is the source of truth for API access - handles automatic refresh
try {
const tokenManager = createTokenManager(c.env);
await tokenManager.store({
userId: id,
provider: 'google',
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
scopes: GOOGLE_SCOPES.split(' '),
});
console.log(`TokenManager: Stored encrypted token for ${email}`);
} catch (error) {
// Log but don't fail OAuth - fall back to props storage
console.error('TokenManager: Failed to store token:', error);
}
// 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,
} 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,
});
});
/**
* Refresh Google access token using refresh token
*
* Used by better-auth flow to refresh expired tokens
*/
export async function refreshAccessToken({
client_id,
client_secret,
refresh_token,
}: {
client_id: string;
client_secret: string;
refresh_token: string;
}): Promise<{ accessToken: string; expiresAt: number; refreshToken?: string } | null> {
try {
const resp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id,
client_secret,
refresh_token,
grant_type: 'refresh_token',
}).toString(),
});
if (!resp.ok) {
console.error('Token refresh failed:', resp.status, await resp.text());
return null;
}
const body = (await resp.json()) as {
access_token: string;
expires_in: number;
refresh_token?: string;
};
return {
accessToken: body.access_token,
expiresAt: Date.now() + body.expires_in * 1000,
// Google may return a new refresh token (rare, but handle it)
refreshToken: body.refresh_token || refresh_token,
};
} catch (error) {
console.error('Token refresh error:', error);
return null;
}
}
export { app as GoogleHandler };