87 lines
3.3 KiB
JavaScript
87 lines
3.3 KiB
JavaScript
|
|
// 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) {
|
||
|
|
const emailDomain = String(p.email).split('@')[1]?.toLowerCase();
|
||
|
|
// Prefer Google's hosted-domain claim; fall back to the email domain.
|
||
|
|
const hd = (p.hd || emailDomain || '').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 };
|
||
|
|
}
|