Review of the v2 auth landing turned up four weak spots in the MFA path. All four are now fixed; behaviour is unchanged for the password-correct + correct-TOTP happy path. 1. TOTP brute-force gate (the big one). /login was calling ipBackoff.recordSuccess(ip) the instant the password hashed correctly, *before* the second factor was proven. That cleared the per-IP failure counter, so each /login retry let an attacker with a known password hammer the 6-digit /login/totp space (10^6) at full speed. Now recordSuccess fires only inside establishSession() — i.e. after every required factor has actually passed (password [+TOTP] or OAuth [+TOTP]). 2. MFA ticket binding. Tickets issued by /login (and the Google callback) were unbound — a stolen ticket replayed from a different origin still worked. Tickets now carry SHA-256 hashes of the issuing request's IP and User-Agent; redeemTicket rejects on mismatch. The ticket is burned even on mismatch so a wrong-binding probe can't be retried. 3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier accepted the same code as many times as you submitted it. Now verifyToken returns the matched counter, and /login/totp does a CAS UPDATE on users.totp_last_counter — codes at counters <= the last accepted value are rejected. New migration 030 adds totp_last_counter, seeded on /totp/enable so the enrollment code itself can't be reused at first login, and zeroed on /totp/disable. 4. Google OAuth domain check no longer falls back to the email suffix when the hd (hosted-domain) claim is missing. Email-suffix matching let consumer (non-Workspace) Google accounts whose email happens to end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set, the operator means "only this Workspace", so accounts without a verified hd must be rejected. Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on mismatch, and bindings-absent back-compat. totp.test.js updated for the new verifyToken return shape (counter on success, null on failure; truthiness still works at call sites) and adds an explicit matched-counter check. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
90 lines
3.5 KiB
JavaScript
90 lines
3.5 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) {
|
|
// 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 };
|
|
}
|