diff --git a/README.md b/README.md index 5854ce7..470ed97 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,35 @@ refused rather than handing over an account to a recycled mailbox. ⚠️ **TOTP is not prompted on an SSO login.** Second-factor is the identity provider's job in this flow, matching the long-standing behaviour of the SSO and API-token paths. +#### Per-organization SSO (customer-configured) + +The providers above are **instance-wide** — they belong to whoever runs the server and appear as +buttons on the login page for everyone. + +An organization can also bring **its own** identity provider, configured by an org owner or admin in +**Settings → Single sign-on**. No environment variable or restart is involved. + +A per-org provider is **never listed publicly**. It appears only when someone types an email address +at one of that organization's domains, at which point the login page offers a generic +"Continue with single sign-on" button. The domain lookup answers with a boolean and nothing else — +no provider name, no slug — so a guessed domain cannot confirm who a customer is, and the mapping +back to a provider happens server-side on submit. Both endpoints are rate limited. + +Each provider gets a randomly generated redirect URI, shown in Settings, which the admin registers +with their identity provider: + +``` +https://yourdomain.com/api/auth/oidc//callback +``` + +The slug is generated rather than chosen so two customers cannot collide on — or guess — each +other's. A domain may be claimed by only one organization; a second claim is refused. + +Signing in through an organization's provider makes the user a member of that organization +(`org_member`). Existing members keep whatever role they already have — logging in never promotes or +demotes anyone. Client secrets are optional (PKCE), and are stored AES-256-GCM encrypted and never +returned by the API. + #### Email (Microsoft Graph or SMTP) Email powers offline alerts, welcome/signup mail, admin notifications, and password reset. Two interchangeable transports are supported, selected by `EMAIL_TRANSPORT`: diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 251ea07..565bd02 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -134,6 +134,32 @@ export default { 'auth.signin_google': 'Sign in with Google', 'auth.signin_with': 'Continue with {provider}', 'auth.sso_failed': 'Single sign-on failed. Please try again.', + 'auth.sso_org_hint': 'Your organization uses single sign-on.', + 'auth.signin_sso': 'Continue with single sign-on', + 'sso.title': 'Single sign-on', + 'sso.blurb': 'Let your team sign in with your own identity provider. Anyone using an email address at one of your domains will be sent there instead of being asked for a password.', + 'sso.add': 'Add a provider', + 'sso.none': 'No provider configured yet.', + 'sso.create': 'Add provider', + 'sso.saved': 'Saved', + 'sso.save_failed': 'Could not save that provider.', + 'sso.load_failed': 'Could not load single sign-on settings.', + 'sso.missing_fields': 'Name, issuer and client ID are required.', + 'sso.confirm_delete': 'Remove this provider? Anyone who signs in with it will lose that route.', + 'sso.delete': 'Remove', + 'sso.enable': 'Enable', + 'sso.disable': 'Disable', + 'sso.disabled': 'disabled', + 'sso.domains_label': 'Email domains', + 'sso.callback_label': 'Redirect URI — add this to your provider', + 'sso.f_name': 'Display name', + 'sso.f_issuer': 'Issuer URL', + 'sso.f_issuer_hint': 'The base URL whose /.well-known/openid-configuration describes your provider. We check it before saving.', + 'sso.f_client_id': 'Client ID', + 'sso.f_client_secret': 'Client secret (optional)', + 'sso.f_client_secret_hint': 'Leave blank for a public client — we use PKCE, so a secret is not required. Stored encrypted and never shown again.', + 'sso.f_domains': 'Email domains', + 'sso.f_domains_hint': 'Comma separated. Anyone with an address at these domains is sent to this provider.', 'auth.sso_err_expired': 'That sign-in took too long. Please try again.', 'auth.sso_err_bad_state': 'Sign-in could not be verified. Please start again.', 'auth.sso_err_no_code': 'The provider did not return an authorization code.', diff --git a/frontend/js/views/login.js b/frontend/js/views/login.js index b912287..008da5e 100644 --- a/frontend/js/views/login.js +++ b/frontend/js/views/login.js @@ -105,6 +105,11 @@ export async function render(container) {
+ +
${isSetup ? ` @@ -478,6 +483,64 @@ function setupHandlers(config, isSetup) { * log — and it is stripped from the address bar before anything else happens, so a shared screen * or a copied URL does not carry a live session. */ + /* + * Email-first SSO for organizations. + * + * Instance-wide providers are always on the page. An ORG provider is different — it belongs to + * one customer — so it is fetched by domain once the address looks complete, and only then. + * + * Debounced because this fires while someone types, and the endpoint is rate limited; asking on + * every keystroke would spend a user's whole budget before they finished their own address. + */ + let ssoLookupTimer = null; + let lastDomainAsked = ''; + const orgSlot = () => document.getElementById('orgSsoSlot'); + + async function lookupOrgSso(email) { + const at = String(email || '').lastIndexOf('@'); + const domain = at === -1 ? '' : email.slice(at + 1).trim().toLowerCase(); + const slot = orgSlot(); + if (!slot) return; + // Nothing to ask about until there is a domain with a dot in it. + if (!domain || !domain.includes('.')) { slot.style.display = 'none'; slot.innerHTML = ''; lastDomainAsked = ''; return; } + if (domain === lastDomainAsked) return; + lastDomainAsked = domain; + try { + const res = await fetch(`/api/auth/sso/discover?email=${encodeURIComponent(email)}`); + const data = await res.json(); + if (!data.sso) { slot.style.display = 'none'; slot.innerHTML = ''; return; } + /* + * A FORM, not a link, and a deliberately generic label. + * + * The lookup tells us only that this domain uses SSO — never which provider or whose it is, + * because that would identify a customer to anyone who guessed a domain. The server does the + * mapping again on submit, so the slug is never published to the page. POST keeps the address + * out of the URL, browser history and any Referer the provider's page would send. + */ + slot.innerHTML = ` +
+ + +
+
+ ${t('auth.sso_org_hint')} +
`; + slot.style.display = ''; + } catch { + // A failed lookup must never block a password login — the form still works. + slot.style.display = 'none'; + slot.innerHTML = ''; + } + } + + document.getElementById('loginEmail')?.addEventListener('input', (e) => { + clearTimeout(ssoLookupTimer); + const value = e.target.value; + ssoLookupTimer = setTimeout(() => lookupOrgSso(value), 400); + }); + const ssoParams = new URLSearchParams((window.location.hash.split('?')[1] || '')); const ssoToken = ssoParams.get('sso_token'); const ssoError = ssoParams.get('sso_error'); diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 3bd8efb..e0fd4ff 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -66,6 +66,35 @@ export async function render(container) { + + +

${t('apitoken.title')}

${t('apitoken.desc')}

@@ -634,6 +663,119 @@ export async function render(container) { } }); + /* ── Per-organization SSO ────────────────────────────────────────────────────────────────── + * + * Only an org owner/admin sees this. The server enforces the same rule (and answers 404, not + * 403, so an outsider learns nothing) — this just avoids showing a card the user cannot use. + */ + const orgId = user.current_organization?.id; + const canManageSso = orgId && ['org_owner', 'org_admin'].includes(user.current_org_role); + + async function loadSso() { + const card = document.getElementById('ssoCard'); + if (!card || !canManageSso) return; + card.style.display = ''; + const listEl = document.getElementById('ssoList'); + let providers = []; + try { + const res = await fetch(`/api/organizations/${orgId}/sso`, { + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, + }); + if (!res.ok) throw new Error('load failed'); + providers = (await res.json()).providers || []; + } catch { + listEl.innerHTML = `

${esc(t('sso.load_failed'))}

`; + return; + } + + if (!providers.length) { + listEl.innerHTML = `

${esc(t('sso.none'))}

`; + return; + } + + const origin = `${window.location.protocol}//${window.location.host}`; + listEl.innerHTML = providers.map((p) => ` +
+
+
+ ${esc(p.name)} + ${p.enabled ? '' : ` — ${esc(t('sso.disabled'))}`} +
${esc(p.issuer)}
+
${esc(t('sso.domains_label'))}: ${esc(p.email_domains || '—')}
+
+
+ + +
+
+ +
+
${esc(t('sso.callback_label'))}
+ ${esc(origin + p.callback_url)} +
+
`).join(''); + + listEl.querySelectorAll('[data-sso-toggle]').forEach((btn) => { + btn.addEventListener('click', async () => { + await ssoRequest('PUT', `/${btn.dataset.ssoToggle}`, { enabled: btn.dataset.enabled !== '1' }); + }); + }); + listEl.querySelectorAll('[data-sso-delete]').forEach((btn) => { + btn.addEventListener('click', async () => { + if (!confirm(t('sso.confirm_delete'))) return; + await ssoRequest('DELETE', `/${btn.dataset.ssoDelete}`); + }); + }); + } + + async function ssoRequest(method, path = '', body) { + try { + const res = await fetch(`/api/organizations/${orgId}/sso${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + // The server's message is the useful one here — a bad issuer or a domain already claimed by + // another organization both say exactly what went wrong, and a generic failure would not. + if (!res.ok) { showToast(data.error || t('sso.save_failed'), 'error'); return false; } + showToast(t('sso.saved'), 'success'); + await loadSso(); + return true; + } catch { + showToast(t('sso.save_failed'), 'error'); + return false; + } + } + + document.getElementById('ssoCreateBtn')?.addEventListener('click', async () => { + const payload = { + name: document.getElementById('ssoName').value.trim(), + issuer: document.getElementById('ssoIssuer').value.trim(), + client_id: document.getElementById('ssoClientId').value.trim(), + client_secret: document.getElementById('ssoClientSecret').value, + email_domains: document.getElementById('ssoDomains').value.trim(), + }; + if (!payload.name || !payload.issuer || !payload.client_id) { + showToast(t('sso.missing_fields'), 'error'); + return; + } + if (await ssoRequest('POST', '', payload)) { + ['ssoName', 'ssoIssuer', 'ssoClientId', 'ssoClientSecret', 'ssoDomains'] + .forEach((id) => { document.getElementById(id).value = ''; }); + document.getElementById('ssoAddDetails').open = false; + } + }); + + loadSso(); + + document.getElementById('createTokenBtn')?.addEventListener('click', async () => { const name = document.getElementById('tokName').value.trim(); const scope = document.getElementById('tokScope').value; diff --git a/server/db/database.js b/server/db/database.js index 3db48e2..55e8115 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -396,6 +396,40 @@ const migrations = [ // Per-telemetry-row rather than on `devices` because a display can be swapped, unplugged or // renegotiated without the player re-registering, and because a dual-output player registers ONE // ROW PER OUTPUT (see output_index) — each row must carry its own screen, not the box's first. + /* + * Per-organization SSO. + * + * Instance-wide providers come from the environment and belong to whoever runs the server. These + * belong to a CUSTOMER: an organization brings its own identity provider, and its people sign in + * with it without the operator touching a config file. + * + * `slug` is globally unique and randomly generated rather than chosen, because it is a URL path + * segment (/api/auth/oidc//start) and two organizations both wanting "okta" must not be + * able to collide — or to guess each other's. The admin only ever sees `name`. + * + * `client_secret_enc` is AES-256-GCM via lib/secretbox, the same at-rest treatment as TOTP + * secrets and BYOK AI keys. PKCE means a secret is optional, so a public client stores NULL. + * + * `email_domains` drives routing: a user typing name@customer.com is sent to that customer's + * provider instead of being shown a password box. It is a plain comma list because it is small, + * edited as a unit, and never joined against. + */ + `CREATE TABLE IF NOT EXISTS org_sso_providers ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + issuer TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret_enc TEXT, + scopes TEXT NOT NULL DEFAULT 'openid email profile', + email_domains TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE + )`, + "CREATE INDEX IF NOT EXISTS idx_org_sso_org ON org_sso_providers(organization_id)", "ALTER TABLE device_telemetry ADD COLUMN attached_display TEXT", "ALTER TABLE device_telemetry ADD COLUMN video_mode TEXT", // Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because diff --git a/server/lib/oidc-providers.js b/server/lib/oidc-providers.js index a6a55c4..8249ee8 100644 --- a/server/lib/oidc-providers.js +++ b/server/lib/oidc-providers.js @@ -104,7 +104,11 @@ function list(env = process.env) { /** 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; - return list(env).find((p) => p.slug === slug) || 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); } /** @@ -116,4 +120,73 @@ function publicList(env = process.env) { return list(env).map((p) => ({ slug: p.slug, name: p.name })); } -module.exports = { list, get, publicList, DEFAULT_SCOPES, SLUG_RE }; + +/* ──────────────────────────────────────────────────────────────────────────────────────────── + * 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 }; diff --git a/server/routes/auth.js b/server/routes/auth.js index 98db406..e6992dc 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,7 +1,6 @@ const express = require('express'); const router = express.Router(); const bcrypt = require('bcryptjs'); -const https = require('https'); const { v4: uuidv4 } = require('uuid'); const { db } = require('../db/database'); const { generateToken, generateMfaPendingToken, verifyMfaPendingToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES } = require('../middleware/auth'); @@ -881,6 +880,41 @@ 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', async (req, res) => { const provider = oidcProviders.get(req.params.slug); if (!provider) return backToApp(res, { sso_error: 'unknown_provider' }); @@ -987,6 +1021,27 @@ router.get('/oidc/:slug/callback', async (req, res) => { 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); + logActivity(user.id, 'org_sso_joined', `via ${provider.name}`, provider.organizationId, getClientIp(req)); + } + } + logSuccessfulLogin(user.id, user.email, getClientIp(req)); const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup }); const token = generateToken(user, workspaceId); diff --git a/server/routes/org-sso.js b/server/routes/org-sso.js new file mode 100644 index 0000000..d19168e --- /dev/null +++ b/server/routes/org-sso.js @@ -0,0 +1,217 @@ +'use strict'; + +/* + * Per-organization SSO — the customer-facing half of single sign-on. + * + * Instance-wide providers live in the environment and belong to whoever runs the server. These + * belong to a CUSTOMER: an organization points ScreenTinker at its own identity provider, and its + * people sign in with it without the operator editing a config file. + * + * The login flow is unchanged. A provider configured here is resolved by exactly the same + * oidc-providers.get(slug) the environment ones go through, so there is one authorization request + * builder, one token exchange and one verifier — not a second, less-tested path for tenants. + */ + +const express = require('express'); +const crypto = require('crypto'); +const router = express.Router(); +const { db } = require('../db/database'); +const { requireAuth } = require('../middleware/auth'); +const { resolveTenancy } = require('../lib/tenancy'); +const secretbox = require('../lib/secretbox'); +const oidc = require('../lib/oidc'); +const { logActivity, getClientIp } = require('../services/activity'); + +/* + * Only an org owner/admin may configure how their people sign in — it is the most security-relevant + * setting a tenant has. Platform staff are deliberately NOT given a bypass here: this is customer + * configuration, and an operator who needs to change it can do so as a member of that organization. + */ +function requireOrgAdmin(req, res, next) { + const orgId = req.params.orgId; + if (!orgId) return res.status(400).json({ error: 'organization required' }); + const row = db.prepare( + 'SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?' + ).get(orgId, req.user.id); + if (!row || (row.role !== 'org_owner' && row.role !== 'org_admin')) { + // 404 rather than 403: an outsider should not learn that an organization id exists. + return res.status(404).json({ error: 'Not found' }); + } + req.orgId = orgId; + next(); +} + +/* + * The slug is a URL path segment and is generated, never chosen. + * + * Two customers both wanting "okta" must not collide, and one must not be able to guess or squat + * another's. It is random and globally unique; the admin only ever sees the display name. + */ +const newSlug = () => `org${crypto.randomBytes(6).toString('hex')}`; + +/** Never let a secret out of the API, in either direction of a round trip. */ +function toPublic(row) { + return { + id: row.id, + slug: row.slug, + name: row.name, + issuer: row.issuer, + client_id: row.client_id, + has_client_secret: !!row.client_secret_enc, + scopes: row.scopes, + email_domains: row.email_domains, + enabled: !!row.enabled, + login_url: `/api/auth/oidc/${row.slug}/start`, + callback_url: `/api/auth/oidc/${row.slug}/callback`, + }; +} + +/* + * Domains are the routing key, so they are normalised hard: lowercased, de-duplicated, stripped of + * a leading @ or scheme someone pasted, and validated as something that can actually be the right + * hand side of an address. A wildcard is refused — "*" would route every unrecognised address at + * one customer's IdP. + */ +function normaliseDomains(raw) { + const seen = new Set(); + for (const part of String(raw || '').split(/[,\s]+/)) { + let d = part.trim().toLowerCase().replace(/^@/, '').replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + if (!d) continue; + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(d)) { + throw new Error(`"${part.trim()}" is not a valid email domain`); + } + seen.add(d); + } + return [...seen].join(','); +} + +/** + * A domain may belong to ONE organization. + * + * Without this, a second tenant could claim a domain already routed elsewhere and quietly capture + * that company's logins — the worst failure this feature could have. First claim wins; the loser is + * told which domain clashed and nothing about who holds it. + */ +function assertDomainsFree(domains, orgId, excludeId) { + if (!domains) return; + const wanted = domains.split(','); + const rows = db.prepare("SELECT id, organization_id, email_domains FROM org_sso_providers WHERE email_domains != ''").all(); + for (const row of rows) { + if (row.id === excludeId) continue; + const held = String(row.email_domains).split(','); + for (const d of wanted) { + if (held.includes(d) && row.organization_id !== orgId) { + const e = new Error(`the domain ${d} is already used for sign-in by another organization`); + e.status = 409; + throw e; + } + } + } +} + +router.use(requireAuth, resolveTenancy); + +// List an organization's providers. +router.get('/:orgId/sso', requireOrgAdmin, (req, res) => { + const rows = db.prepare('SELECT * FROM org_sso_providers WHERE organization_id = ? ORDER BY created_at').all(req.orgId); + res.json({ providers: rows.map(toPublic) }); +}); + +router.post('/:orgId/sso', requireOrgAdmin, async (req, res) => { + const { name, issuer, client_id: clientId, client_secret: clientSecret, scopes, email_domains: domains } = req.body || {}; + if (!name || !issuer || !clientId) { + return res.status(400).json({ error: 'name, issuer and client_id are required' }); + } + + let cleanDomains; + try { + cleanDomains = normaliseDomains(domains); + assertDomainsFree(cleanDomains, req.orgId, null); + } catch (e) { + return res.status(e.status || 400).json({ error: e.message }); + } + + /* + * The issuer is checked against the live provider BEFORE anything is stored. A typo here would + * otherwise be discovered by a user staring at a failed login, and the error they would see says + * nothing useful. Discovery also proves the URL is an OIDC issuer at all rather than a company + * home page someone pasted. + */ + try { + await oidc.discover(String(issuer).trim().replace(/\/+$/, '')); + } catch (e) { + return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${e.message}` }); + } + + const id = crypto.randomUUID(); + const slug = newSlug(); + db.prepare(` + INSERT INTO org_sso_providers (id, organization_id, slug, name, issuer, client_id, client_secret_enc, scopes, email_domains, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + `).run(id, req.orgId, slug, String(name).trim(), String(issuer).trim().replace(/\/+$/, ''), String(clientId).trim(), + clientSecret ? secretbox.encrypt(String(clientSecret)) : null, + String(scopes || 'openid email profile').trim(), cleanDomains); + + logActivity(req.user.id, 'org_sso_created', `${name} (${slug})`, req.orgId, getClientIp(req)); + res.status(201).json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(id))); +}); + +router.put('/:orgId/sso/:id', requireOrgAdmin, async (req, res) => { + const existing = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId); + if (!existing) return res.status(404).json({ error: 'Not found' }); + + const { name, issuer, client_id: clientId, client_secret: clientSecret, scopes, email_domains: domains, enabled } = req.body || {}; + + let cleanDomains = existing.email_domains; + if (domains !== undefined) { + try { + cleanDomains = normaliseDomains(domains); + assertDomainsFree(cleanDomains, req.orgId, existing.id); + } catch (e) { + return res.status(e.status || 400).json({ error: e.message }); + } + } + + const nextIssuer = issuer !== undefined ? String(issuer).trim().replace(/\/+$/, '') : existing.issuer; + if (nextIssuer !== existing.issuer) { + try { await oidc.discover(nextIssuer); } + catch (e) { return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${e.message}` }); } + } + + /* + * An absent client_secret LEAVES THE STORED ONE ALONE; an empty string clears it. The API never + * returns the secret, so a UI that round-trips a form would otherwise blank it on every save — + * the classic way a settings page silently breaks the thing it is editing. + */ + const secretEnc = clientSecret === undefined ? existing.client_secret_enc + : (clientSecret === '' ? null : secretbox.encrypt(String(clientSecret))); + + db.prepare(` + UPDATE org_sso_providers + SET name = ?, issuer = ?, client_id = ?, client_secret_enc = ?, scopes = ?, email_domains = ?, enabled = ?, + updated_at = strftime('%s','now') + WHERE id = ? + `).run( + name !== undefined ? String(name).trim() : existing.name, + nextIssuer, + clientId !== undefined ? String(clientId).trim() : existing.client_id, + secretEnc, + scopes !== undefined ? String(scopes).trim() : existing.scopes, + cleanDomains, + enabled === undefined ? existing.enabled : (enabled ? 1 : 0), + existing.id, + ); + + logActivity(req.user.id, 'org_sso_updated', `${existing.name} (${existing.slug})`, req.orgId, getClientIp(req)); + res.json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(existing.id))); +}); + +router.delete('/:orgId/sso/:id', requireOrgAdmin, (req, res) => { + const existing = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId); + if (!existing) return res.status(404).json({ error: 'Not found' }); + db.prepare('DELETE FROM org_sso_providers WHERE id = ?').run(existing.id); + logActivity(req.user.id, 'org_sso_deleted', `${existing.name} (${existing.slug})`, req.orgId, getClientIp(req)); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/server/server.js b/server/server.js index 808d3a2..43939a1 100644 --- a/server/server.js +++ b/server/server.js @@ -573,6 +573,10 @@ app.use('/api/auth/register', rateLimit(60000, 5)); // 5 registrations per minut app.use('/api/auth/totp/verify', rateLimit(60000, 10)); // Email-verification resend: cap so it can't be used to spray mail at an address. app.use('/api/auth/resend-verification', rateLimit(60000, 5)); +// Domain lookup is unauthenticated by necessity (it runs before login). Rate limited so it +// cannot be walked to enumerate which customers use SSO. +app.use('/api/auth/sso/discover', rateLimit(60000, 10)); +app.use('/api/auth/sso/start', rateLimit(60000, 10)); // Self-service password reset. The request endpoint is the spray surface (it sends mail to // an address the caller supplies), so it gets the tighter cap; the redeem endpoint is a // 32-byte-token guess, capped mostly to keep the bcrypt work bounded. @@ -583,6 +587,9 @@ app.use('/api/auth/reset-password', rateLimit(60000, 10)); // path prefix first, so this fires before /api/auth catches the request. app.use('/api/auth/users', rateLimit(60000, 20)); app.use('/api/auth', require('./routes/auth')); +// Per-organization SSO configuration. Mounted under /api/organizations so the org id is the +// route's own subject, which is what the org_owner/org_admin check keys on. +app.use('/api/organizations', require('./routes/org-sso')); // Rate limit pairing to prevent brute force (5 attempts per minute per IP). // #88: bind this to the whole /api/provision surface, not just /pair - the bare // POST /api/provision (routes/provisioning.js) is a second pairing endpoint that diff --git a/server/test/oidc-sso.test.js b/server/test/oidc-sso.test.js index 617e1d4..adeb917 100644 --- a/server/test/oidc-sso.test.js +++ b/server/test/oidc-sso.test.js @@ -260,3 +260,105 @@ test('the browser is told slugs and names only — never a client id or secret', assert.ok(!serialised.includes('shh')); assert.deepEqual(Object.keys(pub[0]).sort(), ['name', 'slug']); }); + +// --------------------------------------------------------------------------------------------- +// Per-organization SSO. +// +// Instance providers belong to whoever runs the server; these belong to a CUSTOMER. Two properties +// matter more than the feature itself: one organization must not be able to capture another's +// logins, and the login page must not become a way to enumerate who the customers are. + +const Database = require('better-sqlite3'); + +function orgDb() { + const d = new Database(':memory:'); + d.exec(` + CREATE TABLE org_sso_providers ( + id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, issuer TEXT NOT NULL, client_id TEXT NOT NULL, client_secret_enc TEXT, + scopes TEXT NOT NULL DEFAULT 'openid email profile', email_domains TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0); + `); + return d; +} + +function withOrgDb(rows, fn) { + const d = orgDb(); + for (const r of rows) { + d.prepare(`INSERT INTO org_sso_providers (id, organization_id, slug, name, issuer, client_id, email_domains, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + .run(r.id, r.org, r.slug, r.name, r.issuer || ISSUER, r.clientId || 'cid', r.domains || '', r.enabled === undefined ? 1 : r.enabled); + } + // Swap the module's lazily-resolved connection for this in-memory one. + const real = require('../db/database'); + const saved = real.db; + real.db = d; + delete require.cache[require.resolve('../lib/oidc-providers')]; + const mod = require('../lib/oidc-providers'); + try { return fn(mod); } finally { + real.db = saved; + delete require.cache[require.resolve('../lib/oidc-providers')]; + } +} + +test('an org provider is found by the email DOMAIN', () => { + withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com,acme.co.uk' }], (m) => { + assert.equal(m.forEmail('someone@acme.com').name, 'Acme SSO'); + assert.equal(m.forEmail('someone@ACME.CO.UK').name, 'Acme SSO', 'case-insensitive'); + assert.equal(m.forEmail('someone@other.com'), null); + assert.equal(m.forEmail('not-an-email'), null); + }); +}); + +test('a disabled provider stops answering for its domain', () => { + withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.com', enabled: 0 }], (m) => { + assert.equal(m.forEmail('x@acme.com'), null); + assert.equal(m.getOrgProvider('orgaaa'), null, 'and cannot be started directly either'); + }); +}); + +test('ORG PROVIDERS ARE NEVER PUBLISHED to the whole internet', () => { + // The login page lists instance-wide providers only. Listing a customer's IdP would both offer it + // to people it does not belong to and leak the customer list. + withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com' }], (m) => { + const pub = m.publicList({ GOOGLE_CLIENT_ID: 'g' }); + assert.deepEqual(pub.map((p) => p.slug), ['google']); + assert.ok(!JSON.stringify(pub).includes('Acme'), 'no customer name anywhere in the public list'); + }); +}); + +test('an org provider is still resolvable by slug, so the shared login flow can run it', () => { + withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com' }], (m) => { + const p = m.get('orgaaa', {}); + assert.equal(p.name, 'Acme SSO'); + assert.equal(p.organizationId, 'org-a', 'carries its org so the callback can grant membership'); + assert.equal(p.source, 'org'); + }); +}); + +test('an instance provider wins a slug clash with an org one', () => { + withOrgDb([{ id: '1', org: 'org-a', slug: 'google', name: 'Impostor', domains: 'evil.com' }], (m) => { + // Org slugs are randomly generated so this cannot happen by accident — but if it ever did, a + // tenant must not be able to shadow the platform's own Google button. + assert.equal(m.get('google', { GOOGLE_CLIENT_ID: 'real' }).name, 'Google'); + }); +}); + +test('the first organization to claim a domain keeps it', () => { + // Two rows, same domain. forEmail must be deterministic rather than returning whichever the + // database happened to hand back first — the API refuses the second claim, and this is the + // backstop if a row ever gets in another way. + withOrgDb([ + { id: '1', org: 'org-a', slug: 'orgaaa', name: 'First', domains: 'shared.com' }, + { id: '2', org: 'org-b', slug: 'orgbbb', name: 'Second', domains: 'shared.com' }, + ], (m) => { + assert.equal(m.forEmail('x@shared.com').name, 'First'); + }); +}); + +test('no database means no org providers, and no crash', () => { + // The env-only paths must keep working on an instance where the table has not been migrated yet. + const m = require('../lib/oidc-providers'); + assert.doesNotThrow(() => m.publicList({ GOOGLE_CLIENT_ID: 'g' })); +});