mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
A second attack round defeated three of the previous fixes and found a regression I
introduced. Each is reproduced-then-refused against a live server.
MEMBERSHIP: organization_members IS NOT HOW PEOPLE JOIN
Only three places write that table and nothing deletes from it — every INVITED user,
every admin-created account and every workspace assignment lands in workspace_members
and nowhere else. So keying enforcement on organization_members covered org owners and
people who had already used SSO: exactly the set the domain check already caught. A
reviewer invited an outside address into an SSO-only tenant, kept password login, read
the member list and content, and used it to invite more. Enforcement now asks whether
the user is in ANY workspace belonging to an SSO-only organization.
THE INTERLOCK ASKED THE WRONG QUESTION, TWICE
It fired only when a domain list became EMPTY, and it counted PROVIDERS. So:
- replacing acme.test with decoy.test removed every proof and sailed through — two
PUTs, and the customer's domain enforced nothing, with sso_only still reading true;
- with two providers you could disable the one owning your staff's domain, because
the other one, covering a domain nobody signs in at, still "enforced".
The question that matters is per-DOMAIN: after this change, is every domain that
enforces today still enforcing? Losing one needs the operator, whichever route gets you
there. The refusal now names the domain that would stop being covered.
REGRESSION I CAUSED: THE HAPPY PATH LOCKED THE OWNER OUT
Sign up with a personal address, create the org, verify the company domain, turn this
on — and enforcement covers you (you are a member) while your own address is outside
the verified domains, so passwords are refused AND your org's provider will not assert
for you either. No route removes a membership; reset succeeds but login still refuses.
Recovery meant a platform admin turning SSO off for the whole tenant. Enabling now
refuses when the actor's own address is not covered, naming it, and REPORTS everyone
else who will be stranded instead of letting them be discovered by support ticket.
ALSO
- POST /api/admin/users gated only on the target workspace, so you could mint
cfo@theircompany.test into your OWN workspace: login refused, but the row now has a
password_hash and an SSO login will not adopt one — permanently locking a real
person out of their own address. Now gated on the address's domain too.
- `ceo@acme.test.` (trailing root dot) slipped the registration gate.
- two rate-limited sub-paths were still unfolded because the generic org-id fold ate
`sso-only` as an organization id; the specific shapes are matched first now.
1609 tests, three clean runs. Verified live: invited outsider 403, swap refused,
sibling-disable refused, squat 400, self-lockout refused with the address named, and an
on-domain admin gets `stranded_members` back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
401 lines
18 KiB
JavaScript
401 lines
18 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,
|
|
source: 'env',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
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,
|
|
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,
|
|
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, DEFAULT_SCOPES, SLUG_RE,
|
|
};
|