import { Hono, type Context } from "hono"; import { handle } from "hono/vercel"; import { createRemoteJWKSet, jwtVerify } from "jose"; type ErrorCode = | "CONFIGURATION_ERROR" | "INVALID_REQUEST" | "INVALID_TOKEN" | "UNAUTHORIZED"; const GOOGLE_JWKS_URL = new URL("https://www.googleapis.com/oauth2/v3/certs"); const GOOGLE_ISSUERS = new Set([ "accounts.google.com", "https://accounts.google.com", ]); const googleJwks = createRemoteJWKSet(GOOGLE_JWKS_URL); const app = new Hono(); export const config = { runtime: "edge", }; function parseClientIds(raw: string | undefined): string[] { return (raw ?? "") .split(",") .map((part) => part.trim()) .filter(Boolean); } function jsonError( c: Context, status: 400 | 401 | 503, code: ErrorCode, message: string, ) { c.header("Cache-Control", "no-store"); return c.json({ error: code, message }, status); } async function verifyGoogleToken(c: Context) { const expectedSecret = process.env.GOOGLE_TOKEN_VERIFIER_SECRET?.trim(); if (!expectedSecret) { return jsonError( c, 503, "CONFIGURATION_ERROR", "GOOGLE_TOKEN_VERIFIER_SECRET is not configured", ); } if (c.req.header("authorization") !== `Bearer ${expectedSecret}`) { return jsonError(c, 401, "UNAUTHORIZED", "Unauthorized"); } const clientIds = parseClientIds(process.env.GOOGLE_OAUTH_CLIENT_IDS); if (clientIds.length === 0) { return jsonError( c, 503, "CONFIGURATION_ERROR", "GOOGLE_OAUTH_CLIENT_IDS is not configured", ); } let body: unknown; try { body = await c.req.json(); } catch { return jsonError(c, 400, "INVALID_REQUEST", "JSON body is required"); } const idToken = typeof body === "object" && body !== null && "id_token" in body && typeof body.id_token === "string" ? body.id_token.trim() : ""; if (!idToken) { return jsonError(c, 400, "INVALID_REQUEST", "id_token is required"); } try { const { payload } = await jwtVerify(idToken, googleJwks, { algorithms: ["RS256"], audience: clientIds, }); const issuer = typeof payload.iss === "string" ? payload.iss : ""; if (!GOOGLE_ISSUERS.has(issuer)) { return jsonError(c, 401, "INVALID_TOKEN", "Invalid token issuer"); } const audiences = Array.isArray(payload.aud) ? payload.aud : typeof payload.aud === "string" ? [payload.aud] : []; const audience = audiences.find((item) => clientIds.includes(item)) ?? ""; if (!audience) { return jsonError(c, 401, "INVALID_TOKEN", "Invalid token audience"); } const subject = typeof payload.sub === "string" ? payload.sub.trim() : ""; const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : ""; const emailVerified = payload.email_verified === true || String(payload.email_verified).toLowerCase() === "true"; if (!subject || !email || !emailVerified) { return jsonError(c, 401, "INVALID_TOKEN", "Verified email is required"); } c.header("Cache-Control", "no-store"); return c.json({ subject, email, email_verified: true, name: typeof payload.name === "string" ? payload.name.trim() : "", picture: typeof payload.picture === "string" && payload.picture.trim() ? payload.picture.trim() : null, audience, issuer, }); } catch { return jsonError(c, 401, "INVALID_TOKEN", "Google token is invalid"); } } app.post("/", verifyGoogleToken); app.post("/api/verify-google-token", verifyGoogleToken); app.get("/", (c) => c.json({ status: "ok" })); app.get("/api/verify-google-token", (c) => c.json({ status: "ok" })); export default handle(app);