screentinker/server/routes/auth.js
ScreenTinker c91b96ab91 SSO: refuse delegated proof names, release lapsed and deleted claims
Third review pass. It confirmed the crash wrapper holds (~13,000 hostile requests,
no fourth crash), the SSRF rewrite holds (77 vectors, every CIDR boundary proven),
the rate-limiter rewrite closed the login brute-force bypass, and /sso/claim rejects
every wrong token kind. It also found that two things I built yesterday did not do
what they claimed.

THE 8-HOUR LIMIT DID NOT BOUND SQUATTING

Pressing Verify on an expired claim REISSUED it in place, renewing the clock — so one
request per window held a domain forever, through the endpoint meant to enforce the
limit. Worse, a renewal was not a new claim, so the operator was notified exactly once,
on day zero: a tenant could sit on a company's domain for a year off a single stale
alert. A lapsed claim is now RELEASED. Re-adding it is an ordinary new claim: new
token, and the operator is told again. Squatting is not impossible; it is loud.

A DELEGATED PROOF NAME COULD FORGE A DOMAIN

A TXT lookup follows CNAMEs, and RFC 4592 means a wildcard `*.victim.com` synthesizes
`_screentinker-verify.victim.com` too — so a wildcard CNAME let whoever controls its
target prove a domain they do not own, turning an ordinary subdomain takeover into
every `@victim.com` login. A reviewer did this against a real authoritative zone. The
proof name is now refused if it is a CNAME, which is stricter than ACME's dns-01, and
the comment that claimed wildcards "cannot be mistaken for a proof" — true only for
wildcard TXT — has been corrected.

MY VERIFY BUTTON REPORTED FAILURE ON SUCCESS

`await load()` — the loader is `loadSso()`. The ReferenceError went into a bare catch,
so a correct DNS proof showed "Could not verify that domain" and left the card stale.
On the expired branch the admin kept publishing a token the server had already rotated.

ALSO FIXED

  - deleting a provider stranded its verified domains (no FK, UNIQUE, never expires) so
    the domain was blocked for EVERY org forever with no in-product recovery, and its
    users could neither sign in nor reset. Delete now releases the domains and returns
    the accounts to local, in one transaction; a cascade FK backstops it.
  - isOrphanedFederated read absence-of-config as proof-of-deletion, so unsetting
    GOOGLE_CLIENT_ID made every Google account password-resettable instance-wide, and
    irreversibly. Restricted to org-provider slugs.
  - `email_domains: null` (not undefined) took the destructive branch and deleted every
    DNS proof an organization had.
  - unbounded domain lists: 400 domains sent 401 emails; now capped at 50, one digest
    per save, and /api/organizations is rate-limited at all for the first time.
  - login and register responses carried password_reset_hash and email_verify_hash —
    live account-takeover credentials handed to the browser. One sanitiser now.
  - trailing-dot hostname (`https://localhost./`) slipped the SSRF guard.
  - asyncRoute's own catch could throw and kill the process it exists to protect.
  - a legacy DB whose typed domains were never verified now says so LOUDLY at boot
    instead of silently locking every federated user out.

TESTS

Two of the previous round's tests passed against the code they were named after: one
asserted UNIQUE against the test harness's own CREATE TABLE rather than the shipped
schema, the other used two different domains so no ordering was exercised. Both
replaced and confirmed load-bearing. Seven mutations now turn the suite red, including
removing the CNAME refusal, the verified_at filter, and the expiry itself.

1598 tests pass. Delete-release, lapse-release and the leak fix verified against a
running server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 21:01:26 -05:00

1413 lines
73 KiB
JavaScript

