feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
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>
This commit is contained in:
parent
fff0828d79
commit
0c3a4b625f
9 changed files with 419 additions and 12 deletions
20
.env.example
20
.env.example
|
|
@ -43,3 +43,23 @@ ALLOWED_ORIGINS=
|
||||||
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
|
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
|
||||||
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
|
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=false
|
||||||
|
|
||||||
|
# Google OAuth (OIDC) sign-in — OPTIONAL. Leave the client id/secret blank to
|
||||||
|
# disable; the "Sign in with Google" button and the /auth/google routes only
|
||||||
|
# activate when all three of CLIENT_ID, CLIENT_SECRET, and REDIRECT_URL are set.
|
||||||
|
# Create an OAuth 2.0 Client (type: Web application) in Google Cloud Console and
|
||||||
|
# add OAUTH_REDIRECT_URL to its authorized redirect URIs.
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
# Must exactly match a redirect URI on the OAuth client, e.g.
|
||||||
|
# https://dragonflight.live/api/v1/auth/google/callback
|
||||||
|
OAUTH_REDIRECT_URL=
|
||||||
|
# Restrict sign-in to one Google Workspace domain (recommended). First login from
|
||||||
|
# an allowed-domain account auto-provisions a NEW 'viewer' account (matched only
|
||||||
|
# by Google's stable subject id, never by email — so a Google login can never
|
||||||
|
# seize a pre-existing local account). An admin then grants project access.
|
||||||
|
# Leave blank to allow any verified Google account to self-provision (NOT advised).
|
||||||
|
GOOGLE_ALLOWED_DOMAIN=
|
||||||
|
# Note: if a Google-linked account also has TOTP enabled, sign-in still requires
|
||||||
|
# the authenticator code (Google is treated as the first factor). Accounts without
|
||||||
|
# TOTP complete sign-in in one Google step.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"qrcode": "^1.5.4"
|
"qrcode": "^1.5.4",
|
||||||
|
"google-auth-library": "^9.14.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=22.0.0"
|
||||||
|
|
|
||||||
86
services/mam-api/src/auth/google-oauth.js
Normal file
86
services/mam-api/src/auth/google-oauth.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
13
services/mam-api/src/db/migrations/028-google-oauth.sql
Normal file
13
services/mam-api/src/db/migrations/028-google-oauth.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Migration 028 — Google OAuth (OIDC) sign-in.
|
||||||
|
--
|
||||||
|
-- google_sub is Google's stable subject identifier — the join key for a linked
|
||||||
|
-- or auto-provisioned account (unique, but NULL for password-only users).
|
||||||
|
-- email is captured for display + domain checks. password_hash becomes nullable
|
||||||
|
-- so an OAuth-only account can exist without a local password; such an account
|
||||||
|
-- simply can't use the password login path until an admin sets one.
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS google_sub TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;
|
||||||
|
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_sub ON users(google_sub) WHERE google_sub IS NOT NULL;
|
||||||
|
|
@ -104,7 +104,10 @@ app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||||
|
|
||||||
// ── Auth gate ─────────────────────────────────────────────────────────────────
|
// ── Auth gate ─────────────────────────────────────────────────────────────────
|
||||||
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
|
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
|
||||||
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required']);
|
const UNAUTH_PATHS = new Set([
|
||||||
|
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
|
||||||
|
'/auth/google', '/auth/google/callback', '/auth/google/enabled',
|
||||||
|
]);
|
||||||
// node-agent now authenticates /cluster/heartbeat with a bound api_token
|
// node-agent now authenticates /cluster/heartbeat with a bound api_token
|
||||||
// (migration 019 + bound_hostname on the token). requireAuth handles the
|
// (migration 019 + bound_hostname on the token). requireAuth handles the
|
||||||
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
|
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import {
|
||||||
generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
|
generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
|
||||||
} from '../auth/totp.js';
|
} from '../auth/totp.js';
|
||||||
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
|
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
|
||||||
|
import {
|
||||||
|
isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify,
|
||||||
|
} from '../auth/google-oauth.js';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
|
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
|
||||||
|
|
||||||
|
|
@ -125,15 +129,26 @@ async function establishSession(req, user) {
|
||||||
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/v1/auth/login/totp { ticket, code } — second login step. `code` is
|
// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is
|
||||||
// either a 6-digit TOTP or a one-time recovery code.
|
// either a 6-digit TOTP or a one-time recovery code. The ticket comes from the
|
||||||
|
// request body (password-login path) or req.session.mfa_ticket (Google path).
|
||||||
router.post('/login/totp', async (req, res, next) => {
|
router.post('/login/totp', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||||
const { ticket, code } = req.body || {};
|
// Rate-limit the second factor with the same per-IP backoff as /login so
|
||||||
|
// the 6-digit code space can't be hammered.
|
||||||
|
const delay = ipBackoff.delayMs(ip);
|
||||||
|
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
||||||
|
|
||||||
|
const { ticket: bodyTicket, code } = req.body || {};
|
||||||
|
const ticket = bodyTicket || req.session?.mfa_ticket;
|
||||||
|
if (req.session?.mfa_ticket) delete req.session.mfa_ticket;
|
||||||
const userId = redeemTicket(ticket);
|
const userId = redeemTicket(ticket);
|
||||||
if (!userId) return res.status(401).json({ error: 'invalid or expired ticket' });
|
if (!userId) {
|
||||||
if (!code) return res.status(400).json({ error: 'code required' });
|
ipBackoff.recordFailure(ip);
|
||||||
|
return res.status(401).json({ error: 'invalid or expired ticket' });
|
||||||
|
}
|
||||||
|
if (!code) return res.status(400).json({ error: 'code required' });
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, username, display_name, totp_secret, totp_enabled FROM users WHERE id = $1`, [userId]);
|
`SELECT id, username, display_name, totp_secret, totp_enabled FROM users WHERE id = $1`, [userId]);
|
||||||
|
|
@ -150,12 +165,15 @@ router.post('/login/totp', async (req, res, next) => {
|
||||||
return res.status(401).json({ error: 'invalid code' });
|
return res.status(401).json({ error: 'invalid code' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ipBackoff.recordSuccess(ip);
|
||||||
await establishSession(req, user);
|
await establishSession(req, user);
|
||||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check a recovery code against the user's unused codes; mark it spent on match.
|
// Check a recovery code against the user's unused codes; mark it spent on match.
|
||||||
|
// The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check)
|
||||||
|
// so two concurrent redemptions of the same code can't both succeed.
|
||||||
async function consumeRecoveryCode(userId, code) {
|
async function consumeRecoveryCode(userId, code) {
|
||||||
const cleaned = String(code).trim().toLowerCase();
|
const cleaned = String(code).trim().toLowerCase();
|
||||||
if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
|
if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
|
||||||
|
|
@ -163,8 +181,10 @@ async function consumeRecoveryCode(userId, code) {
|
||||||
`SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
|
`SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (await comparePassword(cleaned, row.code_hash)) {
|
if (await comparePassword(cleaned, row.code_hash)) {
|
||||||
await pool.query(`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1`, [row.id]);
|
const upd = await pool.query(
|
||||||
return true;
|
`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]);
|
||||||
|
// Lost the race if another request already consumed it.
|
||||||
|
return upd.rowCount === 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -296,5 +316,105 @@ router.post('/totp/disable', requireAuth, async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Google OAuth (OIDC) sign-in ─────────────────────────────────────────────
|
||||||
|
// All Google routes are config-gated: without GOOGLE_CLIENT_ID/SECRET and
|
||||||
|
// OAUTH_REDIRECT_URL they 404, so a deployment without SSO is unaffected.
|
||||||
|
|
||||||
|
// GET /api/v1/auth/google/enabled — cheap, no auth. Lets the login screen decide
|
||||||
|
// whether to render the "Sign in with Google" button.
|
||||||
|
router.get('/google/enabled', (_req, res) => {
|
||||||
|
res.json({ enabled: googleConfigured() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/auth/google — kick off the OAuth dance. Stores an anti-CSRF state
|
||||||
|
// in the session and redirects to Google's consent screen.
|
||||||
|
router.get('/google', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
|
||||||
|
const state = randomBytes(16).toString('hex');
|
||||||
|
req.session.oauth_state = state;
|
||||||
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||||
|
res.redirect(await buildAuthUrl(state));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/auth/google/callback — Google redirects back here with ?code&state.
|
||||||
|
// Verifies the ID token, enforces the allowed domain, auto-provisions a viewer
|
||||||
|
// on first login, establishes the session, then redirects to the SPA.
|
||||||
|
router.get('/google/callback', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
|
||||||
|
const { code, state } = req.query;
|
||||||
|
const expected = req.session.oauth_state;
|
||||||
|
delete req.session.oauth_state;
|
||||||
|
if (!code || !state || !expected || state !== expected) {
|
||||||
|
return res.status(400).json({ error: 'invalid oauth state' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await exchangeAndVerify(code);
|
||||||
|
const user = await resolveGoogleUser(profile);
|
||||||
|
|
||||||
|
// If this account has TOTP enabled, Google is only the FIRST factor — route
|
||||||
|
// through the same second-factor step as password login. The ticket lives in
|
||||||
|
// the session (not the URL) and the SPA prompts for the code.
|
||||||
|
if (user.totp_enabled) {
|
||||||
|
req.session.mfa_ticket = issueTicket(user.id);
|
||||||
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||||
|
return res.redirect('/?mfa=1');
|
||||||
|
}
|
||||||
|
|
||||||
|
await establishSession(req, user);
|
||||||
|
|
||||||
|
// Redirect to the SPA root; AuthGate will re-check /auth/me and render the app.
|
||||||
|
res.redirect('/');
|
||||||
|
} catch (err) {
|
||||||
|
// Surface a friendly message on the login screen rather than a raw 500.
|
||||||
|
if (err.status === 403) return res.redirect('/?auth_error=domain');
|
||||||
|
if (err.status === 401) return res.redirect('/?auth_error=google');
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map a verified Google profile to a Dragonflight user row.
|
||||||
|
//
|
||||||
|
// Resolution order:
|
||||||
|
// 1. Existing link by google_sub → that user.
|
||||||
|
// 2. Otherwise auto-provision a fresh 'viewer'.
|
||||||
|
//
|
||||||
|
// We deliberately do NOT auto-link to an existing account by matching email:
|
||||||
|
// that would let anyone who controls a Google address with the same email sign
|
||||||
|
// in as a pre-existing local (possibly admin) account, bypassing its password
|
||||||
|
// and TOTP. Linking an existing account to Google is an explicit, authenticated
|
||||||
|
// action (a future "connect Google" under Settings), not something a login does.
|
||||||
|
async function resolveGoogleUser(profile) {
|
||||||
|
const found = await pool.query(
|
||||||
|
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
|
||||||
|
if (found.rows.length) return found.rows[0];
|
||||||
|
|
||||||
|
const base = (profile.email.split('@')[0] || 'user').replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user';
|
||||||
|
let username = base, n = 1;
|
||||||
|
while ((await pool.query(`SELECT 1 FROM users WHERE username = $1`, [username])).rows.length) {
|
||||||
|
username = base + (++n);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ins = await pool.query(
|
||||||
|
`INSERT INTO users (username, password_hash, display_name, role, email, google_sub)
|
||||||
|
VALUES ($1, NULL, $2, 'viewer', $3, $4)
|
||||||
|
RETURNING id, username, display_name, totp_enabled`,
|
||||||
|
[username, profile.name, profile.email, profile.sub]);
|
||||||
|
return ins.rows[0];
|
||||||
|
} catch (err) {
|
||||||
|
// Concurrent first-login race: the unique google_sub index rejected our
|
||||||
|
// INSERT because a sibling request just created the row. Re-resolve.
|
||||||
|
if (err.code === '23505') {
|
||||||
|
const retry = await pool.query(
|
||||||
|
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
|
||||||
|
if (retry.rows.length) return retry.rows[0];
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
export { realUserCount };
|
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };
|
||||||
|
|
|
||||||
40
services/mam-api/test/auth/google-oauth.test.js
Normal file
40
services/mam-api/test/auth/google-oauth.test.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// 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'));
|
||||||
|
});
|
||||||
63
services/mam-api/test/routes/google-link.test.js
Normal file
63
services/mam-api/test/routes/google-link.test.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// Security regression test for resolveGoogleUser: a Google sign-in must NEVER
|
||||||
|
// adopt a pre-existing local account by matching email (that would be account
|
||||||
|
// takeover). It links only by google_sub, otherwise provisions a fresh viewer.
|
||||||
|
// Skips without TEST_DATABASE_URL.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||||||
|
import { hashPassword } from '../../src/auth/passwords.js';
|
||||||
|
|
||||||
|
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
||||||
|
|
||||||
|
async function loadResolve() {
|
||||||
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||||
|
return (await import('../../src/routes/auth.js?v=' + Date.now())).resolveGoogleUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a Google login with an email matching a local admin does NOT take over that account', { skip: SKIP }, async () => {
|
||||||
|
const pool = await setupTestDb();
|
||||||
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||||
|
try {
|
||||||
|
// Pre-existing local admin with a password and the same email the attacker controls.
|
||||||
|
const adminId = (await pool.query(
|
||||||
|
`INSERT INTO users (username, password_hash, role, email)
|
||||||
|
VALUES ('boss', $1, 'admin', 'boss@wilddragon.net') RETURNING id`,
|
||||||
|
[await hashPassword('a-real-password-12')])).rows[0].id;
|
||||||
|
|
||||||
|
const resolveGoogleUser = await loadResolve();
|
||||||
|
const user = await resolveGoogleUser({ sub: 'google-attacker-sub', email: 'boss@wilddragon.net', name: 'Not The Boss' });
|
||||||
|
|
||||||
|
// Must be a brand-new account, NOT the admin.
|
||||||
|
assert.notEqual(user.id, adminId, 'Google login must not resolve to the existing admin');
|
||||||
|
const row = (await pool.query(`SELECT role, google_sub FROM users WHERE id = $1`, [user.id])).rows[0];
|
||||||
|
assert.equal(row.role, 'viewer', 'auto-provisioned account must be a viewer');
|
||||||
|
assert.equal(row.google_sub, 'google-attacker-sub');
|
||||||
|
// The admin row is untouched (no google_sub linked onto it).
|
||||||
|
const admin = (await pool.query(`SELECT google_sub FROM users WHERE id = $1`, [adminId])).rows[0];
|
||||||
|
assert.equal(admin.google_sub, null, 'the existing admin must not have been linked');
|
||||||
|
} finally { await pool.end(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a returning Google user resolves to the same account by google_sub', { skip: SKIP }, async () => {
|
||||||
|
const pool = await setupTestDb();
|
||||||
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||||
|
try {
|
||||||
|
const resolveGoogleUser = await loadResolve();
|
||||||
|
const first = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' });
|
||||||
|
const second = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' });
|
||||||
|
assert.equal(first.id, second.id, 'same google_sub must map to the same user');
|
||||||
|
const count = (await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE google_sub = 'sub-123'`)).rows[0].n;
|
||||||
|
assert.equal(count, 1, 'must not create a duplicate on second login');
|
||||||
|
} finally { await pool.end(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('username collisions get a numeric suffix', { skip: SKIP }, async () => {
|
||||||
|
const pool = await setupTestDb();
|
||||||
|
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||||
|
try {
|
||||||
|
await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('sam', 'x', 'viewer')`);
|
||||||
|
const resolveGoogleUser = await loadResolve();
|
||||||
|
const u = await resolveGoogleUser({ sub: 'sub-sam', email: 'sam@wilddragon.net', name: 'Sam' });
|
||||||
|
assert.match(u.username, /^sam\d+$/, 'colliding username should get a numeric suffix');
|
||||||
|
} finally { await pool.end(); }
|
||||||
|
});
|
||||||
|
|
@ -116,15 +116,73 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Google sign-in availability + a friendly message for the callback's
|
||||||
|
// ?auth_error redirect (domain-not-allowed / generic google failure).
|
||||||
|
function useGoogleAndAuthError(setError) {
|
||||||
|
const [googleEnabled, setGoogleEnabled] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch(API_BASE + '/auth/google/enabled', { credentials: 'include' })
|
||||||
|
.then(r => r.json()).then(d => setGoogleEnabled(!!d.enabled)).catch(() => {});
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const e = params.get('auth_error');
|
||||||
|
if (e === 'domain') setError('That Google account is not in an allowed domain.');
|
||||||
|
else if (e === 'google') setError('Google sign-in failed. Please try again.');
|
||||||
|
if (e) {
|
||||||
|
// Clean the query string so a reload doesn't re-show the error.
|
||||||
|
const url = location.pathname + location.hash;
|
||||||
|
history.replaceState(null, '', url);
|
||||||
|
}
|
||||||
|
}, [setError]);
|
||||||
|
return googleEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoogleButton() {
|
||||||
|
return (
|
||||||
|
<a href={API_BASE + '/auth/google'} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
width: '100%', boxSizing: 'border-box', textDecoration: 'none',
|
||||||
|
background: 'var(--bg-3)', color: 'var(--text-1)',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 4,
|
||||||
|
padding: '9px', fontSize: 13, fontWeight: 600, marginTop: 10,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700 }}>G</span>
|
||||||
|
Sign in with Google
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '14px 0 4px' }}>
|
||||||
|
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>or</span>
|
||||||
|
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LoginScreen({ onDone }) {
|
function LoginScreen({ onDone }) {
|
||||||
const [username, setUsername] = React.useState('');
|
const [username, setUsername] = React.useState('');
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
const [error, setError] = React.useState('');
|
const [error, setError] = React.useState('');
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
// Second factor: when the server returns { mfa_required, ticket }, we switch
|
// Second factor: when the server returns { mfa_required, ticket }, we switch
|
||||||
// to the code step instead of completing login.
|
// to the code step instead of completing login. `ticket` may be a real value
|
||||||
|
// (password path) or the sentinel 'session' (Google path, where the ticket
|
||||||
|
// lives in the session cookie and is not exposed to JS).
|
||||||
const [ticket, setTicket] = React.useState(null);
|
const [ticket, setTicket] = React.useState(null);
|
||||||
const [code, setCode] = React.useState('');
|
const [code, setCode] = React.useState('');
|
||||||
|
const googleEnabled = useGoogleAndAuthError(setError);
|
||||||
|
|
||||||
|
// Google OAuth with TOTP redirects back to /?mfa=1; the ticket is in the
|
||||||
|
// session, so enter the code step without a body ticket.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
if (params.get('mfa') === '1') {
|
||||||
|
setTicket('session');
|
||||||
|
history.replaceState(null, '', location.pathname + location.hash);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setError(''); setBusy(true);
|
setError(''); setBusy(true);
|
||||||
|
|
@ -144,7 +202,9 @@
|
||||||
const submitCode = async () => {
|
const submitCode = async () => {
|
||||||
setError(''); setBusy(true);
|
setError(''); setBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await postJson('/auth/login/totp', { ticket, code: code.trim() });
|
// For the Google path the ticket is the session sentinel — send code only.
|
||||||
|
const payload = ticket === 'session' ? { code: code.trim() } : { ticket, code: code.trim() };
|
||||||
|
const r = await postJson('/auth/login/totp', payload);
|
||||||
if (r.status === 200) { onDone(); return; }
|
if (r.status === 200) { onDone(); return; }
|
||||||
const body = await r.json().catch(() => ({}));
|
const body = await r.json().catch(() => ({}));
|
||||||
// An expired/used ticket means the user must start over.
|
// An expired/used ticket means the user must start over.
|
||||||
|
|
@ -180,6 +240,7 @@
|
||||||
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
||||||
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
|
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
|
||||||
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
|
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
|
||||||
|
{googleEnabled && <><Divider /><GoogleButton /></>}
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue