'use strict'; /* * Which identity providers this instance offers. * * Providers are resolved through ONE function on purpose. Instance-wide providers come from the * environment today; per-organization SSO will come from the database later, and when it does it * plugs in here rather than growing a second login path. The rest of the app only ever asks * "give me the provider called X" and never learns where the answer came from. * * ── Configuration ──────────────────────────────────────────────────────────────────────────── * * OIDC_PROVIDERS=okta,authentik comma-separated slugs to enable * OIDC_OKTA_ISSUER=https://example.okta.com * OIDC_OKTA_CLIENT_ID=... * OIDC_OKTA_CLIENT_SECRET=... optional — PKCE means a public client works * OIDC_OKTA_NAME=Okta optional button label * OIDC_OKTA_SCOPES=openid email profile optional * * Google and Microsoft are ordinary OIDC providers and are registered automatically from the * variables the README has always documented (GOOGLE_CLIENT_ID, MICROSOFT_CLIENT_ID + * MICROSOFT_TENANT_ID), so an existing deployment keeps working without editing anything. They get * no special code path — the only difference is that their issuer is filled in for you. */ const GOOGLE_ISSUER = 'https://accounts.google.com'; const DEFAULT_SCOPES = 'openid email profile'; /** A slug has to be safe in a URL path and in an env var name. */ const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,30}$/; /* * `local` is what users.auth_provider says for a password account, so a provider by that name would * make every federated login look like a password login to the linking rules — and would put a NULL * password_hash on rows that POST /login then feeds straight to bcrypt.compareSync. Reserved rather * than merely discouraged. */ const RESERVED_SLUGS = new Set(['local', 'recovery']); function envKey(slug, suffix) { return `OIDC_${slug.toUpperCase().replace(/-/g, '_')}_${suffix}`; } function fromEnv(env, slug) { const issuer = (env[envKey(slug, 'ISSUER')] || '').trim().replace(/\/+$/, ''); const clientId = (env[envKey(slug, 'CLIENT_ID')] || '').trim(); if (!issuer || !clientId) return null; return { slug, name: (env[envKey(slug, 'NAME')] || '').trim() || slug.replace(/[-_]/g, ' '), issuer, clientId, clientSecret: (env[envKey(slug, 'CLIENT_SECRET')] || '').trim() || null, scopes: (env[envKey(slug, 'SCOPES')] || '').trim() || DEFAULT_SCOPES, // Escape hatch for an IdP that verifies addresses but does not say so in the token. Off unless // the operator sets it, and it only ever covers an ABSENT claim — see emailIsVerified(). assumeEmailVerified: /^(1|true|yes)$/i.test((env[envKey(slug, 'ASSUME_EMAIL_VERIFIED')] || '').trim()), source: 'env', }; } /** * May this provider's assertion of `email` be treated as verified? * * The login callback used to demand `claims.email_verified === true` outright. That is correct for a * provider a CUSTOMER configured — such a provider is chosen by the party it vouches for, so * anything it merely asserts is worth nothing — but it made Microsoft sign-in impossible, because * **Entra ID v2 does not emit the claim at all**. Every Entra login authenticated successfully and * was then refused with `email_unverified`. * * The distinction that resolves it: `users.email_verified` is OUR state and this is the IdP's claim. * Whether an address is trustworthy is a decision about WHO WE TRUSTED, not a field we can insist a * provider populate. An instance-wide provider was chosen by the operator — the same trust that * already exempts it from domain confinement — and the Microsoft entry is additionally pinned to one * tenant GUID, so only that directory can issue tokens for it. * * Two limits keep this from becoming the hole the strict check was closing: * - an EXPLICIT `email_verified: false` is always refused. Assuming only ever covers an omitted * claim, never a provider actively saying the address is unverified; * - org-configured providers can never set it (rowToProvider pins it false, and nothing reads it * from the database), so the account-takeover path stays shut. */ function emailIsVerified(claims, provider) { const asserted = (claims || {}).email_verified; if (asserted === true) return true; if (asserted === undefined || asserted === null) return !!(provider && provider.assumeEmailVerified); return false; // explicit false, or anything else the provider chose to send } /** * Every provider this instance offers, in a stable order. * * ⚠️ Never returns clientSecret to a caller that only wants to draw buttons — see publicList(). */ function list(env = process.env) { const out = []; const seen = new Set(); // Back-compat: the two providers the README documented before generic OIDC existed. const googleId = (env.GOOGLE_CLIENT_ID || '').trim(); if (googleId) { out.push({ slug: 'google', name: 'Google', issuer: GOOGLE_ISSUER, clientId: googleId, clientSecret: (env.GOOGLE_CLIENT_SECRET || '').trim() || null, scopes: DEFAULT_SCOPES, // Google DOES send email_verified. Nothing to assume, so it stays strict. assumeEmailVerified: false, source: 'env', }); seen.add('google'); } const msId = (env.MICROSOFT_CLIENT_ID || '').trim(); if (msId) { /* * ⚠️ A TENANT GUID IS REQUIRED. `common` and `organizations` are refused, for two reasons that * point the same way. * * It does not work: Microsoft's multi-tenant metadata advertises * `https://login.microsoftonline.com/{tenantid}/v2.0` — a literal template — so the issuer can * never equal the configured URL and every login fails at /start regardless. * * And the obvious patch is dangerous: loosening the `iss` comparison to accept the template * means accepting tokens from EVERY Azure tenant, which is nOAuth — an admin of any tenant can * set an arbitrary, unverified `email` on one of their own users and be issued a session as that * address here. Doing multi-tenant Microsoft safely needs per-tenant pinning (validate `tid` * against an allowlist and key the account on `oid`+`tid`, not on email), which is a feature, * not a relaxed regex. * * So: refuse loudly at boot rather than ship a login that either never works or works too well. */ const rawTenant = (env.MICROSOFT_TENANT_ID || '').trim().toLowerCase(); if (!rawTenant || ['common', 'organizations', 'consumers'].includes(rawTenant)) { if (!list._warned) { console.warn('[sso] MICROSOFT_CLIENT_ID is set but MICROSOFT_TENANT_ID is missing or multi-tenant ' + `(${rawTenant || 'unset'}). Microsoft sign-in is DISABLED: set your tenant GUID. See README.`); list._warned = true; } seen.add('microsoft'); } else { out.push({ slug: 'microsoft', name: 'Microsoft', // A tenant GUID narrows the issuer to that tenant, so a token from any other tenant fails // the `iss` check instead of being quietly accepted. issuer: `https://login.microsoftonline.com/${rawTenant}/v2.0`, clientId: msId, clientSecret: (env.MICROSOFT_CLIENT_SECRET || '').trim() || null, scopes: DEFAULT_SCOPES, /* * Entra ID v2 never sends email_verified, so demanding it refused every Microsoft login. * Safe here specifically because this entry is operator-chosen AND pinned to one tenant * GUID above: only that directory can issue a token whose `iss` matches. An explicit * email_verified:false is still refused — see emailIsVerified(). */ assumeEmailVerified: true, source: 'env', }); seen.add('microsoft'); } } for (const raw of String(env.OIDC_PROVIDERS || '').split(',')) { const slug = raw.trim().toLowerCase(); if (!slug || seen.has(slug)) continue; if (!SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug)) continue; // ignore rather than crash a boot over a typo const p = fromEnv(env, slug); if (p) { out.push(p); seen.add(slug); } } return out; } /** One provider by slug, or null. This is the seam per-org SSO will extend. */ function get(slug, env = process.env) { if (!slug || !SLUG_RE.test(String(slug))) return null; const fromEnvList = list(env).find((p) => p.slug === slug); if (fromEnvList) return fromEnvList; // Instance providers win a name clash, which cannot happen in practice (org slugs are random) // but decides it deterministically if it ever did. return getOrgProvider(slug); } /** * What the login page is allowed to know: enough to draw a button and nothing else. * No client ids, because the browser never talks to the provider directly any more — the redirect * is built server-side, so there is nothing for the page to do with one. */ function publicList(env = process.env) { return list(env).map((p) => ({ slug: p.slug, name: p.name })); } /* ──────────────────────────────────────────────────────────────────────────────────────────── * Per-organization providers. * * Loaded lazily so this module stays usable (and testable) without a database — the env-only paths * above never touch it. An org provider is an ordinary provider once loaded: the login flow cannot * tell the difference, which is the whole point of resolving everything through get(). */ let _db = null; function db() { if (_db === null) { try { _db = require('../db/database').db; } catch { _db = false; } } return _db || null; } function rowToProvider(row, secretbox) { return { slug: row.slug, name: row.name, issuer: String(row.issuer).replace(/\/+$/, ''), clientId: row.client_id, /* * Fail CLOSED. secretbox.decrypt returns null when the key has rotated, which silently turned a * confidential client into a public one — the login then fails at the provider with an error * nobody can act on, while the admin screen still says "a secret is set". */ clientSecret: row.client_secret_enc ? (secretbox.decrypt(row.client_secret_enc) ?? (() => { throw new Error('client secret could not be decrypted — re-enter it'); })()) : null, scopes: row.scopes || DEFAULT_SCOPES, /* * ⚠️ NEVER settable for an organization's provider, and deliberately not read from the row. * * A customer chooses this provider, so it speaks for the party it vouches for. Letting an org * assume verification would hand back exactly the takeover primitive the strict check exists to * stop. Domain confinement narrows WHICH addresses it may assert; this keeps the assertion * itself honest. */ assumeEmailVerified: false, source: 'org', organizationId: row.organization_id, /* * ⚠️ VERIFIED domains only — never org_sso_providers.email_domains. * * That column is what an admin typed. This is what they PROVED, by publishing a record in the * domain's own DNS, and it is the only thing the login callback may confine an assertion to. * Reading the typed column here would reduce the whole verification feature to a decoration: * a tenant could type any company's domain and immediately assert addresses in it. */ emailDomains: verifiedDomainsFor(row.id).join(','), }; } /** The domains a provider has actually proved it controls. */ function verifiedDomainsFor(providerId) { const conn = db(); if (!conn) return []; try { return conn.prepare('SELECT domain FROM org_sso_domains WHERE provider_id = ? AND verified_at IS NOT NULL') .all(providerId).map((r) => r.domain); } catch (e) { if (/no such table/i.test(e.message)) return []; throw e; } } /** One org provider by its (globally unique) slug, or null. */ function getOrgProvider(slug) { const conn = db(); if (!conn || !slug || !SLUG_RE.test(String(slug))) return null; try { const row = conn.prepare('SELECT * FROM org_sso_providers WHERE slug = ? AND enabled = 1').get(String(slug)); if (!row) return null; return rowToProvider(row, require('./secretbox')); } catch (e) { /* * Only "the table is not there yet" is a null. This catch used to swallow EVERYTHING, which * turned a secret that could not be decrypted back into a silent success — the exact failure the * fail-closed check above exists to prevent. Anything else propagates so it is logged and the * login fails loudly. */ if (/no such table/i.test(e.message)) return null; throw e; } } /** * Who owns a provider slug — without decrypting anything, and regardless of whether it is enabled. * * The linking rules need to know which ORGANIZATION established an account, not how to talk to its * provider, and asking get() for that has two problems: it fails closed on an undecryptable secret * (right for a login, wrong for an ownership question) and it hides disabled rows, which still own * the accounts they created. * * null means "nothing here owns that slug" — either it never existed or the provider has since been * deleted, and those are deliberately the same answer. */ function ownerOf(slug) { if (!slug || !SLUG_RE.test(String(slug))) return null; if (list().some((p) => p.slug === slug)) return { source: 'env', organizationId: null }; const conn = db(); if (!conn) return null; try { const row = conn.prepare('SELECT organization_id FROM org_sso_providers WHERE slug = ?').get(String(slug)); return row ? { source: 'org', organizationId: row.organization_id } : null; } catch (e) { if (/no such table/i.test(e.message)) return null; throw e; } } /** * Which provider, if any, owns an email address. * * Domain routing is what makes per-org SSO usable: a customer's staff type their work address and * are sent to their own identity provider rather than being asked for a password they do not have. * * ⚠️ Matched on the domain ONLY, never on whether the address exists. Answering "yes, that domain * uses SSO" tells an attacker nothing they could not learn from the customer's website; answering * "yes, that USER exists" would be an account-enumeration oracle on the login page. */ function forEmail(email) { const conn = db(); if (!conn) return null; const at = String(email || '').lastIndexOf('@'); if (at === -1) return null; const domain = String(email).slice(at + 1).toLowerCase().trim(); if (!domain) return null; try { /* * Routing is driven by the VERIFIED domain table, not by the text an admin typed, and the JOIN * is what enforces it — an unverified claim cannot send anyone anywhere. * * No ORDER BY: `domain` is UNIQUE, so at most one row can match and there is no tie to break. * An earlier version ordered here and the comment claimed it decided a race; it decided * nothing, and saying so invited someone to rely on it. */ const row = conn.prepare(` SELECT p.* FROM org_sso_domains d JOIN org_sso_providers p ON p.id = d.provider_id WHERE d.domain = ? AND d.verified_at IS NOT NULL AND p.enabled = 1 `).get(domain); if (row) return rowToProvider(row, require('./secretbox')); } catch (e) { // Only a missing table is a null — anything else (a secret that will not decrypt, a schema // drift) must surface rather than silently answering "this domain has no SSO", which is how a // fail-closed guarantee turns back into a fail-open one. if (!/no such table/i.test(e.message)) throw e; } return null; } /** * Is this address inside an organization that REQUIRES its identity provider? * * Only a VERIFIED domain can compel anyone: an org must not be able to switch off password login * for a domain it merely typed, which would be a denial-of-service against a company it has nothing * to do with. Enabled providers only, for the same reason a disabled provider routes nobody. */ function ssoOnlyForEmail(email) { const conn = db(); if (!conn) return null; const at = String(email || '').lastIndexOf('@'); if (at === -1) return null; // A trailing root dot is the same domain; `acme.test.` slipped the match and let someone // register at an SSO-only domain (a distinct string, so no squat — but a hole in the gate). const domain = String(email).slice(at + 1).toLowerCase().trim().replace(/\.+$/, ''); if (!domain) return null; try { return conn.prepare(` SELECT o.id AS organization_id, o.name AS organization_name, p.slug FROM org_sso_domains d JOIN org_sso_providers p ON p.id = d.provider_id JOIN organizations o ON o.id = d.organization_id WHERE d.domain = ? AND d.verified_at IS NOT NULL AND p.enabled = 1 AND o.sso_only = 1 `).get(domain) || null; } catch (e) { /* * ⚠️ FAIL CLOSED. This used to swallow `no such column` and return null — and null means "not * SSO-only", i.e. password login proceeds. It is the single control stopping a password from * bypassing a customer's identity provider, so a schema problem must never be the thing that * quietly switches it off. The sibling forEmail() carries the same warning for the same reason. * * `no such table` on the DOMAINS table is different and genuinely means "this instance has no * per-org SSO at all", so it stays a null. */ /* * "The feature is not installed" and "the schema drifted" are different answers. * * A missing per-org SSO table, or no organizations table at all, means this instance has no * per-organization SSO — nothing is being bypassed, so null is correct and a single-tenant * install must keep working. A missing sso_only COLUMN on a table that does exist is drift, and * that is the case that must never quietly answer "not required". */ if (/no such table: (org_sso_domains|org_sso_providers|organizations|organization_members)/i.test(e.message)) return null; console.error('[sso] could not determine SSO-only status, refusing password login:', e.message); throw e; } } /** * Must THIS USER use single sign-on? * * ⚠️ Membership, not just the address. ssoOnlyForEmail() answers about a DOMAIN, and a review used * that gap to walk straight in: any account in the tenant whose address sits outside the verified * domains kept password login — a contractor, an MSP, the one address nobody remembered. Worse, it * could be manufactured on demand, because an org admin can create a local password account at any * address and bind it to their workspace. Enforcing on the domain alone protects the domain; it * does not protect the ORGANIZATION, which is what the setting claims to do. * * So both are asked: the address's domain (which catches people who are not members yet) and every * organization the user actually belongs to. */ function ssoOnlyForUser(user) { if (!user) return null; const byDomain = ssoOnlyForEmail(user.email); if (byDomain) return byDomain; const conn = db(); if (!conn) return null; try { /* * ⚠️ WORKSPACE membership, not just organization_members. * * Almost nobody is in `organization_members`: only three places write it (creating an org, * an org-SSO login, a platform admin creating an org) and nothing ever deletes a row. Every * INVITED user, every admin-created account and every workspace assignment lands in * `workspace_members` and nowhere else — so an earlier version of this check covered org * owners and people who had already used SSO, which is exactly the set the domain check * already caught. A review invited an outside address into an SSO-only tenant and kept * password login, then used it to invite more. */ return conn.prepare(` SELECT o.id AS organization_id, o.name AS organization_name FROM organizations o WHERE o.sso_only = 1 AND (EXISTS (SELECT 1 FROM organization_members m WHERE m.organization_id = o.id AND m.user_id = ?) OR EXISTS (SELECT 1 FROM workspace_members wm JOIN workspaces w ON w.id = wm.workspace_id WHERE w.organization_id = o.id AND wm.user_id = ?)) LIMIT 1 `).get(user.id, user.id) || null; } catch (e) { if (/no such table: (organization_members|organizations|workspace_members|workspaces)/i.test(e.message)) return null; throw e; // drift on a table that exists — fail closed; the caller refuses the login } } module.exports = { list, get, publicList, getOrgProvider, ownerOf, forEmail, ssoOnlyForEmail, ssoOnlyForUser, emailIsVerified, DEFAULT_SCOPES, SLUG_RE, };