mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
The OIDC callback required `claims.email_verified === true`. Entra ID v2 does not send that claim at all, so every Microsoft login authenticated correctly against the tenant and was then refused with `email_unverified` on the way back. Nothing caught it: the SSO tests assert how the Microsoft issuer string is built but never put a Microsoft-shaped token through the policy. The strict check was itself a fix -- `=== false` had been accepting an omitted claim -- and it is right for a provider a CUSTOMER configured, since such a provider is chosen by the party it vouches for and its bare assertion is worth nothing. What was wrong is treating that as a question about the token when it is a question about who we trusted. `users.email_verified` is our own state; the claim is the IdP's. An instance-wide provider was chosen by the operator -- the same trust that already exempts it from domain confinement -- and Microsoft is additionally pinned to one tenant GUID, so only that directory can issue a token whose `iss` matches. So the policy now depends on the provider, in emailIsVerified(), next to the flag it reads so the two cannot drift: - explicit true -> believed, from anyone - claim absent, operator -> believed (Microsoft; opt-in for other IdPs) - claim absent, org -> refused - explicit false -> refused, always Org providers pin the flag false in rowToProvider and never read it from the row, so the takeover path the strict check existed to close stays closed. Google is left strict: it does send the claim. Also documents MICROSOFT_CLIENT_SECRET (supported in code, missing from the table), that the redirect URI must be registered under Web rather than SPA, and the email optional claim -- the other two ways an Entra setup fails. All three mutations of this policy fail the new tests: reinstating the strict check (3 failures), letting an org provider assume (1), and accepting an explicit false (2).
450 lines
21 KiB
JavaScript
450 lines
21 KiB
JavaScript
'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,
|
|
};
|