// Google OAuth (OIDC) sign-in helpers. // // Entirely config-gated: if GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / // OAUTH_REDIRECT_URL aren't set, isConfigured() is false and the routes 404, so // a deployment without Google SSO behaves exactly as before. google-auth-library // is imported lazily so the dependency is only required when the feature is on. // // Flow: /auth/google redirects to Google's consent screen with a signed `state`; // /auth/google/callback exchanges the code, verifies the ID token, enforces the // allowed Workspace domain, and auto-provisions a viewer account on first login. const SCOPES = ['openid', 'email', 'profile']; export function isConfigured() { return !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.OAUTH_REDIRECT_URL); } export function allowedDomain() { return (process.env.GOOGLE_ALLOWED_DOMAIN || '').trim().toLowerCase() || null; } // Lazily build an OAuth2 client (throws a clear error if the dep is missing). async function makeClient() { let OAuth2Client; try { ({ OAuth2Client } = await import('google-auth-library')); } catch { const err = new Error('google-auth-library is not installed'); err.status = 500; throw err; } return new OAuth2Client({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, redirectUri: process.env.OAUTH_REDIRECT_URL, }); } // URL of Google's consent screen. `state` is an opaque anti-CSRF token we also // stash in the session and re-check on callback. export async function buildAuthUrl(state) { const client = await makeClient(); return client.generateAuthUrl({ access_type: 'online', scope: SCOPES, state, prompt: 'select_account', // If a Workspace domain is configured, hint Google to scope the picker to it. ...(allowedDomain() ? { hd: allowedDomain() } : {}), }); } // Exchange the authorization code and verify the returned ID token. Returns the // verified { sub, email, name, hd } payload. Throws { status } on any failure. export async function exchangeAndVerify(code) { const client = await makeClient(); const { tokens } = await client.getToken(code); if (!tokens.id_token) { const err = new Error('no id_token from Google'); err.status = 401; throw err; } const ticket = await client.verifyIdToken({ idToken: tokens.id_token, audience: process.env.GOOGLE_CLIENT_ID, }); const p = ticket.getPayload(); if (!p || !p.sub) { const err = new Error('invalid id_token'); err.status = 401; throw err; } // Require an explicitly verified email — a missing/undefined claim is NOT // treated as verified, since the email drives account linking/provisioning. if (!p.email || p.email_verified !== true) { const err = new Error('email not verified'); err.status = 403; throw err; } const domain = allowedDomain(); if (domain) { // ONLY trust Google's `hd` (hosted-domain) claim — it's present iff the // account is a member of a Google Workspace domain that Google itself // has verified. The email-suffix fallback we used to allow let any // non-Workspace account with a spoof-friendly email through; if a // GOOGLE_ALLOWED_DOMAIN is set, the operator means "only this Workspace," // and consumer accounts (no hd) must be rejected. const hd = (p.hd || '').toLowerCase(); if (hd !== domain) { const err = new Error('domain not allowed'); err.status = 403; throw err; } } return { sub: p.sub, email: p.email, name: p.name || p.email, hd: p.hd || null }; }