const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const { generateToken, generateMfaPendingToken, verifyMfaPendingToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const { logActivity, getClientIp } = require('../services/activity');
const totp = require('../lib/totp');
const totpLockout = require('../lib/totp-lockout');
const loginLockout = require('../lib/login-lockout');
const QRCode = require('qrcode');
const { sendSignupEmails, sendVerificationEmail, sendPasswordResetEmail } = require('../services/signupEmails');
const passwordReset = require('../lib/passwordReset');
const emailVerify = require('../lib/emailVerify');
const emailSvc = require('../services/email');
const { deleteUserCascade, OrgHasOtherMembersError } = require('../lib/user-deletion');
const config = require('../config');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const oidc = require('../lib/oidc');
const oidcProviders = require('../lib/oidc-providers');
// Phase 2.1: find or create the user's default org+workspace. Returns the
// workspace_id to embed in the JWT. Idempotent: if the user already has
// memberships (e.g. migrated from Phase 1), returns the first one without
// creating anything.
// #12: allowCreate gates the MINT path only. An existing membership is always
// returned (idempotent). When allowCreate is false and the user has no
// membership, returns null - the caller is created org-less and an admin /
// operator assigns them to a workspace afterward.
function ensureDefaultOrgForUser(user, { allowCreate = true } = {}) {
const existing = db.prepare(`
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ?
ORDER BY wm.joined_at ASC LIMIT 1
`).get(user.id);
if (existing) return existing.id;
if (!allowCreate) return null;
// No memberships -> mint a fresh org and Default workspace owned by user.
const orgId = uuidv4();
const wsId = uuidv4();
const orgName = (user.name && user.name.trim())
? `${user.name}'s organization`
: `${user.email}'s organization`;
const tx = db.transaction(() => {
db.prepare(`INSERT INTO organizations (
id, name, owner_user_id, plan_id,
stripe_customer_id, stripe_subscription_id,
subscription_status, subscription_ends
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
orgId, orgName, user.id, user.plan_id || 'free',
user.stripe_customer_id || null, user.stripe_subscription_id || null,
user.subscription_status || 'active', user.subscription_ends || null
);
db.prepare(`INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_owner')`).run(orgId, user.id);
db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, 'Default', ?)`).run(wsId, orgId, user.id);
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(wsId, user.id);
});
tx();
return wsId;
}
function logFailedLogin(email, ip, reason) {
try {
db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (NULL, ?, ?, ?)')
.run('auth:login_failed', `${email} - ${reason}`, ip);
} catch {}
}
function logSuccessfulLogin(userId, email, ip) {
try {
// Phase 2.2 writer-leak fix: stamp the user's oldest workspace so this
// login event is queryable in tenant-scoped activity views. Multi-workspace
// users still land on one row; the activity dashboard already shows
// per-user context separately from per-workspace context.
const ws = db.prepare(
'SELECT workspace_id FROM workspace_members WHERE user_id = ? ORDER BY joined_at ASC LIMIT 1'
).get(userId);
db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address, workspace_id) VALUES (?, ?, ?, ?, ?)')
.run(userId, 'auth:login_success', email, ip, ws?.workspace_id || null);
db.prepare("UPDATE users SET last_login = strftime('%s','now') WHERE id = ?").run(userId);
} catch {}
}
// ==================== Local Auth ====================
// Returns true if new account creation is allowed at this moment.
// First-user setup (empty DB) is always allowed so a fresh install can be initialized.
function canRegister() {
if (!config.disableRegistration) return true;
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
return userCount === 0;
}
// Register
router.post('/register', (req, res) => {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const { email, password, name, createOrg } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (existing) return res.status(409).json({ error: 'Email already registered' });
const id = uuidv4();
const passwordHash = bcrypt.hashSync(password, 10);
// First user becomes platform_admin with enterprise plan (self-hosted) or free plan with Pro trial.
// Phase 1 renamed the legacy 'superadmin' role to 'platform_admin'; new bootstrap users get the new name directly.
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'platform_admin' : 'user';
const isFirstUser = userCount === 0;
const plan = (isFirstUser && config.selfHosted) ? 'enterprise' : 'pro'; // Start on Pro trial
const trialStarted = isFirstUser && config.selfHosted ? null : Math.floor(Date.now() / 1000);
// Email verification: require it for a normal local signup only when we can actually send
// the mail. The bootstrap (first) user is never gated — a fresh install must not lock out
// its own admin — and neither is an instance with no email transport configured (a self-host
// that can't send would otherwise strand every signup). email_verified column DEFAULTs to 1,
// so we only ever write 0 here on the require-verification path.
const requireVerify = !isFirstUser && emailSvc.isConfigured();
const emailVerified = requireVerify ? 0 : 1;
db.prepare(`
INSERT INTO users (id, email, name, password_hash, auth_provider, role, plan_id, trial_started, trial_plan, email_verified)
VALUES (?, ?, ?, ?, 'local', ?, ?, ?, ?, ?)
`).run(id, email.toLowerCase(), name || email.split('@')[0], passwordHash, role, plan, trialStarted, trialStarted ? 'pro' : null, emailVerified);
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, stripe_customer_id, stripe_subscription_id, subscription_status, subscription_ends, email_verified FROM users WHERE id = ?').get(id);
// #12: org-on-create. Per-request createOrg overrides the deployment default
// (config.autoCreateOrgOnSignup). The first user is always given an org so a
// fresh install is never left headless. When neither applies, the user is
// created org-less and lands on the "no workspaces yet" state until an admin
// assigns them.
const createOrgForUser = isFirstUser
|| (createOrg !== undefined ? !!createOrg : config.autoCreateOrgOnSignup);
const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: createOrgForUser });
// Welcome + admin-notify emails (hosted instance only, idempotent, async).
sendSignupEmails(user, req);
// Verification email (issue a token first) whenever this signup needs to confirm its address.
if (requireVerify) {
const vtoken = emailVerify.issue(user.id);
sendVerificationEmail(user, vtoken, req);
}
// Hosted (SELF_HOSTED unset) HARD-BLOCKS an unverified local signup: no session until they
// click the link. Self-host is a soft nudge — fall through and issue the session; the client
// shows a "verify your email" banner (user.email_verified === 0) with a resend button.
if (requireVerify && !config.selfHosted) {
return res.status(201).json({ verification_required: true, email: user.email });
}
const token = generateToken(user, workspaceId);
res.status(201).json({ token, user, current_workspace_id: workspaceId });
});
// Login
router.post('/login', (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
const user = db.prepare('SELECT * FROM users WHERE email = ? AND auth_provider = ?').get(email.toLowerCase(), 'local');
if (!user) {
logFailedLogin(email, getClientIp(req), 'User not found');
return res.status(401).json({ error: 'Invalid email or password' });
}
// Per-ACCOUNT brute-force lockout (lib/login-lockout), on top of the per-IP limiter in
// server.js. Checked BEFORE bcrypt so a locked account costs no hashing work.
//
// The response is deliberately IDENTICAL to a wrong password: a distinct 429 would tell
// an attacker "this account exists and is under attack", turning the endpoint into an
// account-existence oracle. The trade is that a locked-out legitimate user sees the
// generic message, so the trip is written to activity_log for the operator instead.
if (loginLockout.isLocked(user.id)) {
logFailedLogin(email, getClientIp(req), 'Locked out (too many failed passwords)');
return res.status(401).json({ error: 'Invalid email or password' });
}
if (!bcrypt.compareSync(password, user.password_hash)) {
const rec = loginLockout.recordFailure(user.id);
if (rec.lockedUntil) logActivity(null, 'auth:login_locked', `${email} - locked after repeated failures`, null, getClientIp(req));
logFailedLogin(email, getClientIp(req), 'Wrong password');
return res.status(401).json({ error: 'Invalid email or password' });
}
// Password proven. Clear the counter HERE rather than in issueSession: the TOTP and
// email-verification branches below return before issueSession is ever reached, so a
// reset placed there would never fire for those accounts.
loginLockout.reset(user.id);
// Email verification gate. Unverified LOCAL accounts are asked to confirm on login — this
// covers both new signups AND existing users who predate the feature (grandfathered locals are
// email_verified=0). Gated ONLY where we can actually send the mail (isConfigured), so an
// instance with no email transport never locks anyone out. Existing users never received a
// signup email, so (re)send one here (guarded against re-mailing a still-valid token). HOSTED
// hard-blocks — no session, no MFA step; self-host is a soft nudge (login proceeds, client
// shows a banner). SSO + platform admins are grandfathered to 1, so this never trips for them.
if (!user.email_verified && emailSvc.isConfigured()) {
ensureVerificationEmail(user, req);
if (!config.selfHosted) {
return res.json({ verification_required: true, email: user.email });
}
}
// #100: password OK. If TOTP is enabled, DON'T issue a session yet - return an
// mfa_pending token; the client completes via POST /api/auth/totp/verify. This is
// the ONLY place TOTP gates (interactive password login). The SSO routes and the
// API-token path never reach here, so both bypass TOTP by construction.
if (user.totp_enabled) {
return res.json({ mfa_required: true, mfa_token: generateMfaPendingToken(user) });
}
issueSession(req, res, user);
});
// #100: finish an interactive login - shared by /login (no TOTP) and /totp/verify
// (after TOTP). Logs the successful login + issues the full session JWT.
function issueSession(req, res, user, extra = {}) {
logSuccessfulLogin(user.id, user.email, getClientIp(req));
const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup });
const token = generateToken(user, workspaceId);
// #100: callers pass a SELECT * row. Strip password_hash AND the TOTP internals
// (the encrypted secret + the replay counter) so no secret/internal rides in the
// response body - "secrets never in responses", same as the API token work.
const safeUser = publicUser(user);
res.json({ token, user: safeUser, current_workspace_id: workspaceId, ...extra });
}
// ==================== Email verification (signup) ====================
// (Re)send a verification email for an unverified user, UNLESS a still-valid token is already
// pending — so a login-gated user isn't re-mailed on every attempt. Callers have already checked
// emailSvc.isConfigured(). `user` is a SELECT * row (carries email_verify_expires).
function ensureVerificationEmail(user, req) {
const now = Math.floor(Date.now() / 1000);
if (user.email_verify_expires && user.email_verify_expires > now) return; // valid token still out
const token = emailVerify.issue(user.id);
sendVerificationEmail(user, token, req);
}
// The emailed link lands here (GET, unauthenticated — the user isn't logged in yet). We flip
// the flag and redirect into the app with a flash flag, so there's no separate frontend route.
router.get('/verify-email', (req, res) => {
const ok = emailVerify.consume(req.query.token);
return res.redirect(ok ? '/app#/login?verified=1' : '/app#/login?verify_error=1');
});
// Resend the verification email. Unauthenticated (the hosted gate blocks the session, so the
// user has no token) and rate-limited in server.js. Always returns a generic success so it
// never reveals whether an address exists or is already verified.
router.post('/resend-verification', (req, res) => {
const email = String(req.body?.email || '').toLowerCase().trim();
if (email) {
const user = db.prepare("SELECT * FROM users WHERE email = ? AND auth_provider = 'local'").get(email);
if (user && !user.email_verified) {
const token = emailVerify.issue(user.id);
sendVerificationEmail(user, token, req);
}
}
res.json({ ok: true });
});
// ==================== Self-service password reset ====================
// Two endpoints, both unauthenticated by necessity (the user cannot log in).
//
// The request endpoint ALWAYS answers the same way — same status, same body — whether the
// address exists, is an SSO identity with no local password, or is malformed. Anything
// else turns it into an account-existence oracle, which is the classic mistake here.
//
// Completing a reset deliberately does NOT return a session. The user logs in afterwards,
// so a TOTP-enabled account still has to clear its second factor; issuing a token here
// would turn "read one email" into a full session and quietly bypass MFA.
const RESET_GENERIC_OK = { ok: true, message: 'If that address has an account, a reset link is on its way.' };
/*
* An account whose identity provider no longer exists — and why it may reset a password.
*
* A federated row normally must NOT be resettable: the identity provider owns that account, and
* offering a password would be a way around it. But a provider can be deleted, and the row it
* created outlives it, pointing at a slug nothing answers to. Such an account cannot log in by any
* route: no provider to authenticate against, no password to reset, and registration refuses the
* address as taken.
*
* That is not only an accident. A tenant can claim a domain it does not own (claims are not yet
* verified — see the README), sign in as an address there, delete its provider, and leave the real
* owner permanently unable to reach an account bearing their own address.
*
* Proving control of the MAILBOX is the right way out, and it is strictly stronger evidence than
* the identity-provider assertion that created the row. So an orphaned account may reset, and doing
* so returns it to a local account. A row whose provider still exists is untouched by this.
*/
/*
* What an identity provider is allowed to call an email address.
*
* Exactly one @, no whitespace, no control characters, a domain with at least one dot. Deliberately
* stricter than the RFC — this is not validating what may exist in the world, it is deciding what
* this system will key an ACCOUNT on, and every exotic form is a way for two spellings to look like
* one address to a human and two to the database.
*/
const ASSERTED_EMAIL_RE = /^[^\s@\x00-\x1f]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
/**
* May this provider speak for this address?
*
* A pure function on purpose: the confinement it implements is the single control standing between
* per-organization SSO and an account-takeover primitive, and a control that can only be exercised
* by standing up a hostile identity provider is a control that does not get tested. It was in fact
* shipped untested once — the test named after it asserted only that a provider row carried two
* fields, and passed with the guard deleted.
*
* `provider.emailDomains` is the VERIFIED set (see rowToProvider), so this cannot be satisfied by a
* domain the tenant merely typed.
*/
function emailAllowedForProvider(provider, email) {
// Instance-wide providers are the operator's own choice and keep the trust they have always had.
if (!provider.organizationId) return true;
const addr = String(email || '').toLowerCase();
/*
* Malformed addresses are refused rather than tidied. `victim@evil.test@acme.test\n` used to pass
* — lastIndexOf('@') took `acme.test\n`, and trimming turned it into an allowed domain — so an
* address that is not one thing got treated as belonging to a domain it only ended with. Anything
* carrying whitespace, control characters or a second @ is not an address this will reason about.
*/
if (!ASSERTED_EMAIL_RE.test(addr)) return false;
const at = addr.lastIndexOf('@');
if (at === -1) return false;
const domain = addr.slice(at + 1).trim();
if (!domain) return false;
// Lowercased on both sides: forEmail lowercases when routing, and a row that differed in case
// would otherwise route a user in and then reject them at the callback.
const allowed = String(provider.emailDomains || '').split(',').map((d) => d.trim().toLowerCase()).filter(Boolean);
return allowed.includes(domain);
}
function isOrphanedFederated(user) {
if (!user || user.auth_provider === 'local') return false;
/*
* ⚠️ ONLY an organization provider's slug, never an instance one.
*
* This used to ask "does anything answer to that slug?", which cannot tell DELETED apart from
* NOT CURRENTLY CONFIGURED. Unsetting GOOGLE_CLIENT_ID — or fat-fingering MICROSOFT_TENANT_ID to
* `common`, a typo the provider code already refuses — therefore made every account on that
* provider password-resettable instance-wide, and irreversibly: the reset rewrites auth_provider
* to 'local', so restoring the variable does not restore the binding. An organization that chose
* SSO to enforce its IdP's MFA would have had that silently downgraded to mailbox access.
*
* Org slugs are generated as `org` + 12 hex (see org-sso.js), so the shape is decisive: an env
* provider can never match it, and an unconfigured env provider is UNAVAILABLE, not deleted.
*
* Deleting an org provider now returns its users to local accounts outright (org-sso.js), so this
* only catches rows stranded some other way — an interrupted delete, a restored backup.
*/
if (!/^org[0-9a-f]{12}$/.test(String(user.auth_provider))) return false;
return !oidcProviders.ownerOf(user.auth_provider);
}
router.post('/forgot-password', (req, res) => {
const email = String(req.body?.email || '').toLowerCase().trim();
// Respond identically no matter what happens below.
try {
if (email) {
const candidate = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
// A local account, or one stranded by a deleted provider — see isOrphanedFederated above.
const user = candidate && (candidate.auth_provider === 'local' || isOrphanedFederated(candidate))
? candidate : null;
if (user) {
if (!emailSvc.isConfigured()) {
// Loud, because the user will wait for an email that can never arrive and the
// generic response cannot tell them.
console.error(`[password-reset] NO EMAIL TRANSPORT CONFIGURED — reset requested for ${email} cannot be delivered.`);
} else {
const token = passwordReset.issue(user.id);
sendPasswordResetEmail(user, token, req).catch(e =>
console.error('[password-reset] send failed:', e && e.message));
logActivity(user.id, 'auth:password_reset_requested', null, null, getClientIp(req));
}
}
}
} catch (e) {
console.error('[password-reset] request error:', e && e.message);
}
return res.json(RESET_GENERIC_OK);
});
router.post('/reset-password', (req, res) => {
const { token, password } = req.body || {};
if (!password || String(password).length < passwordReset.MIN_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must be at least ${passwordReset.MIN_PASSWORD_LENGTH} characters` });
}
const userId = passwordReset.consume(token, String(password));
if (!userId) return res.status(400).json({ error: 'This reset link is invalid or has expired. Request a new one.' });
// Someone who locked themselves out guessing must not stay locked out after proving
// control of the mailbox and choosing a new password.
loginLockout.reset(userId);
const u = db.prepare('SELECT email, auth_provider FROM users WHERE id = ?').get(userId);
/*
* Return a stranded federated row to a local account. Without this the reset would "succeed" and
* change nothing anyone can use: POST /login only ever looks at auth_provider = 'local', so the
* new password would be unreachable and the account still lost.
*/
if (isOrphanedFederated(u)) {
db.prepare("UPDATE users SET auth_provider = 'local', provider_id = NULL WHERE id = ?").run(userId);
console.log(`[password-reset] ${u.email} reclaimed from deleted provider ${u.auth_provider}`);
logActivity(userId, 'auth:federated_account_reclaimed', `was ${u.auth_provider}`, null, getClientIp(req));
}
logActivity(userId, 'auth:password_reset_completed', null, null, getClientIp(req));
console.log(`[password-reset] password changed for ${u ? u.email : userId}`);
// No session on purpose — see above.
return res.json({ ok: true, message: 'Password updated. You can now sign in.' });
});
// ==================== TOTP MFA (#100) ====================
// Opt-in per-user, LOCAL accounts only (SSO IdPs own MFA). Enrollment is a two-step
// confirm (setup -> enable) so a mistyped secret can't lock anyone out. Recovery
// codes are shown ONCE at enable, stored SHA-256-hashed, single-use.
const RECOVERY_CODE_COUNT = 10;
function recoveryCodesRemaining(userId) {
return db.prepare('SELECT COUNT(*) AS n FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL').get(userId).n;
}
// Atomically replace a user's recovery codes - no window where old + new both verify
// (tightening #3). Returns the plaintext set (shown ONCE).
function resetRecoveryCodes(userId) {
const { plain, hashes } = totp.generateRecoveryCodes(RECOVERY_CODE_COUNT);
db.transaction(() => {
db.prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?').run(userId);
const ins = db.prepare('INSERT INTO totp_recovery_codes (id, user_id, code_hash) VALUES (?, ?, ?)');
for (const h of hashes) ins.run(uuidv4(), userId, h);
})();
return plain;
}
// Consume one single-use recovery code (mark used). True if a fresh code matched.
function consumeRecoveryCode(userId, input) {
if (!input) return false;
const row = db.prepare('SELECT id FROM totp_recovery_codes WHERE user_id = ? AND code_hash = ? AND used_at IS NULL')
.get(userId, totp.hashRecoveryCode(input));
if (!row) return false;
db.prepare("UPDATE totp_recovery_codes SET used_at = strftime('%s','now') WHERE id = ?").run(row.id);
return true;
}
router.get('/totp/status', requireAuth, (req, res) => {
const u = db.prepare('SELECT totp_enabled, auth_provider FROM users WHERE id = ?').get(req.user.id);
res.json({
enabled: !!u.totp_enabled,
eligible: u.auth_provider === 'local',
recovery_codes_remaining: u.totp_enabled ? recoveryCodesRemaining(req.user.id) : 0,
});
});
// Step 1: mint a pending secret + return the otpauth:// URI + a ready-to-render QR
// data URL (drawn server-side with the already-bundled `qrcode` lib, same as the
// device-owner provisioning QR). The raw secret is also returned for manual entry.
router.post('/totp/setup', requireAuth, asyncRoute(async (req, res) => {
const u = db.prepare('SELECT auth_provider, totp_enabled, email FROM users WHERE id = ?').get(req.user.id);
if (u.auth_provider !== 'local') return res.status(400).json({ error: 'TOTP is only for password accounts; your identity provider manages MFA.' });
if (u.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled. Disable it first to re-enroll.' });
const secret = totp.generateSecret();
db.prepare("UPDATE users SET totp_secret_enc = ?, totp_enabled = 0, updated_at = strftime('%s','now') WHERE id = ?")
.run(totp.encryptSecret(secret), req.user.id);
// Fold the instance host into the QR label so users with accounts on more than one
// ScreenTinker can tell them apart in their authenticator app (#100). trust-proxy is set,
// so req.get('host') is the public host even behind Cloudflare/nginx.
const host = (req.get('host') || '').replace(/[^A-Za-z0-9.:-]/g, '').slice(0, 60);
const otpauth_uri = totp.keyuri(u.email, secret, host || undefined);
let qr_data_url = null;
// QR is a convenience — if it fails, the client still has otpauth_uri + secret for manual entry.
try { qr_data_url = await QRCode.toDataURL(otpauth_uri, { errorCorrectionLevel: 'M', margin: 1, width: 240 }); }
catch (e) { /* fall through with qr_data_url = null */ }
res.json({ otpauth_uri, secret, qr_data_url });
}));
// Step 2: confirm a code from the user's app, THEN enable + issue recovery codes (once).
router.post('/totp/enable', requireAuth, (req, res) => {
const u = db.prepare('SELECT totp_secret_enc, totp_enabled, totp_last_step, auth_provider FROM users WHERE id = ?').get(req.user.id);
if (u.auth_provider !== 'local') return res.status(400).json({ error: 'TOTP unavailable for SSO accounts.' });
if (u.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled.' });
if (!u.totp_secret_enc) return res.status(400).json({ error: 'Start with POST /api/auth/totp/setup.' });
const step = totp.verifyCode(req.body.code, totp.decryptSecret(u.totp_secret_enc), u.totp_last_step);
if (!step) return res.status(400).json({ error: 'Invalid code' });
db.prepare("UPDATE users SET totp_enabled = 1, totp_last_step = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(step, req.user.id);
res.json({ enabled: true, recovery_codes: resetRecoveryCodes(req.user.id) }); // shown ONCE
});
// Disable: re-auth with a current code (or a recovery code) so a hijacked session
// can't silently strip MFA. Clears the secret + all recovery codes.
router.post('/totp/disable', requireAuth, (req, res) => {
const u = db.prepare('SELECT totp_secret_enc, totp_enabled, totp_last_step FROM users WHERE id = ?').get(req.user.id);
if (!u.totp_enabled) return res.status(400).json({ error: 'TOTP is not enabled.' });
const ok = !!totp.verifyCode(req.body.code, totp.decryptSecret(u.totp_secret_enc), u.totp_last_step)
|| consumeRecoveryCode(req.user.id, req.body.code);
if (!ok) return res.status(400).json({ error: 'Invalid code' });
db.transaction(() => {
db.prepare("UPDATE users SET totp_enabled = 0, totp_secret_enc = NULL, totp_last_step = 0, updated_at = strftime('%s','now') WHERE id = ?").run(req.user.id);
db.prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?').run(req.user.id);
})();
res.json({ enabled: false });
});
// Regenerate recovery codes: re-auth (current code) + ATOMIC replace (tightening #3).
router.post('/totp/recovery-codes/regenerate', requireAuth, (req, res) => {
const u = db.prepare('SELECT totp_secret_enc, totp_enabled, totp_last_step FROM users WHERE id = ?').get(req.user.id);
if (!u.totp_enabled) return res.status(400).json({ error: 'TOTP is not enabled.' });
const step = totp.verifyCode(req.body.code, totp.decryptSecret(u.totp_secret_enc), u.totp_last_step);
if (!step) return res.status(400).json({ error: 'Invalid code' });
db.prepare('UPDATE users SET totp_last_step = ? WHERE id = ?').run(step, req.user.id);
res.json({ recovery_codes: resetRecoveryCodes(req.user.id) });
});
// Second login step: exchange an mfa_pending token + a code (TOTP or recovery) for a
// full session. Per-route 10/min rate-limit (server.js) + per-user lockout (#87 model).
router.post('/totp/verify', (req, res) => {
const { mfa_token, code } = req.body;
if (!mfa_token || !code) return res.status(400).json({ error: 'mfa_token and code required' });
let decoded;
// verifyMfaPendingToken is the ONLY accessor that accepts the pre-TOTP audience; a full
// session token presented here is rejected by it (audience mismatch).
try { decoded = verifyMfaPendingToken(mfa_token); } catch { return res.status(401).json({ error: 'mfa session expired' }); }
if (!decoded.mfa_pending || !decoded.id) return res.status(401).json({ error: 'invalid mfa token' });
if (totpLockout.isLocked(decoded.id)) return res.status(429).json({ error: 'Too many invalid codes. Try again later.' });
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id);
if (!user || !user.totp_enabled) return res.status(401).json({ error: 'invalid mfa token' });
// TOTP first (with intra-window replay block via totp_last_step), then a recovery code.
const step = totp.verifyCode(code, totp.decryptSecret(user.totp_secret_enc), user.totp_last_step);
let viaRecovery = false;
if (step) {
db.prepare('UPDATE users SET totp_last_step = ? WHERE id = ?').run(step, user.id);
} else if (consumeRecoveryCode(user.id, code)) {
viaRecovery = true;
} else {
totpLockout.recordFailure(decoded.id);
logFailedLogin(user.email, getClientIp(req), 'Bad TOTP/recovery code');
return res.status(401).json({ error: 'Invalid code' });
}
totpLockout.reset(decoded.id);
issueSession(req, res, user, {
via_recovery: viaRecovery,
recovery_codes_remaining: recoveryCodesRemaining(user.id),
});
});
// ==================== Google OAuth ====================
/*
* REMOVED 2026-08-10: POST /api/auth/google and POST /api/auth/microsoft.
*
* Both authenticated with an ACCESS token and neither checked who it was issued for. Google's path
* fell back to `tokeninfo?access_token=` and read the email out of the reply; Microsoft's handed the
* bearer token to Graph /me and trusted that. Graph — and tokeninfo — will describe the user behind
* a token minted for SOMEBODY ELSE'S application, so any site a user signed into that requested
* `email` or `User.Read` could replay their token here and be handed a session as them.
*
* Nothing is lost by deleting them: the login page called `google.accounts.oauth2` and
* `new msal.PublicClientApplication`, and neither SDK was ever loaded by any page in this app, so
* both buttons threw ReferenceError on click. The feature had never worked.
*
* Replaced by the OIDC routes at the bottom of this file, which verify an ID token's signature,
* issuer, audience and our own nonce, and which cover Google, Microsoft and any other provider
* through one code path. See lib/oidc.js.
*/
// ==================== User Management ====================
// Get current user + tenancy context.
// Phase 2.1: response shape extended with current_workspace, current_organization,
// roles, and the list of accessible workspaces. Legacy fields (user object at
// the top level) are preserved so existing frontend code continues to work.
router.get('/me', requireAuth, resolveTenancy, (req, res) => {
// Platform admins see every workspace in the system (via the LEFT JOIN they
// still get their own workspace_role for direct memberships; NULL elsewhere,
// matching accessContext's actingAs semantics). Regular users see every
// workspace they can reach via either path: direct workspace_members row, OR
// org_owner / org_admin on the parent organization. Mirrors the access
// logic in accessibleWorkspaceIds() (lib/tenancy.js); kept as a separate
// query rather than reusing it because /me needs full row shape, not just
// IDs. Role is read from the signed JWT (not user-supplied), so non-admins
// cannot reach the admin branch. No cap on the admin list yet - revisit at
// 50+ workspaces when dropdown UX without search starts to degrade.
//
// Each accessible_workspaces entry also carries `can_admin: bool` so the
// UI can render admin affordances (rename pencil etc.) only where the
// caller has permission. The server still enforces permission on the
// actual mutation routes regardless of this advisory flag.
// device_count: correlated subquery on workspaces.id. Equality fails on NULL
// so unclaimed pair-pool devices (workspace_id IS NULL) are correctly excluded.
// Microseconds per row at current scale (~37 rows worst case for platform_admin);
// not optimizing - revisit if the admin list grows past a few hundred workspaces.
// #13: platform staff (admin OR operator) SEE every workspace (visibility).
// can_admin below is computed separately from isPlatformRole (owner only), so
// operators see all workspaces but get can_admin:false on each.
const isPlatformStaffUser = isPlatformStaff(req.user.role);
const isPlatformAdmin = isPlatformRole(req.user.role);
const accessible = isPlatformStaffUser
? db.prepare(`
SELECT w.id, w.name, w.organization_id, o.name AS organization_name,
wm.role AS workspace_role, om.role AS org_role,
(SELECT COUNT(*) FROM devices WHERE workspace_id = w.id) AS device_count
FROM workspaces w
JOIN organizations o ON o.id = w.organization_id
LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ?
LEFT JOIN organization_members om ON om.organization_id = w.organization_id AND om.user_id = ?
ORDER BY o.name, w.name
`).all(req.user.id, req.user.id)
: db.prepare(`
SELECT w.id, w.name, w.organization_id, o.name AS organization_name,
wm.role AS workspace_role, om.role AS org_role,
(SELECT COUNT(*) FROM devices WHERE workspace_id = w.id) AS device_count
FROM workspaces w
JOIN organizations o ON o.id = w.organization_id
LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ?
LEFT JOIN organization_members om ON om.organization_id = w.organization_id AND om.user_id = ?
WHERE wm.user_id IS NOT NULL
OR (om.user_id IS NOT NULL AND om.role IN ('org_owner', 'org_admin'))
ORDER BY o.name, w.name
`).all(req.user.id, req.user.id);
// Compute can_admin per workspace. Mirrors canAdminWorkspace() in lib/permissions.js
// but uses already-joined org_role to avoid another N+1 query per workspace.
for (const w of accessible) {
w.can_admin = isPlatformAdmin
|| w.org_role === 'org_owner' || w.org_role === 'org_admin'
|| w.workspace_role === 'workspace_admin';
delete w.org_role; // internal-only; don't leak to client
}
const currentOrg = req.organizationId
? db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(req.organizationId)
: null;
res.json({
...req.user,
// Read straight from the row (the JWT predates this field) so the client's verify banner
// reflects live state after reload. Fail-open to verified if somehow absent.
email_verified: db.prepare('SELECT email_verified FROM users WHERE id = ?').get(req.user.id)?.email_verified ?? 1,
hide_billing: config.hideBilling, // #116: client hides the Subscription nav + guards #/billing
current_workspace_id: req.workspaceId,
current_workspace: req.workspace ? { id: req.workspace.id, name: req.workspace.name, organization_id: req.workspace.organization_id } : null,
current_organization: currentOrg,
current_workspace_role: req.workspaceRole,
current_org_role: req.orgRole,
is_platform_admin: req.isPlatformAdmin,
acting_as: req.actingAs,
accessible_workspaces: accessible,
});
});
// Switch the active workspace. Validates the user has access (direct
// workspace_member, org-level admin in the parent org, or platform_admin),
// then mints a fresh JWT with the new current_workspace_id.
router.post('/switch-workspace', requireAuth, (req, res) => {
const { workspace_id } = req.body || {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspace_id);
if (!ws) return res.status(404).json({ error: 'Workspace not found' });
// #13: platform staff (admin OR operator) can switch into any workspace.
const isPlatformStaffUser = isPlatformStaff(req.user.role);
const wsMember = db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?').get(ws.id, req.user.id);
const orgMember = db.prepare(`
SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?
`).get(ws.organization_id, req.user.id);
const canAct = isPlatformStaffUser
|| !!wsMember
|| (orgMember && (orgMember.role === 'org_owner' || orgMember.role === 'org_admin'));
if (!canAct) return res.status(403).json({ error: 'Access denied to that workspace' });
const token = generateToken(req.user, ws.id);
res.json({ token, current_workspace_id: ws.id });
});
// Update current user
router.put('/me', requireAuth, (req, res) => {
const { name, password, current_password, email_alerts } = req.body;
if (name) {
db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(name, req.user.id);
}
if (email_alerts !== undefined) {
db.prepare('UPDATE users SET email_alerts = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(email_alerts ? 1 : 0, req.user.id);
}
if (password) {
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const row = db.prepare('SELECT password_hash, auth_provider FROM users WHERE id = ?').get(req.user.id);
if (!row) return res.status(404).json({ error: 'User not found' });
if (row.auth_provider !== 'local') {
return res.status(400).json({ error: `Your account signs in via ${row.auth_provider}. Manage your password there.` });
}
if (row.password_hash) {
if (!current_password || !bcrypt.compareSync(current_password, row.password_hash)) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
}
const hash = bcrypt.hashSync(password, 10);
// #10: a successful password change clears must_change_password, releasing
// the first-login change-password gate.
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(hash, req.user.id);
}
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(req.user.id);
res.json(user);
});
// List users - platform admins see all, admins see team members only
router.get('/users', requireAuth, requireAdmin, (req, res) => {
if (PLATFORM_ROLES.includes(req.user.role)) {
// One aggregate query (no N+1): each user carries workspace_count, and for
// an exactly-one membership the single workspace id/name + org name (used by
// the admin Users page Workspace column). MAX() over a single grouped row
// yields that row's values; the CASE blanks them when count != 1 so we never
// surface a single workspace name for a multi-membership user.
const users = db.prepare(`
SELECT u.id, u.email, u.name, u.role, u.auth_provider, u.avatar_url, u.plan_id, u.created_at, u.last_login,
COUNT(wm.workspace_id) AS workspace_count,
CASE WHEN COUNT(wm.workspace_id) = 1 THEN MAX(w.id) END AS workspace_id,
CASE WHEN COUNT(wm.workspace_id) = 1 THEN MAX(w.name) END AS workspace_name,
CASE WHEN COUNT(wm.workspace_id) = 1 THEN MAX(o.name) END AS organization_name
FROM users u
LEFT JOIN workspace_members wm ON wm.user_id = u.id
LEFT JOIN workspaces w ON w.id = wm.workspace_id
LEFT JOIN organizations o ON o.id = w.organization_id
GROUP BY u.id
ORDER BY u.created_at ASC
`).all();
res.json(users);
} else {
// Admin sees themselves + users in their teams
const users = db.prepare(`
SELECT DISTINCT u.id, u.email, u.name, u.role, u.auth_provider, u.avatar_url, u.plan_id, u.created_at
FROM users u
LEFT JOIN team_members tm ON u.id = tm.user_id
WHERE u.id = ? OR tm.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
ORDER BY u.created_at ASC
`).all(req.user.id, req.user.id);
res.json(users);
}
});
// Delete user (superadmin only)
router.delete('/users/:id', requireAuth, requireSuperAdmin, (req, res) => {
if (req.params.id === req.user.id) return res.status(400).json({ error: 'Cannot delete yourself' });
const target = db.prepare('SELECT id, email FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
// #18: a bare DELETE FROM users fails the FK constraints (23 uncascaded refs).
// deleteUserCascade resolves every reference in one transaction: hard-deletes
// orgs the user solely owns, preserves (unlinks/reassigns) resources in orgs
// they don't own, and refuses if they own a shared org.
try {
deleteUserCascade(db, { targetId: target.id, actingAdminId: req.user.id });
} catch (e) {
if (e instanceof OrgHasOtherMembersError) return res.status(409).json({ error: e.message });
throw e;
}
logActivity(req.user.id, 'delete_user', `target: ${target.email}`, null, getClientIp(req));
res.json({ success: true });
});
// Update user platform role (platform admin only).
// #14: this manages users.role (the PLATFORM-level role) only - workspace and
// org roles are managed in the members views. Whitelist is the current model:
// 'user' and 'platform_admin' (the legacy 'admin'/'superadmin' strings are gone
// after normalization and are no longer accepted here).
const ASSIGNABLE_PLATFORM_ROLES = ['user', 'platform_operator', 'platform_admin'];
router.put('/users/:id/role', requireAuth, requireSuperAdmin, (req, res) => {
const { role } = req.body;
if (!ASSIGNABLE_PLATFORM_ROLES.includes(role)) return res.status(400).json({ error: 'Invalid role' });
// Self-demotion guard: a platform admin can't strip their own platform role
// (would lock themselves out of platform admin actions).
if (req.params.id === req.user.id && !isPlatformRole(role)) return res.status(400).json({ error: 'Cannot demote yourself' });
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);
res.json({ success: true });
});
// Admin password reset for another user.
// Superadmins: can reset any local user. Admins: can reset members of teams
// they own (and never a superadmin). Self-reset routes through PUT /me with
// current_password — this endpoint is the override path.
router.put('/users/:id/password', requireAuth, requireAdmin, (req, res) => {
const { password } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
if (req.params.id === req.user.id) {
return res.status(400).json({ error: 'Use Settings > Change Password for your own account' });
}
const target = db.prepare('SELECT id, email, role, auth_provider FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.auth_provider !== 'local') {
return res.status(400).json({ error: `User signs in via ${target.auth_provider} — password reset does not apply` });
}
if (!PLATFORM_ROLES.includes(req.user.role)) {
// Admin path: must own a team that includes the target, and target must
// be a regular user (cannot reset another admin's or a platform_admin's
// password — that would be a lateral-takeover vector).
if (target.role !== 'user') {
return res.status(403).json({ error: 'Admins can only reset passwords for regular users' });
}
const sharedOwnedTeam = db.prepare(`
SELECT 1 FROM team_members tm_admin
JOIN team_members tm_target ON tm_admin.team_id = tm_target.team_id
WHERE tm_admin.user_id = ? AND tm_admin.role = 'owner'
AND tm_target.user_id = ?
LIMIT 1
`).get(req.user.id, req.params.id);
if (!sharedOwnedTeam) {
return res.status(403).json({ error: 'You can only reset passwords for members of teams you own' });
}
}
const hash = bcrypt.hashSync(password, 10);
db.prepare("UPDATE users SET password_hash = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(hash, req.params.id);
// Explicit audit entry — the generic activity logger captures the route
// and target id, but a labeled detail string makes the audit log readable.
// Never include the password; just who reset whose password.
logActivity(req.user.id, 'password_reset_for_user', `target: ${target.email}`, null, getClientIp(req));
res.json({ success: true });
});
// Get auth config (public - tells frontend which providers are available)
router.get('/config', (req, res) => {
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
/*
* `providers` is the whole SSO surface now: slug + display name, nothing else. The browser no
* longer needs a client id, because it never talks to a provider itself — it follows a link to
* /api/auth/oidc/<slug>/start and the server builds the authorization request. That is what
* removed the need for a provider SDK on this page, and with it the CSP exception one would need.
*/
const providers = oidcProviders.publicList();
res.json({
providers,
// Kept so a cached older login page hides its buttons rather than drawing dead ones. The client
// ids are deliberately no longer echoed — nothing in the browser has any use for them.
googleEnabled: providers.some((p) => p.slug === 'google'),
microsoftEnabled: providers.some((p) => p.slug === 'microsoft'),
localEnabled: true,
needsSetup: userCount === 0,
registration_enabled: !config.disableRegistration || userCount === 0,
});
});
// Accept a workspace invite. Mounted here (under /api/auth) rather than in
// routes/workspaces.js because the invite id is the only thing the caller
// has - they don't necessarily know which workspace it targets yet, so
// /api/workspaces/:id/... wouldn't fit. requireAuth gates access; the
// invite's email is matched against the authenticated user's email
// case-insensitively, so a logged-in account can only accept invites
// addressed to its own email.
router.post('/accept-invite/:inviteId', requireAuth, (req, res) => {
const invite = db.prepare('SELECT * FROM workspace_invites WHERE id = ?').get(req.params.inviteId);
if (!invite) return res.status(404).json({ error: 'Invite not found' });
const now = Math.floor(Date.now() / 1000);
if (invite.expires_at <= now) {
db.prepare('DELETE FROM workspace_invites WHERE id = ?').run(invite.id);
return res.status(410).json({ error: 'Invite has expired' });
}
if (String(invite.email).toLowerCase() !== String(req.user.email).toLowerCase()) {
return res.status(403).json({ error: 'This invite is for a different email address' });
}
const ws = db.prepare('SELECT id, name, organization_id FROM workspaces WHERE id = ?').get(invite.workspace_id);
if (!ws) {
// Workspace was deleted between invite creation and accept. Clean up.
db.prepare('DELETE FROM workspace_invites WHERE id = ?').run(invite.id);
return res.status(410).json({ error: 'Workspace no longer exists' });
}
const org = db.prepare('SELECT name FROM organizations WHERE id = ?').get(ws.organization_id);
// Idempotent: if the user already has a workspace_members row, return
// success without changing the role (don't silently demote/upgrade), and
// still consume the invite. The invitee's intent ("I want access") is
// already satisfied either way.
const existing = db.prepare('SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?')
.get(ws.id, req.user.id);
const txn = db.transaction(() => {
if (!existing) {
db.prepare(`
INSERT INTO workspace_members (workspace_id, user_id, role, invited_by)
VALUES (?, ?, ?, ?)
`).run(ws.id, req.user.id, invite.role, invite.invited_by);
}
db.prepare('DELETE FROM workspace_invites WHERE id = ?').run(invite.id);
});
txn();
// Stamp workspaceId so activityLogger captures tenant attribution.
req.workspaceId = ws.id;
res.json({
workspace_id: ws.id,
workspace_name: ws.name,
organization_name: org?.name || null,
role: existing ? existing.role : invite.role,
already_member: !!existing,
});
});
// ==================== OpenID Connect (generic SSO) ====================
/*
* ONE flow for every provider — Google, Microsoft, Okta, Keycloak, Authentik, anything that speaks
* OIDC. Authorization Code + PKCE, run server-side, which is why there is no provider SDK on the
* login page and no third-party script origin in the CSP.
*
* It replaces two endpoints that could not tell WHO a token was minted for. Detail in lib/oidc.js;
* the short version is that identity now comes from an ID token whose signature, issuer, audience
* and OUR nonce are all checked, instead of from an access token handed to a userinfo endpoint.
*
* ⚠️ TOTP: an SSO login does not prompt for it, matching the existing documented behaviour at the
* password-login branch above ("The SSO routes and the API-token path never reach here"). The
* second factor is the identity provider's job in this flow. Changing that is a product decision,
* not something this refactor should do silently.
*/
// The transaction is held in a short-lived signed cookie rather than server memory so that a
// restart mid-login, or a second server process, does not strand the user on a dead state.
const OIDC_TX_COOKIE = 'st_oidc_tx';
// Holds a completed session for the seconds between the provider redirect and the page claiming it.
const SSO_CLAIM_COOKIE = 'st_sso_claim';
const OIDC_TX_TTL_S = 600;
/*
* The only shape of a user row that may leave the server.
*
* Two call sites each stripped `password_hash, totp_secret_enc, totp_last_step` and stopped there,
* so every login response also carried `password_reset_hash` and `email_verify_hash` — live
* credentials for taking the account over, handed to the browser. They are hashes of random tokens
* and only ever went to the account's own page, so this is hygiene rather than a takeover, but it
* means a logged-in XSS reads a working reset hash. Denylisted in ONE place so the next field
* nobody thinks about has somewhere obvious to go.
*/
const PRIVATE_USER_FIELDS = [
'password_hash', 'totp_secret_enc', 'totp_last_step',
'password_reset_hash', 'password_reset_expires',
'email_verify_hash', 'email_verify_expires',
];
function publicUser(row) {
if (!row) return row;
const out = { ...row };
for (const f of PRIVATE_USER_FIELDS) delete out[f];
return out;
}
function readCookie(req, name) {
const raw = req.headers.cookie;
if (!raw) return null;
for (const part of raw.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
if (part.slice(0, eq).trim() !== name) continue;
const value = part.slice(eq + 1).trim();
/*
* ⚠️ decodeURIComponent THROWS on a malformed escape — `Cookie: st_oidc_tx=%` is a URIError.
* Anyone can send that, and this function is called before the handler's try block, so the
* throw used to reach the async boundary and take the process down (see asyncRoute below).
* A cookie we cannot decode is a cookie we do not have.
*/
try { return decodeURIComponent(value); } catch { return null; }
}
return null;
}
/*
* Wrap an async handler so a rejection becomes a 500 instead of killing the server.
*
* Express 4 does not await handlers, so an async one that throws produces an unhandled rejection,
* and server.js turns that into process.exit(1) — one malformed request, one dead instance, on a
* restart loop. This has now bitten three separate times on these routes (a state comparison, a
* cookie decode, a provider whose secret would not decrypt), each time because something threw
* OUTSIDE the handler's own try block. Fixing the individual throws does not fix the shape, so
* every async route here goes through this instead.
*/
function asyncRoute(handler) {
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch((err) => {
console.error(`[auth] unhandled error in ${req.method} ${req.path}:`, err && err.message);
// Wrapped: if responding THROWS, the rejection has no handler and kills the process — the
// guard against process death causing process death.
try {
if (res.headersSent) return;
// These are browser redirects, not API calls; a JSON body would be shown as text.
if (req.path.startsWith('/oidc/')) return backToApp(res, { sso_error: 'server_error' });
res.status(500).json({ error: 'Something went wrong' });
} catch (e2) {
console.error('[auth] failed to report an error:', e2 && e2.message);
}
});
}
/*
* The origin the provider will redirect back to. APP_URL pins it, exactly as the signup and invite
* mails do, because the redirect_uri must match what is registered with the provider CHARACTER FOR
* CHARACTER — deriving it from the request Host would break the moment someone reaches the box by
* a second name, and would be attacker-controlled input in the bargain.
*/
function publicOrigin(req) {
const configured = (process.env.APP_URL || '').trim().replace(/\/+$/, '');
if (configured) return configured;
return `${req.protocol}://${req.get('host')}`;
}
const redirectUriFor = (req, slug) => `${publicOrigin(req)}/api/auth/oidc/${slug}/callback`;
// Send the browser back to the SPA. Errors travel as a code the login page can translate; the
// token travels in the FRAGMENT, which browsers do not send to servers and proxies do not log.
function backToApp(res, params) {
const qs = new URLSearchParams(params).toString();
res.redirect(`/app#/login?${qs}`);
}
// Which providers this instance offers. Public: it is what draws the login buttons.
router.get('/providers', (req, res) => {
res.json({ providers: oidcProviders.publicList() });
});
/*
* Does this email address belong to an organization with its own identity provider?
*
* ⚠️ Answers with a BOOLEAN and nothing else. It deliberately does not return the provider's slug
* or its display name, because both identify a CUSTOMER: a lookup that answered
* "yes — Acme Corp SSO" would turn a guessed domain into confirmation that Acme buys this product,
* and the slug would hand out a working entry point to their tenant's login.
*
* "example.com uses SSO" is the smallest answer that still lets the page draw the right button, and
* it is something anyone could infer by watching an employee log in. The domain-to-provider mapping
* stays server-side: POST /sso/start does the lookup again and redirects, so the browser never
* learns which provider it is being sent to until the provider itself says so.
*
* It also never reveals whether the ACCOUNT exists — only the domain is matched — so this cannot be
* walked to enumerate users.
*/
router.get('/sso/discover', (req, res) => {
res.json({ sso: !!oidcProviders.forEmail(req.query.email) });
});
/*
* Begin an organization SSO login for an email address.
*
* POST, so the address travels in a body rather than in a URL that lands in browser history, proxy
* logs and any Referer sent by the provider's page. The lookup happens here rather than in the
* browser for the reason above: the slug is never published.
*/
router.post('/sso/start', express.urlencoded({ extended: false }), (req, res) => {
const provider = oidcProviders.forEmail((req.body && req.body.email) || req.query.email);
// An unknown domain is answered exactly like a known one that is disabled: back to the login page
// with nothing learned.
if (!provider) return res.redirect('/app#/login?sso_error=unknown_provider');
res.redirect(`/api/auth/oidc/${encodeURIComponent(provider.slug)}/start`);
});
router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
const provider = oidcProviders.get(req.params.slug);
if (!provider) return backToApp(res, { sso_error: 'unknown_provider' });
try {
const doc = await oidc.discover(provider.issuer);
const pkce = oidc.createPkce();
const nonce = oidc.randomToken();
const state = oidc.randomToken();
const tx = jwt.sign(
{ typ: 'oidc-tx', slug: provider.slug, nonce, verifier: pkce.verifier, state },
config.jwtSecret,
// HS256 explicitly, and a `typ` the session verifier does not accept: two token kinds signed
// with one secret must never be interchangeable, even if today only `slug` happens to stop it.
{ expiresIn: OIDC_TX_TTL_S, algorithm: 'HS256' },
);
res.cookie(OIDC_TX_COOKIE, tx, {
httpOnly: true,
sameSite: 'lax', // the provider returns via a top-level GET, which Lax allows
secure: req.protocol === 'https',
maxAge: OIDC_TX_TTL_S * 1000,
path: '/api/auth',
});
const url = new URL(doc.authorization_endpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', provider.clientId);
url.searchParams.set('redirect_uri', redirectUriFor(req, provider.slug));
url.searchParams.set('scope', provider.scopes);
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
url.searchParams.set('code_challenge', pkce.challenge);
url.searchParams.set('code_challenge_method', pkce.method);
res.redirect(url.toString());
} catch (err) {
console.error(`[oidc] ${req.params.slug} start failed:`, err.message);
backToApp(res, { sso_error: 'provider_unavailable' });
}
}));
router.get('/oidc/:slug/callback', asyncRoute(async (req, res) => {
const provider = oidcProviders.get(req.params.slug);
if (!provider) return backToApp(res, { sso_error: 'unknown_provider' });
// The provider itself can refuse (consent declined, admin policy). That is not an error here.
if (req.query.error) {
console.warn(`[oidc] ${provider.slug} returned ${req.query.error}`);
return backToApp(res, { sso_error: 'provider_refused' });
}
const raw = readCookie(req, OIDC_TX_COOKIE);
res.clearCookie(OIDC_TX_COOKIE, { path: '/api/auth' });
if (!raw) return backToApp(res, { sso_error: 'expired' });
let tx;
try {
tx = jwt.verify(raw, config.jwtSecret, { algorithms: ['HS256'] });
if (tx.typ !== 'oidc-tx') throw new Error('not a login transaction');
} catch {
return backToApp(res, { sso_error: 'expired' });
}
// CSRF: the state we minted, in the cookie only we could set, must match the one coming back.
// Compared in constant time so a wrong state cannot be discovered a character at a time.
const got = String(req.query.state || '');
const want = String(tx.state || '');
/*
* Compared as BYTES, not characters.
*
* `got.length` is UTF-16 code units; Buffer.from() produces UTF-8 bytes. A state of 43 characters
* containing one multi-byte character is 43 chars but 44 bytes, so the guard passed and
* timingSafeEqual threw ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH — inside an async handler, which
* Express 4 does not catch, which server.js turns into process.exit. One crafted request per
* restart was enough to take an instance down.
*/
const gotBuf = Buffer.from(got, 'utf8');
const wantBuf = Buffer.from(want, 'utf8');
if (gotBuf.length !== wantBuf.length || !crypto.timingSafeEqual(gotBuf, wantBuf)) {
return backToApp(res, { sso_error: 'bad_state' });
}
if (tx.slug !== provider.slug) return backToApp(res, { sso_error: 'bad_state' });
if (!req.query.code) return backToApp(res, { sso_error: 'no_code' });
let claims;
try {
const tokens = await oidc.exchangeCode({
issuer: provider.issuer,
clientId: provider.clientId,
clientSecret: provider.clientSecret,
code: String(req.query.code),
redirectUri: redirectUriFor(req, provider.slug),
verifier: tx.verifier,
});
claims = await oidc.verifyIdToken(tokens.id_token, {
issuer: provider.issuer,
clientId: provider.clientId,
nonce: tx.nonce,
});
} catch (err) {
console.error(`[oidc] ${provider.slug} verification failed:`, err.message);
return backToApp(res, { sso_error: 'verification_failed' });
}
const email = String(claims.email || '').toLowerCase().trim();
if (!email) return backToApp(res, { sso_error: 'no_email' });
/*
* ⚠️ AN ORGANIZATION'S PROVIDER MAY ONLY SPEAK FOR ITS OWN DOMAINS.
*
* Without this, per-org SSO is an account-takeover primitive, demonstrated end to end twice in
* review: any org owner can point us at an identity provider they fully control, and such a
* provider can assert ANY email with email_verified:true — including a platform_admin's. Every
* check passes honestly, because the attacker IS the issuer.
*
* Instance-wide providers are exempt: the OPERATOR chose them, which is the trust they have
* always had. An org provider is chosen by a customer, so it is confined to the domains that
* customer registered — and a domain cannot be registered while another organization holds it.
*
* The domains are the VERIFIED ones — proved by a DNS record published in the domain itself — so
* this is confinement to what the tenant demonstrably controls, not to what they typed.
*/
if (!emailAllowedForProvider(provider, email)) {
console.warn(`[oidc] ${provider.slug} asserted ${email}, outside its verified domains [${provider.emailDomains}]`);
return backToApp(res, { sso_error: 'domain_not_allowed' });
}
/*
* An unverified email is refused. The whole account model keys on email — linking, invites,
* password reset — so accepting an address the provider itself will not vouch for would let
* anyone who can type an address into a sloppy IdP arrive as its owner. Providers that omit the
* claim entirely are treated as "not asserted", which is the same answer.
*/
// `=== false` accepted an OMITTED claim, which is the opposite of what the comment above says and
// what Azure AD v2 actually sends (it omits it). Absent means not asserted, which is not verified.
if (claims.email_verified !== true) return backToApp(res, { sso_error: 'email_unverified' });
try {
const result = upsertFederatedUser({ claims, email, provider, req });
if (result.error) return backToApp(res, { sso_error: result.error });
const { user, isNew } = result;
/*
* A provider that belongs to an ORGANIZATION vouches for its own people, so anyone who signs in
* through it becomes a member of that organization — otherwise a customer would configure SSO,
* their staff would authenticate successfully, and each would land in a fresh empty org of their
* own, which is the opposite of what they asked for.
*
* Membership is added, never changed: an existing member keeps whatever role they already have,
* so an org_owner cannot be demoted by logging in, and a plain member cannot be promoted by one.
* Instance-wide providers do none of this — they say nothing about which tenant anyone is in.
*/
if (provider.organizationId) {
const already = db.prepare(
'SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ?'
).get(provider.organizationId, user.id);
if (!already) {
db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_member')")
.run(provider.organizationId, user.id);
// (userId, action, details, deviceId, ipAddress, workspaceId) — the org id is NOT the 4th
// arg; it was landing in device_id, which has no FK to catch it.
logActivity(user.id, 'org_sso_joined', `via ${provider.name} org=${provider.organizationId}`, null, getClientIp(req));
}
}
logSuccessfulLogin(user.id, user.email, getClientIp(req));
const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup });
const token = generateToken(user, workspaceId);
if (isNew) sendSignupEmails(user, req);
/*
* ⚠️ The session token is NOT put in the redirect URL.
*
* An earlier version returned it in the fragment. That is a login-CSRF hole: anyone could send
* a victim `/app#/login?sso_token=<their own token>` and the page would install it, silently
* signing that person into the ATTACKER'S account — after which their uploads, playlists and
* settings all land somewhere the attacker can read.
*
* Instead the token goes into a one-shot httpOnly cookie that only this origin can set, and the
* page exchanges it at /sso/claim. A link cannot forge that cookie, so a token can only be
* claimed by the browser that actually completed the login.
*/
/*
* The cookie carries a CLAIM token, not the session token itself. Two reasons, both learned:
* every token here is signed with the same secret, so a token minted for another purpose (a
* pre-TOTP `mfa_pending` one, say) was accepted by /sso/claim and returned the full user row;
* and the session token lives for days, so a copy of it sitting in a Set-Cookie header is worth
* stealing long after the login. This wrapper is good for 120 seconds and for nothing else.
*/
const claimToken = jwt.sign(
{ typ: 'sso-claim', tok: token, wsp: workspaceId || null },
config.jwtSecret,
{ algorithm: 'HS256', expiresIn: 120 },
);
res.cookie(SSO_CLAIM_COOKIE, claimToken, {
httpOnly: true,
sameSite: 'lax',
secure: req.protocol === 'https',
maxAge: 120 * 1000,
path: '/api/auth',
});
backToApp(res, { sso: '1' });
} catch (err) {
console.error(`[oidc] ${provider.slug} sign-in failed:`, err.message);
backToApp(res, { sso_error: 'server_error' });
}
}));
/*
* Exchange the one-shot cookie for the session token.
*
* POST so it cannot be triggered by a link or an <img>, and the cookie is cleared on the way out.
*
* ⚠️ Clearing a cookie asks the BROWSER to forget it; it does not invalidate anything. What bounds
* a leaked copy is the claim token's own 120-second expiry, which is why the session token is
* wrapped rather than handed over directly. Do not restore the comment that used to claim this was
* "already spent" — it was not, and a review demonstrated the same cookie claiming twice.
*/
router.post('/sso/claim', (req, res) => {
const token = readCookie(req, SSO_CLAIM_COOKIE);
res.clearCookie(SSO_CLAIM_COOKIE, { path: '/api/auth' });
if (!token) return res.status(401).json({ error: 'No sign-in to complete' });
let claims;
try {
// Pinned algorithm and an explicit `typ`: two token kinds signed with one secret must never be
// interchangeable, and this endpoint accepted anything the secret had touched.
claims = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
} catch {
return res.status(401).json({ error: 'That sign-in has expired' });
}
if (claims.typ !== 'sso-claim' || !claims.tok) {
return res.status(401).json({ error: 'That sign-in has expired' });
}
let session;
try {
session = jwt.verify(claims.tok, config.jwtSecret, { algorithms: ['HS256'] });
} catch {
return res.status(401).json({ error: 'That sign-in has expired' });
}
/*
* The wrapped token must be an ordinary SESSION token. Forging the wrapper needs the signing
* secret, so this is not exploitable — but a `mfa_pending` token nested inside a valid wrapper
* was accepted, which is the same interchangeability the outer typ check was added to close.
* A session token carries no `aud` and no `mfa_pending`; anything else is a different kind.
*/
if (session.mfa_pending || session.aud || !session.id) {
return res.status(401).json({ error: 'That sign-in has expired' });
}
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(session.id);
if (!user) return res.status(401).json({ error: 'That sign-in has expired' });
const safeUser = publicUser(user);
res.json({ token: claims.tok, user: safeUser, current_workspace_id: claims.wsp || null });
});
/*
* Find or create the account behind a verified set of claims.
*
* The linking rule is the one the Google path already used, kept deliberately: an existing account
* WITH a password is never taken over by an SSO login — the owner proves control by logging in
* locally and linking from Settings. An account with no password (already federated) is re-pointed
* at whichever provider just authenticated it.
*/
function upsertFederatedUser({ claims, email, provider, req }) {
const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!existing) {
if (!canRegister()) return { error: 'registration_disabled' };
const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const isFirst = userCount === 0;
const role = isFirst ? 'platform_admin' : 'user';
const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000);
db.prepare(`
INSERT INTO users (id, email, name, auth_provider, provider_id, avatar_url, role, plan_id, trial_started, trial_plan, email_verified)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`).run(id, email, claims.name || '', provider.slug, String(claims.sub), claims.picture || '',
role, plan, trialStarted, trialStarted ? 'pro' : null);
return { user: db.prepare('SELECT * FROM users WHERE id = ?').get(id), isNew: true };
}
if (existing.auth_provider !== provider.slug) {
if (existing.password_hash) return { error: 'account_exists_local' };
/*
* `password_hash IS NULL` was the wrong test for "safe to relink". Every SSO-created account has
* a null password, so it meant "any federated account may be adopted by whichever provider spoke
* last" — fine when the operator chose them all, an account takeover once a customer can add
* one. An ORG provider therefore never adopts an account another provider established; the user
* links it deliberately instead.
*/
if (provider.organizationId) {
/*
* `existing.auth_provider && … !== 'local'` failed OPEN on an empty string, and compared
* SLUGS, which got the two interesting cases backwards:
*
* - a customer replacing their identity provider (or an admin who deleted one and made
* another) got a new random slug, so their own org could no longer sign its own people in
* — every SSO account in the tenant bricked, with no recovery route;
* - meanwhile an account owned by a DELETED provider looked adoptable to everyone.
*
* Ownership is therefore asked of the ORGANIZATION behind the slug, and the only states an
* org provider may take over are its own org's, and `local` with no password — an invited
* user who has not set one yet, which is a real and wanted case.
*
* An account established by a provider that no longer exists is deliberately NOT adoptable:
* see the squatting note in the callback. It is recovered by proving control of the email
* through password reset, not by another identity provider asserting it.
*/
const owner = oidcProviders.ownerOf(existing.auth_provider);
const sameOrg = !!(owner && owner.organizationId && owner.organizationId === provider.organizationId);
const neverFederated = existing.auth_provider === 'local';
if (!sameOrg && !neverFederated) return { error: 'account_exists_other_provider' };
}
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?')
.run(provider.slug, String(claims.sub), claims.picture || existing.avatar_url, existing.id);
return { user: db.prepare('SELECT * FROM users WHERE id = ?').get(existing.id), isNew: false };
}
/*
* Same provider, but a DIFFERENT subject. `sub` is the provider's stable id and the email is not:
* addresses get reassigned, especially inside companies. Refusing here is what stops a recycled
* address inheriting the previous holder's account.
*/
if (existing.provider_id && String(existing.provider_id) !== String(claims.sub)) {
return { error: 'subject_mismatch' };
}
if (!existing.provider_id) {
db.prepare('UPDATE users SET provider_id = ? WHERE id = ?').run(String(claims.sub), existing.id);
}
return { user: db.prepare('SELECT * FROM users WHERE id = ?').get(existing.id), isNew: false };
}
module.exports = router;
// Exported for tests: these two carry the security decisions of the SSO flow, and testing them
// through a live identity provider only is how they shipped unverified the first time.
module.exports.emailAllowedForProvider = emailAllowedForProvider;
module.exports.upsertFederatedUser = upsertFederatedUser;
module.exports.isOrphanedFederated = isOrphanedFederated;