/**
* Better-Auth OAuth Handler for MCP Server
*
* Integrates better-auth social login with workers-oauth-provider for MCP clients.
*
* Flow:
* 1. MCP client hits /authorize
* 2. Parse auth request, store in KV, redirect to /login
* 3. User logs in via better-auth (Google/Microsoft/GitHub)
* 4. better-auth redirects to /mcp-auth-complete
* 5. Read better-auth session, complete MCP authorization
*
* Routes:
* - GET / - Server homepage
* - GET /authorize - Parse MCP request, redirect to better-auth login
* - POST /authorize - Handle approval form submission
* - GET /mcp-auth-complete - Complete MCP auth after better-auth login
* - GET /admin/login - Admin login (redirects to /login)
* - GET /admin/callback - Legacy route, redirects to new flow
*/
import type { AuthRequest, OAuthHelpers } from '@cloudflare/workers-oauth-provider';
import { Hono } from 'hono';
import { eq, and } from 'drizzle-orm';
import { createAuth } from '../lib/auth';
import { createDatabase } from '../lib/db';
import { account } from '../lib/db/schema';
import type { Env, Props } from '../types';
import {
addApprovedClient,
generateCSRFProtection,
isClientApproved,
OAuthError,
renderApprovalDialog,
validateCSRFToken,
} from './workers-oauth-utils';
import { createAdminSession } from '../admin/session';
import { renderHomepage, formatToolName } from '@jezweb/mcp-ui';
import { getToolsMetadata } from '../tools';
// State storage key prefix
const MCP_AUTH_STATE_PREFIX = 'mcp_auth_state:';
const app = new Hono<{
Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers };
}>();
/**
* GET / - MCP Server Homepage
*/
app.get('/', (c) => {
const tools = getToolsMetadata();
const serverUrl = c.env.BETTER_AUTH_URL || 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',
},
// Lead generation chat widget
enableLeadChat: c.env.ENABLE_LEAD_CHAT === 'true',
})
);
});
/**
* GET /admin/login - Admin login via better-auth
*/
app.get('/admin/login', (c) => {
// Redirect to better-auth login with admin callback
const loginUrl = new URL('/login', c.req.url);
loginUrl.searchParams.set('callbackURL', '/admin');
return c.redirect(loginUrl.toString());
});
/**
* GET /admin/callback - Legacy callback, now handled by better-auth
*/
app.get('/admin/callback', async (c) => {
// Check for better-auth session
const auth = createAuth(c.env);
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
// No session, redirect to login
return c.redirect('/login?callbackURL=/admin');
}
// Create admin session from better-auth session
const { cookie } = await createAdminSession(
{
email: session.user.email,
name: session.user.name || session.user.email,
picture: session.user.image || undefined,
},
c.env.OAUTH_KV
);
return new Response(null, {
status: 302,
headers: {
Location: '/admin',
'Set-Cookie': cookie,
},
});
});
/**
* GET /authorize - Initial MCP authorization request
* Shows approval dialog or redirects to better-auth login
*/
app.get('/authorize', async (c) => {
let oauthReqInfo: AuthRequest;
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 user already has a better-auth session
const auth = createAuth(c.env);
const session = await auth.api.getSession({ headers: c.req.raw.headers });
// Check if client is already approved (skip consent screen)
if (session && await isClientApproved(c.req.raw, clientId, c.env.COOKIE_ENCRYPTION_KEY)) {
// User is logged in and client is approved - complete immediately
return await completeMcpAuth(c, oauthReqInfo, session);
}
// Generate CSRF protection for approval form
const { token: csrfToken, setCookie } = generateCSRFProtection();
// Show approval dialog
return renderApprovalDialog(c.req.raw, {
client: await c.env.OAUTH_PROVIDER.lookupClient(clientId) as { client_name?: string; client_uri?: string } | null,
clientId,
csrfToken,
server: {
name: 'My MCP Server',
description: 'Sign in to connect your account and 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
const approvedClientCookie = await addApprovedClient(
c.req.raw,
state.oauthReqInfo.clientId,
c.env.COOKIE_ENCRYPTION_KEY
);
// Check if user already has a better-auth session
const auth = createAuth(c.env);
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (session) {
// User is already logged in, complete MCP auth immediately
const response = await completeMcpAuth(c, state.oauthReqInfo, session);
// Add the approved client cookie
const headers = new Headers(response.headers);
headers.append('Set-Cookie', approvedClientCookie);
return new Response(response.body, {
status: response.status,
headers,
});
}
// Store OAuth request info in KV for retrieval after login
const stateToken = crypto.randomUUID();
await c.env.OAUTH_KV.put(
`${MCP_AUTH_STATE_PREFIX}${stateToken}`,
JSON.stringify(state.oauthReqInfo),
{ expirationTtl: 600 } // 10 minutes
);
// Build login URL with return to mcp-auth-complete
const loginUrl = new URL('/login', c.req.url);
loginUrl.searchParams.set('callbackURL', `/mcp-auth-complete?state=${stateToken}`);
// Set approved client cookie and redirect to login
return new Response(null, {
status: 302,
headers: {
Location: loginUrl.toString(),
'Set-Cookie': approvedClientCookie,
},
});
} 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);
}
});
/**
* GET /mcp-auth-complete - Complete MCP authorization after better-auth login
*/
app.get('/mcp-auth-complete', async (c) => {
const stateToken = c.req.query('state');
if (!stateToken) {
return c.text('Missing state parameter', 400);
}
// Retrieve stored OAuth request info
const storedData = await c.env.OAUTH_KV.get(`${MCP_AUTH_STATE_PREFIX}${stateToken}`);
if (!storedData) {
return c.text('Invalid or expired state. Please try connecting again.', 400);
}
// Delete used state
await c.env.OAUTH_KV.delete(`${MCP_AUTH_STATE_PREFIX}${stateToken}`);
let oauthReqInfo: AuthRequest;
try {
oauthReqInfo = JSON.parse(storedData);
} catch {
return c.text('Invalid state data', 400);
}
// Get better-auth session
const auth = createAuth(c.env);
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
// No session - redirect back to login
console.error('No better-auth session found after login callback');
return c.redirect('/login?error=session_not_found');
}
// Complete MCP authorization with user info from better-auth session
return await completeMcpAuth(c, oauthReqInfo, session);
});
/**
* Complete MCP authorization using better-auth session
*
* Uses better-auth's getAccessToken API which auto-refreshes expired tokens
*/
async function completeMcpAuth(
c: { env: Env & { OAUTH_PROVIDER: OAuthHelpers }; req: { url: string; raw: { headers: Headers } } },
oauthReqInfo: AuthRequest,
session: { user: { id: string; email: string; name: string | null; image: string | null } }
) {
const { user } = session;
console.log(`MCP Auth: Completing authorization for ${user.email}`);
let accessToken = '';
let refreshToken: string | undefined;
let tokenExpiresAt: number | undefined;
// Use better-auth's getAccessToken which auto-refreshes expired tokens
const auth = createAuth(c.env);
try {
const tokenResult = await auth.api.getAccessToken({
body: {
providerId: 'google',
},
headers: c.req.raw.headers,
});
if (tokenResult?.accessToken) {
accessToken = tokenResult.accessToken;
// better-auth may return refresh token and expiry
refreshToken = (tokenResult as { refreshToken?: string }).refreshToken;
tokenExpiresAt = (tokenResult as { accessTokenExpiresAt?: Date }).accessTokenExpiresAt?.getTime();
console.log(`MCP Auth: Got access token for ${user.email} via better-auth API`);
} else {
console.log(`MCP Auth: No access token returned for ${user.email}`);
}
} catch (error) {
console.error(`MCP Auth: getAccessToken failed for ${user.email}:`, error);
// Fallback: query account table directly
const db = createDatabase(c.env.DB!);
const googleAccount = await db.query.account.findFirst({
where: and(eq(account.userId, user.id), eq(account.providerId, 'google')),
});
if (googleAccount?.accessToken) {
accessToken = googleAccount.accessToken;
refreshToken = googleAccount.refreshToken || undefined;
// Convert seconds to milliseconds if needed
const expiresAt = googleAccount.accessTokenExpiresAt?.getTime();
tokenExpiresAt = expiresAt && expiresAt < 10000000000 ? expiresAt * 1000 : expiresAt;
console.log(`MCP Auth: Fallback - got token from account table for ${user.email}`);
}
}
console.log(
`MCP Auth: Google account for ${user.email}, token ${accessToken ? 'present' : 'missing'}`
);
// Complete authorization and return token to MCP client
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
metadata: {
label: user.name || user.email,
},
props: {
accessToken,
refreshToken,
tokenExpiresAt,
email: user.email,
id: user.id,
name: user.name || user.email,
picture: user.image || undefined,
identityProvider: 'better-auth',
} as Props,
request: oauthReqInfo,
scope: oauthReqInfo.scope,
userId: user.id,
});
return new Response(null, {
status: 302,
headers: {
Location: redirectTo,
},
});
}
export { app as BetterAuthHandler };