screentinker/server/lib/oidc-providers.js
ScreenTinker e97228a502 SSO: per-organization providers, configured by the customer
Instance-wide providers belong to whoever runs the server. These belong to a
CUSTOMER: an organization points ScreenTinker at its own identity provider from
Settings → Single sign-on, with no environment variable and no restart.

The login flow is unchanged. An org provider is resolved through the same
oidc-providers.get(slug) the env ones go through, so there is one authorization
request builder, one token exchange and one verifier — not a second, less
tested path for tenants. That seam is why Phase 1 put provider lookup behind a
single function.

⚠️ An org provider is NEVER published. It is not in /api/auth/providers, because
listing a customer's IdP would both offer it to people it does not belong to and
leak the customer list from the login page. It surfaces only when someone types
an address at one of that organization's domains; otherwise the instance-wide
buttons are what you get.

The discovery endpoint answers with a BOOLEAN and nothing else — no slug, no
display name. Returning "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. POST /sso/start repeats the lookup server-side and
redirects, so the browser never learns which provider it is being sent to until
the provider says so, and the address travels in a body rather than in a URL
that lands in history, proxy logs and a Referer. Both endpoints rate limited to
10/min.

Other properties, each with a test:
  - slugs are RANDOM, not chosen, so two customers cannot collide on or guess
    each other's URL
  - a domain may be claimed by ONE organization; a second claim is refused, so a
    tenant cannot capture another company's logins
  - the issuer is verified by live discovery BEFORE the row is written, so a
    typo is caught at configuration rather than by a user staring at a failed
    login
  - client secrets are optional (PKCE), stored AES-256-GCM via lib/secretbox,
    never returned; an absent secret on update leaves the stored one alone,
    which is how a settings form that cannot show it avoids blanking it
  - cross-org access answers 404, not 403, so an outsider cannot confirm that an
    organization id exists
  - signing in through an org provider grants membership of that organization,
    but never changes an existing member's role

Verified live end to end: creation against a real issuer, domain normalisation
(`@Acme.CO.UK` → `acme.co.uk`), boolean-only discovery, a rejected domain
squat, a rejected bad issuer, and 404 for a foreign organization.

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

193 lines
7.7 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}$/;
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) {
// `common` lets any Microsoft account in, which is what multi-tenant means and is the documented
// default. A single-tenant deployment sets the tenant GUID and the issuer narrows with it, so a
// token from another tenant then fails the iss check rather than being silently accepted.
const tenant = (env.MICROSOFT_TENANT_ID || 'common').trim() || 'common';
out.push({
slug: 'microsoft',
name: 'Microsoft',
issuer: `https://login.microsoftonline.com/${tenant}/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)) 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,
clientSecret: row.client_secret_enc ? secretbox.decrypt(row.client_secret_enc) : null,
scopes: row.scopes || DEFAULT_SCOPES,
source: 'org',
organizationId: row.organization_id,
};
}
/** 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 { return null; } // table not migrated yet
}
/**
* 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 {
const rows = conn.prepare("SELECT * FROM org_sso_providers WHERE enabled = 1 AND email_domains != ''").all();
const secretbox = require('./secretbox');
for (const row of rows) {
const domains = String(row.email_domains || '').split(',').map((d) => d.trim().toLowerCase()).filter(Boolean);
if (domains.includes(domain)) return rowToProvider(row, secretbox);
}
} catch { /* table not migrated yet */ }
return null;
}
module.exports = { list, get, publicList, getOrgProvider, forEmail, DEFAULT_SCOPES, SLUG_RE };