Optional "Sign in with Google" with auto-provisioning, fully config-gated: without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the button is hidden, so deployments without SSO are unaffected. - migration 028: users.google_sub (unique) + email; password_hash nullable for OAuth-only accounts - src/auth/google-oauth.js: lazy google-auth-library, ID-token verify, GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true - auth routes: /auth/google (state-CSRF redirect), /auth/google/callback, /auth/google/enabled; reuses establishSession - web-ui: "Sign in with Google" on the login screen (shown only when enabled), friendly callback error handling - .env.example documents all new vars Security hardening (from review of this + the TOTP work): - resolveGoogleUser links ONLY by google_sub, never by email — a Google login can never seize a pre-existing local account (account-takeover fix) - a Google-linked account with TOTP still requires the second factor (ticket in session, /?mfa=1 step) instead of bypassing it - /login/totp now applies the per-IP login backoff - recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount) - concurrent first-login race on google_sub is caught and re-resolved - tests: google-oauth config helpers + google-link takeover/dedup regression Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
40 lines
1.9 KiB
JavaScript
40 lines
1.9 KiB
JavaScript
// Unit tests for the config-gating + domain helpers in google-oauth.js. The
|
|
// token-exchange / ID-token-verify path requires Google's servers and is covered
|
|
// by manual verification (see .env.example); here we lock down the pure logic
|
|
// that decides whether the feature is on and which domain is allowed.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { isConfigured, allowedDomain } from '../../src/auth/google-oauth.js';
|
|
|
|
function withEnv(vars, fn) {
|
|
const saved = {};
|
|
for (const k of Object.keys(vars)) { saved[k] = process.env[k];
|
|
if (vars[k] === undefined) delete process.env[k]; else process.env[k] = vars[k]; }
|
|
try { return fn(); }
|
|
finally {
|
|
for (const k of Object.keys(vars)) {
|
|
if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k];
|
|
}
|
|
}
|
|
}
|
|
|
|
test('isConfigured is false unless client id, secret, and redirect are all set', () => {
|
|
withEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => {
|
|
assert.equal(isConfigured(), false);
|
|
});
|
|
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => {
|
|
assert.equal(isConfigured(), false);
|
|
});
|
|
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: undefined }, () => {
|
|
assert.equal(isConfigured(), false);
|
|
});
|
|
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: 'https://h/cb' }, () => {
|
|
assert.equal(isConfigured(), true);
|
|
});
|
|
});
|
|
|
|
test('allowedDomain normalizes and defaults to null', () => {
|
|
withEnv({ GOOGLE_ALLOWED_DOMAIN: undefined }, () => assert.equal(allowedDomain(), null));
|
|
withEnv({ GOOGLE_ALLOWED_DOMAIN: '' }, () => assert.equal(allowedDomain(), null));
|
|
withEnv({ GOOGLE_ALLOWED_DOMAIN: ' WildDragon.NET ' }, () => assert.equal(allowedDomain(), 'wilddragon.net'));
|
|
});
|