import { Hono } from "hono";
import type { AuthRequest, OAuthHelpers } from "@cloudflare/workers-oauth-provider";
// Google OAuth URLs
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo";
// OAuth scopes
const SCOPES = "openid email profile";
// User info type from Google
interface GoogleUserInfo {
sub: string;
email: string;
email_verified: boolean;
name: string;
picture?: string;
hd?: string; // Hosted domain (Google Workspace)
}
// Token response from Google
interface GoogleTokenResponse {
access_token: string;
expires_in: number;
refresh_token?: string;
scope: string;
token_type: string;
id_token?: string;
}
// Props that will be passed to MCP server
export type GoogleOAuthProps = {
email: string;
name: string;
picture?: string;
accessToken: string;
};
// Extended Env type with OAuth helpers and secrets
type AppEnv = Env & {
OAUTH_PROVIDER: OAuthHelpers;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
COOKIE_ENCRYPTION_KEY: string;
};
const app = new Hono<{ Bindings: AppEnv }>();
// Helper to render HTML pages
function renderHTML(title: string, body: string): Response {
return new Response(
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 { color: #333; margin-bottom: 20px; }
p { color: #666; line-height: 1.6; }
.btn {
display: inline-block;
padding: 12px 24px;
background: #4285f4;
color: white;
text-decoration: none;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 16px;
margin-right: 10px;
}
.btn:hover { background: #3367d6; }
.btn-secondary {
background: #757575;
}
.btn-secondary:hover { background: #616161; }
.error { color: #d32f2f; }
.client-info {
background: #f9f9f9;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
}
form { display: inline; }
</style>
</head>
<body>
<div class="container">
${body}
</div>
</body>
</html>`,
{
headers: { "Content-Type": "text/html" },
},
);
}
// Generate a random state for CSRF protection
function generateState(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
// GET /authorize - Direct redirect to Google (no consent screen)
app.get("/authorize", async (c) => {
const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw);
if (!oauthReqInfo.clientId) {
return new Response("Invalid Request: Missing client information", {
status: 400,
});
}
// Generate state for CSRF protection
const state = generateState();
// Store OAuth request info in KV with the state as key
await c.env.OAUTH_KV.put(
`oauth_state:${state}`,
JSON.stringify(oauthReqInfo),
{ expirationTtl: 600 }, // 10 minutes
);
// Build Google OAuth URL
const googleAuthUrl = new URL(GOOGLE_AUTH_URL);
googleAuthUrl.searchParams.set("client_id", c.env.GOOGLE_CLIENT_ID);
googleAuthUrl.searchParams.set(
"redirect_uri",
`${new URL(c.req.url).origin}/callback`,
);
googleAuthUrl.searchParams.set("response_type", "code");
googleAuthUrl.searchParams.set("scope", SCOPES);
googleAuthUrl.searchParams.set("state", state);
googleAuthUrl.searchParams.set("access_type", "offline");
googleAuthUrl.searchParams.set("prompt", "consent");
// Add domain hint if ALLOWED_DOMAIN is set
if (c.env.ALLOWED_DOMAIN) {
googleAuthUrl.searchParams.set("hd", c.env.ALLOWED_DOMAIN);
}
// Direct redirect to Google OAuth (no consent screen)
return Response.redirect(googleAuthUrl.toString(), 302);
});
// GET /callback - Handle Google OAuth callback
app.get("/callback", async (c) => {
const url = new URL(c.req.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
// Handle errors from Google
if (error) {
return renderHTML(
"Authentication Error",
`<h1 class="error">Authentication Failed</h1><p>Error: ${error}</p>`,
);
}
if (!code || !state) {
return renderHTML(
"Error",
'<h1 class="error">Invalid Callback</h1><p>Missing code or state parameter.</p>',
);
}
// Retrieve and validate state
const storedData = await c.env.OAUTH_KV.get(`oauth_state:${state}`);
if (!storedData) {
return renderHTML(
"Error",
'<h1 class="error">Invalid State</h1><p>State validation failed or expired.</p>',
);
}
// Clean up state
await c.env.OAUTH_KV.delete(`oauth_state:${state}`);
// Parse original OAuth request info
let oauthReqInfo: AuthRequest;
try {
oauthReqInfo = JSON.parse(storedData);
} catch {
return renderHTML(
"Error",
'<h1 class="error">Invalid State</h1><p>Failed to parse state data.</p>',
);
}
try {
// Exchange code for tokens with Google
const tokenResponse = await fetch(GOOGLE_TOKEN_URL, {
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: `${url.origin}/callback`,
}),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
console.error("Token exchange failed:", errorText);
return renderHTML(
"Error",
'<h1 class="error">Authentication Failed</h1><p>Failed to exchange authorization code.</p>',
);
}
const tokens: GoogleTokenResponse = await tokenResponse.json();
// Get user info from Google
const userInfoResponse = await fetch(GOOGLE_USERINFO_URL, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
});
if (!userInfoResponse.ok) {
return renderHTML(
"Error",
'<h1 class="error">Authentication Failed</h1><p>Failed to fetch user information.</p>',
);
}
const userInfo: GoogleUserInfo = await userInfoResponse.json();
// Validate email is verified
if (!userInfo.email_verified) {
return renderHTML(
"Error",
'<h1 class="error">Access Denied</h1><p>Your email address is not verified.</p>',
);
}
// Validate domain if ALLOWED_DOMAIN is set
const allowedDomainEnv = c.env.ALLOWED_DOMAIN as string;
if (allowedDomainEnv && allowedDomainEnv.length > 0) {
const userDomain = userInfo.email.split("@")[1]?.toLowerCase();
const allowedDomain = allowedDomainEnv.toLowerCase();
if (userDomain !== allowedDomain) {
return renderHTML(
"Error",
`<h1 class="error">Access Denied</h1><p>Only @${allowedDomain} accounts are allowed.</p>`,
);
}
}
// Create props to pass to MCP server
const props: GoogleOAuthProps = {
email: userInfo.email,
name: userInfo.name,
picture: userInfo.picture,
accessToken: tokens.access_token,
};
// Complete the OAuth flow
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
request: oauthReqInfo,
userId: userInfo.email,
metadata: {
label: userInfo.name,
},
scope: oauthReqInfo.scope,
props,
});
// Redirect back to the client
return Response.redirect(redirectTo, 302);
} catch (err) {
console.error("OAuth callback error:", err);
return renderHTML(
"Error",
'<h1 class="error">Authentication Failed</h1><p>An unexpected error occurred.</p>',
);
}
});
// Health check endpoint
app.get("/health", (c) => {
return c.json({ status: "ok" });
});
// Export as ExportedHandler for OAuthProvider compatibility
export const GoogleHandler = {
fetch: app.fetch,
} as ExportedHandler;