/**
* GitHub OAuth Handler for MCP Server
*
* Routes:
* - GET / - Server homepage
* - GET /authorize - Show approval dialog or redirect to GitHub
* - POST /authorize - Process approval form submission
* - GET /callback - Handle GitHub OAuth callback
*
* Key differences from other providers:
* - GitHub tokens don't expire (no refresh flow needed)
* - Email may be private (handled by identity functions)
*/
import type { AuthRequest, OAuthHelpers } from '@cloudflare/workers-oauth-provider';
import { Hono } from 'hono';
import {
buildGitHubAuthUrl,
exchangeGitHubCode,
fetchGitHubUserInfo,
GITHUB_IDENTITY_SCOPES,
} from '../auth/identity/github';
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: GitHub OAuth scopes =====
// See: https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps
const GITHUB_SCOPES = GITHUB_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 GitHub OAuth
* Separate from MCP client authorization flow
*/
app.get('/admin/login', async (c) => {
if (!c.env.GITHUB_CLIENT_ID) {
return c.text('GitHub OAuth not configured', 500);
}
// 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 GitHub OAuth
const authUrl = buildGitHubAuthUrl({
clientId: c.env.GITHUB_CLIENT_ID,
redirectUri: new URL('/admin/callback', c.req.url).href,
scope: 'user:email read:user',
state: stateToken,
});
return c.redirect(authUrl);
});
/**
* GET /admin/callback - Handle admin OAuth callback
*/
app.get('/admin/callback', async (c) => {
if (!c.env.GITHUB_CLIENT_ID || !c.env.GITHUB_CLIENT_SECRET) {
return c.text('GitHub 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');
if (error) {
return c.text(`OAuth error: ${error} - ${errorDescription || ''}`, 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 [tokens, errResponse] = await exchangeGitHubCode({
clientId: c.env.GITHUB_CLIENT_ID,
clientSecret: c.env.GITHUB_CLIENT_SECRET,
code,
redirectUri: new URL('/admin/callback', c.req.url).href,
});
if (errResponse) {
return errResponse;
}
// Fetch user info (handles private email fallback internally)
const user = await fetchGitHubUserInfo(tokens.accessToken);
if (!user) {
return c.text('Failed to fetch user info', 500);
}
// 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 GitHub 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 redirectToGitHub(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 GitHub 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 redirectToGitHub(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 GitHub OAuth
*/
function redirectToGitHub(
request: Request,
stateToken: string,
env: Env,
headers: Record<string, string> = {}
) {
// This should never happen due to validation in handler-factory.ts
if (!env.GITHUB_CLIENT_ID) {
throw new Error('GITHUB_CLIENT_ID not configured');
}
const authUrl = buildGitHubAuthUrl({
clientId: env.GITHUB_CLIENT_ID,
redirectUri: new URL('/callback', request.url).href,
scope: GITHUB_SCOPES,
state: stateToken,
});
return new Response(null, {
headers: {
...headers,
location: authUrl,
},
status: 302,
});
}
/**
* GET /callback - Handle GitHub OAuth callback
*/
app.get('/callback', async (c) => {
if (!c.env.GITHUB_CLIENT_ID || !c.env.GITHUB_CLIENT_SECRET) {
return c.text('GitHub 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 (no refresh token for GitHub)
const [tokens, errResponse] = await exchangeGitHubCode({
clientId: c.env.GITHUB_CLIENT_ID,
clientSecret: c.env.GITHUB_CLIENT_SECRET,
code: c.req.query('code') || '',
redirectUri: new URL('/callback', c.req.url).href,
});
if (errResponse) return errResponse;
// Fetch user info from GitHub (handles private email internally)
const user = await fetchGitHubUserInfo(tokens.accessToken);
if (!user) {
return c.text('Failed to fetch user info', 500);
}
const { id, email, name, picture } = user;
// Log token info (GitHub tokens don't expire)
console.log(`OAuth: GitHub token obtained for ${email} (no expiry)`);
// Complete authorization and return token to MCP client
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
metadata: {
label: name || email,
},
props: {
accessToken: tokens.accessToken,
// No refresh token - GitHub tokens don't expire
refreshToken: undefined,
tokenExpiresAt: undefined,
email,
id,
name,
picture,
identityProvider: 'github',
} 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 GitHubHandler };