'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'); const { isPublicEmailDomain } = require('../lib/public-email-domains'); const domainVerify = require('../lib/domain-verify'); const emailSvc = require('../services/email'); /* * 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(); } /* * Configuring SSO requires a VERIFIED email address, on top of being an org admin. * * Everything else here rests on the identity of the person doing it: they claim domains, they point * the organization at an identity provider, and they are who the operator's claim notification * names. An unverified address is an assertion nobody has checked, so without this the entire * feature — including domain claims — is reachable by anyone who can type an address into the * signup form and never open the mail. * * Reads are deliberately NOT gated: seeing your own organization's configuration changes nothing, * and locking an admin out of the screen that explains why sign-in is broken helps no one. */ function requireVerifiedAdmin(req, res, next) { const row = db.prepare('SELECT email_verified FROM users WHERE id = ?').get(req.user.id); if (!row || !row.email_verified) { return res.status(403).json({ error: 'Verify your email address before configuring single sign-on.', code: 'email_unverified', }); } 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 and their proof state. A provider whose domains are all unverified can be saved and // looks configured, but routes nobody — the UI needs this to say so rather than imply success. domains: domainsFor(row.id), }; } /* * 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. */ const MAX_DOMAINS = 50; function normaliseDomains(raw) { const seen = new Set(); for (const part of String(raw || '').split(/[,\s]+/)) { /* * Capped. Uncapped, one verified org admin could POST 20,000 domains inside the 12 MB body * limit: 20,000 rows inserted under a single write lock (stalling every other query on the * instance) and 20,000 notification emails per platform admin. No real organization signs in * from fifty domains, and an org that does can create a second provider. */ if (seen.size >= MAX_DOMAINS) { const e = new Error(`at most ${MAX_DOMAINS} sign-in domains per provider`); e.status = 400; throw e; } 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`); } /* * A consumer mailbox provider is never an organization's sign-in domain, and claiming one is an * attack rather than a mistake: every Gmail or Outlook user typing their address into this * product's login page would be offered a "sign in with your organization" button pointing at * one tenant's infrastructure. It also lets one cheap account deny a public domain to everyone. */ if (isPublicEmailDomain(d)) { const e = new Error(`${d} is a public email provider and cannot be used as a sign-in domain. ` + 'Use a domain your organization owns.'); e.status = 400; throw e; } 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, excludeProviderId) { if (!domains) return; for (const d of domains.split(',')) { const row = db.prepare('SELECT * FROM org_sso_domains WHERE domain = ?').get(d); if (!row) continue; if (row.provider_id && row.provider_id === excludeProviderId) continue; /* * A lapsed unverified claim reserves nothing. Clearing it here rather than on a timer means the * domain frees itself the moment someone else asks for it, and there is no sweeper to forget to * run — a squatter's unprovable claim simply stops being an obstacle. */ if (domainVerify.isClaimExpired(row)) { db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(row.id); continue; } // Same-org duplicates were allowed and should not have been: two providers claiming one // domain makes routing depend on table-scan order, so half a company's staff get sent to an // identity provider that has never heard of them. const e = new Error(row.organization_id === orgId ? `the domain ${d} is already used by another of your providers` : `the domain ${d} is already used for sign-in by another organization`); e.status = 409; throw e; } } /* * Bring the claimed-domain rows in line with what the admin typed. * * A newly claimed domain arrives UNVERIFIED and stays inert until DNS proves the claim — it routes * nobody and the login callback refuses assertions for it. Re-typing an existing domain must not * reset that proof, which is why this diffs rather than deleting and re-inserting: a save on the * name field would otherwise silently un-verify every domain the customer had already proved, and * log their whole company out. * * Runs inside the caller's transaction so a domain cannot be reserved by two organizations at once. */ function syncDomains(providerId, orgId, domains) { const wanted = domains ? domains.split(',').filter(Boolean) : []; const existing = db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ?').all(providerId); const stale = new Map(existing.map((r) => [r.domain, r])); const claimed = []; // newly claimed, for the operator notification — sent AFTER the transaction for (const d of wanted) { const mine = stale.get(d); if (mine && !domainVerify.isClaimExpired(mine)) { stale.delete(d); // already ours and still live — keep any proof that happened continue; } if (mine) { /* * A LAPSED claim is not renewed in place. Renewing silently is what made the 8-hour limit * meaningless: a review held a domain indefinitely at one request per window, and because a * renewal was not a new claim, the operator was told exactly once, on day zero. * * So the row is dropped and re-created: the token changes (a record left over from the * abandoned attempt cannot satisfy the new one), and it counts as a fresh claim, which means * it is notified again. Squatting is not made impossible — it is made loud. */ stale.delete(d); db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(mine.id); } assertDomainsFree(d, orgId, providerId); db.prepare(`INSERT INTO org_sso_domains (id, organization_id, provider_id, domain, token) VALUES (?, ?, ?, ?, ?)`) .run(crypto.randomUUID(), orgId, providerId, d, domainVerify.newToken()); claimed.push(d); } for (const row of stale.values()) { db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(row.id); } return claimed; } /* * Tell the operator that a tenant has claimed a domain. * * DNS verification makes a claim worthless without control of the domain, so this is not what stops * abuse — it is what makes abuse VISIBLE. A tenant claiming `microsoft.com` will never verify it, * but an operator still wants to know somebody tried, and the notification is the difference * between finding that out now and finding it out from the company involved. * * Deliberately NOT sent to postmaster@ the claimed domain. That would mean this product emails * third parties who never signed up for it, on input any tenant can supply — a spam cannon with a * ScreenTinker return address. The operator can contact a domain owner; the server should not do it * unprompted. * * Failure to send is logged and swallowed: a mail outage must not stop a customer configuring SSO. */ function notifyOperatorOfClaim(req, { domains, orgId, providerName }) { try { if (!domains || !domains.length) return; // Always log, even with no mail transport — otherwise an instance without email has no record // of a claim at all, and those are exactly the instances least likely to notice. console.log(`[org-sso] domain(s) claimed by org ${orgId} (${providerName}): ${domains.join(', ')}`); if (!emailSvc.isConfigured()) return; // COALESCE, because email_alerts is nullable in practice on older rows and `= 1` silently // excludes NULL — the activation-nudge query already defends this way. const admins = db.prepare("SELECT email FROM users WHERE role = 'platform_admin' AND COALESCE(email_alerts, 1) = 1").all(); if (!admins.length) return; const org = db.prepare('SELECT name FROM organizations WHERE id = ?').get(orgId); const who = req.user && req.user.email ? req.user.email : 'an administrator'; // ONE message per save listing every domain, not one per domain per admin — fifty admins // claiming ten domains was five hundred messages from a single request. const body = [ `${who} claimed ${domains.length} sign-in domain(s):`, '', ...domains.map((d) => ` ${d}`), '', `Organization: ${org ? org.name : orgId} (${orgId})`, `Provider: ${providerName}`, '', 'A claimed domain routes nobody until a DNS TXT record proves it, and the claim lapses after', '8 hours if it is not proved. No action is needed unless this looks wrong.', ].join('\n'); const subject = domains.length === 1 ? `[ScreenTinker] SSO domain claimed: ${domains[0]}` : `[ScreenTinker] ${domains.length} SSO domains claimed`; for (const a of admins) { Promise.resolve(emailSvc.sendEmail({ to: a.email, subject, text: body })) .catch((e) => console.error('[org-sso] claim notification failed:', e && e.message)); } } catch (e) { console.error('[org-sso] claim notification failed:', e && e.message); } } /* * Tell the operator that a customer wants password login re-opened. * * The mail deliberately carries NO action link. A token that acts on its own turns every forwarded, * archived or auto-previewed copy of this message into a way to switch off a customer's single * sign-on; the decision belongs to a signed-in platform admin, so the mail only says where to make * it. Logged unconditionally, because an instance with no mail transport still needs a record that * somebody asked. */ function notifyOperatorOfRemovalRequest(req, { id, orgId, orgName, reason }) { try { const who = req.user && req.user.email ? req.user.email : 'an administrator'; console.warn(`[org-sso] SSO-ONLY REMOVAL REQUESTED for org ${orgName || orgId} (${orgId}) by ${who} — request ${id}`); if (!emailSvc.isConfigured()) return; const admins = db.prepare("SELECT email FROM users WHERE role = 'platform_admin' AND COALESCE(email_alerts, 1) = 1").all(); if (!admins.length) return; const body = [ `${who} has asked to stop requiring single sign-on for ${orgName || orgId}.`, '', 'Approving this RE-OPENS password sign-in for everyone at that organization\u2019s verified', 'domains. Until it is approved, nothing changes.', '', reason ? `Reason given: ${reason}` : 'No reason was given.', '', `Organization: ${orgName || ''} (${orgId})`, `Request: ${id}`, '', 'Review it in ScreenTinker under Admin. There is no link in this email on purpose — the', 'decision has to be made while signed in as a platform admin, so a forwarded copy of this', 'message cannot turn off a customer\u2019s single sign-on.', ].join('\n'); for (const a of admins) { Promise.resolve(emailSvc.sendEmail({ to: a.email, subject: `[ScreenTinker] Approval needed: stop requiring SSO for ${orgName || orgId}`, text: body, })).catch((e) => console.error('[org-sso] removal notification failed:', e && e.message)); } } catch (e) { console.error('[org-sso] removal notification failed:', e && e.message); } } /** A provider's domains, with the DNS record each unverified one still needs. */ function domainsFor(providerId) { return db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ? ORDER BY domain').all(providerId) .map((r) => ({ domain: r.domain, verified: !!r.verified_at, verified_at: r.verified_at, last_checked_at: r.last_checked_at, last_error: r.verified_at ? null : r.last_error, // The token is not a secret — it only means anything published in that domain's own DNS. ...domainVerify.instructions(r.domain, r.token), })); } /* * What an admin is told when discovery fails. * * The temptation is to hand back the underlying message, because it is genuinely the most useful * thing for a real misconfiguration. But the issuer is caller-supplied and fetched server-side, so * that message is an SSRF read primitive: `https://internal-host:8080 responded 403` and * `discovery issuer mismatch: … document says ` both report on services the caller cannot reach * directly. The jwks branch of the /test endpoint was already genericised for exactly this reason; * these paths were not, which left the scanner intact one line above the comment saying not to. * * So: the shape of the failure, never the upstream's answer. The full message goes to the log. */ function discoveryErrorMessage(e, issuer) { const raw = String((e && e.message) || ''); console.warn(`[org-sso] discovery failed for ${issuer}: ${raw}`); if (/must use https|not publicly routable|not a URL/i.test(raw)) return raw; // our own guard, no upstream data if (/issuer mismatch/i.test(raw)) return 'that URL is not the OpenID issuer it claims to be'; if (/is missing /i.test(raw)) return 'that issuer published an incomplete OpenID configuration'; if (/redirected/i.test(raw)) return 'that issuer redirected; the URL must be the final one'; if (/abort|timeout/i.test(raw)) return 'that issuer did not respond in time'; return 'no OpenID configuration could be read from that URL'; } /* * Wrap an async handler so a rejection is a 500 rather than a dead server. Express 4 does not await * handlers and server.js turns an unhandled rejection into process.exit — see the longer note on * asyncRoute in routes/auth.js, which is the same guard for the same reason. */ function asyncRoute(handler) { return (req, res, next) => Promise.resolve(handler(req, res, next)).catch((err) => { console.error(`[org-sso] unhandled error in ${req.method} ${req.path}:`, err && err.message); // Wrapped for the same reason as in routes/auth.js: a throw while REPORTING an error would // become an unhandled rejection and take the process down. try { if (!res.headersSent) res.status(500).json({ error: 'Something went wrong' }); } catch (e2) { console.error('[org-sso] failed to report an error:', e2 && e2.message); } }); } 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, requireVerifiedAdmin, asyncRoute(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: ${discoveryErrorMessage(e, issuer)}` }); } const id = crypto.randomUUID(); const slug = newSlug(); let newlyClaimed = []; /* * Re-check the domains INSIDE the transaction. The first check happened before `await * oidc.discover()`, which yields the event loop for a network round trip the caller's own IdP * controls the length of — two admins racing that window both passed and both got the domain, * after which routing became whichever row the scan reached first. */ try { db.transaction(() => { assertDomainsFree(cleanDomains, req.orgId, null); 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); newlyClaimed = syncDomains(id, req.orgId, cleanDomains); })(); } catch (e) { if (e.status) return res.status(e.status).json({ error: e.message }); console.error('[org-sso] create failed:', e.message); return res.status(500).json({ error: 'Could not save that provider' }); } // Notified after the transaction commits, so an operator is never told about a claim that rolled // back — and never inside it, where a slow mail path would hold a write lock. notifyOperatorOfClaim(req, { domains: newlyClaimed, orgId: req.orgId, providerName: String(name).trim() }); // (userId, action, details, deviceId, ipAddress, workspaceId) — the org id is NOT the 4th arg. logActivity(req.user.id, 'org_sso_created', `${name} (${slug}) org=${req.orgId}`, null, getClientIp(req)); res.status(201).json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(id))); })); router.put('/:orgId/sso/:id', requireOrgAdmin, requireVerifiedAdmin, asyncRoute(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; // `!== undefined` let a null through, and null took the destructive branch: normaliseDomains(null) // is '', which deleted every claimed domain and every DNS proof with it. A client that sends the // field as null on an unrelated save must not log a customer's whole company out. const domainsSupplied = domains !== undefined && domains !== null; if (domainsSupplied) { 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: ${discoveryErrorMessage(e, nextIssuer)}` }); } } /* * 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. */ let newlyClaimed = []; const secretEnc = clientSecret === undefined ? existing.client_secret_enc : (clientSecret === '' ? null : secretbox.encrypt(String(clientSecret))); /* * Same transaction, same re-check, and for the same reason as the create path above — this one was * missed when that was fixed, which left the race fully open on the route an attacker would * actually pick: `await oidc.discover()` on a CHANGED issuer is a round trip whose length the * caller's own IdP decides, so it can be held open for the full fetch timeout while a victim * organization claims the domain legitimately. The UNIQUE constraint on `domain` is the hard * backstop now; this keeps the failure a clean 409 rather than a constraint error. */ try { db.transaction(() => { if (domainsSupplied) assertDomainsFree(cleanDomains, req.orgId, existing.id); 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, ); if (domainsSupplied) newlyClaimed = syncDomains(existing.id, req.orgId, cleanDomains); })(); } catch (e) { // A thrown assertDomainsFree carries its own status; anything else is ours and stays generic // rather than returning a raw SQLite message to the caller. if (e.status) return res.status(e.status).json({ error: e.message }); console.error('[org-sso] update failed:', e.message); return res.status(500).json({ error: 'Could not save that provider' }); } notifyOperatorOfClaim(req, { domains: newlyClaimed, orgId: req.orgId, providerName: existing.name }); logActivity(req.user.id, 'org_sso_updated', `${existing.name} (${existing.slug}) org=${req.orgId}`, null, getClientIp(req)); res.json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(existing.id))); })); /* * Check a provider without making anyone log in. * * The overwhelmingly common failure is a configuration one — an issuer that is a company home page * rather than an OIDC issuer, a provider that is unreachable from the server, a JWKS with no signing * keys — and every one of those currently surfaces as a user staring at a failed login with an * error that says nothing useful. This turns that into an answer at configuration time. * * ⚠️ It is deliberately honest about its limits. Discovery and JWKS prove the provider EXISTS and * that we could verify a token it signed. They cannot prove the client id is right, that the secret * matches, or that the redirect URI is registered — only a real authorization round trip does that, * and the response says so rather than implying a green tick means "SSO works". */ router.post('/:orgId/sso/:id/test', requireOrgAdmin, requireVerifiedAdmin, asyncRoute(async (req, res) => { const row = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId); if (!row) return res.status(404).json({ error: 'Not found' }); const checks = []; let doc = null; try { doc = await oidc.discover(row.issuer); checks.push({ name: 'discovery', ok: true, detail: doc.issuer }); } catch (e) { checks.push({ name: 'discovery', ok: false, detail: discoveryErrorMessage(e, row.issuer) }); return res.json({ ok: false, checks }); } checks.push({ name: 'endpoints', ok: !!(doc.authorization_endpoint && doc.token_endpoint), detail: doc.authorization_endpoint || 'missing authorization_endpoint', }); try { const jwks = await oidc.fetchJwks(doc.jwks_uri); const signing = (jwks.keys || []).filter((k) => !k.use || k.use === 'sig'); checks.push({ name: 'signing_keys', ok: signing.length > 0, detail: signing.length ? `${signing.length} key(s)` : 'the provider published no signing keys', }); } catch (e) { // Deliberately generic. `jwks_uri` comes from the CALLER'S OWN discovery document, so echoing // the upstream status here turned this endpoint into a readable internal port scanner. checks.push({ name: 'signing_keys', ok: false, detail: 'could not read the provider keys' }); } // What the admin must have registered at the provider — the single most common thing to get // wrong, and something we can state exactly rather than ask them to guess. const origin = (process.env.APP_URL || '').trim().replace(/\/+$/, '') || `${req.protocol}://${req.get('host')}`; res.json({ ok: checks.every((c) => c.ok), checks, redirect_uri: `${origin}/api/auth/oidc/${row.slug}/callback`, // Said plainly so a passing test is not mistaken for a working login. note: 'unverifiable_by_test', }); })); /* * Check DNS for the proof, and record the answer. * * Verification is the whole point of the domain table: until this succeeds the domain routes nobody * and the login callback refuses to accept an assertion for it, so a claim on a domain the tenant * does not control buys them nothing at all. * * Deliberately pull-based rather than a background sweep. The admin has just edited DNS and wants to * know now, and a per-request check means there is no scheduler to fall over quietly and no window * where a verified domain sits unnoticed. */ router.post('/:orgId/sso/:id/domains/:domain/verify', requireOrgAdmin, requireVerifiedAdmin, asyncRoute(async (req, res) => { const provider = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?') .get(req.params.id, req.orgId); if (!provider) return res.status(404).json({ error: 'Not found' }); const row = db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ? AND domain = ?') .get(provider.id, String(req.params.domain).toLowerCase()); if (!row) return res.status(404).json({ error: 'Not found' }); if (row.verified_at) return res.json({ ok: true, domain: row.domain, verified: true, already: true }); /* * A lapsed claim is RELEASED, not reissued. * * Reissuing here renewed the clock, so pressing Verify once per window held a domain forever — * the exact squatting the time limit exists to stop, performed through the endpoint meant to * enforce it. Releasing it frees the domain for anyone else, and re-adding it is an ordinary new * claim: new token, and the operator is notified again. */ if (domainVerify.isClaimExpired(row)) { db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(row.id); logActivity(req.user.id, 'org_sso_domain_lapsed', `${row.domain} org=${req.orgId}`, null, getClientIp(req)); return res.status(409).json({ ok: false, domain: row.domain, verified: false, expired: true, error: 'That claim expired and has been released. Add the domain again to get a new record.', }); } const result = await domainVerify.check(row.domain, row.token); if (result.ok) { db.prepare("UPDATE org_sso_domains SET verified_at = strftime('%s','now'), last_checked_at = strftime('%s','now'), last_error = NULL WHERE id = ?") .run(row.id); logActivity(req.user.id, 'org_sso_domain_verified', `${row.domain} via ${result.via} org=${req.orgId}`, null, getClientIp(req)); console.log(`[org-sso] ${row.domain} verified via ${result.via} for org ${req.orgId}`); return res.json({ ok: true, domain: row.domain, verified: true, via: result.via }); } db.prepare("UPDATE org_sso_domains SET last_checked_at = strftime('%s','now'), last_error = ? WHERE id = ?") .run(result.error, row.id); res.status(400).json({ ok: false, domain: row.domain, verified: false, error: result.error, ...domainVerify.instructions(row.domain, row.token), }); })); /* ──────────────────────────────────────────────────────────────────────────────────────────── * SSO-only: requiring the organization's identity provider. */ /** Only a VERIFIED domain can compel anyone — see the note on ssoOnlyForEmail. */ function verifiedDomainCount(orgId) { return db.prepare(` SELECT COUNT(*) AS n FROM org_sso_domains d JOIN org_sso_providers p ON p.id = d.provider_id WHERE d.organization_id = ? AND d.verified_at IS NOT NULL AND p.enabled = 1 `).get(orgId).n; } router.get('/:orgId/sso-only', requireOrgAdmin, (req, res) => { const org = db.prepare('SELECT sso_only FROM organizations WHERE id = ?').get(req.orgId); const pending = db.prepare( "SELECT id, requested_by, reason, created_at FROM org_sso_only_requests WHERE organization_id = ? AND status = 'pending' ORDER BY created_at DESC" ).get(req.orgId); res.json({ sso_only: !!(org && org.sso_only), verified_domains: verifiedDomainCount(req.orgId), pending_removal_request: pending || null, }); }); /* * Turn it ON. An org admin does this alone: it can only ever reduce the ways into their own tenant, * and the people affected are their own. */ router.post('/:orgId/sso-only', requireOrgAdmin, requireVerifiedAdmin, (req, res) => { /* * Refuse when nothing is proved. Otherwise an organization could switch off password login for * accounts it cannot offer any other way in for — locking its own people out of a product they * can then only reach by asking the operator to undo it. */ if (!verifiedDomainCount(req.orgId)) { return res.status(400).json({ error: 'Verify at least one sign-in domain before requiring single sign-on — otherwise nobody could sign in.', code: 'no_verified_domain', }); } db.prepare('UPDATE organizations SET sso_only = 1 WHERE id = ?').run(req.orgId); logActivity(req.user.id, 'org_sso_only_enabled', `org=${req.orgId}`, null, getClientIp(req)); console.log(`[org-sso] SSO-only ENABLED for org ${req.orgId} by ${req.user.email}`); res.json({ sso_only: true }); }); /* * Turning it OFF is a REQUEST, not a switch. * * This is the direction that re-opens password login, so it is the direction an attacker who has * taken an org admin would take, and it is also what a customer will demand at their worst moment — * identity provider down, nobody can work — which is precisely when a self-service toggle gets * flipped without thinking. A platform admin has to approve it. */ router.post('/:orgId/sso-only/removal-request', requireOrgAdmin, requireVerifiedAdmin, (req, res) => { const org = db.prepare('SELECT sso_only, name FROM organizations WHERE id = ?').get(req.orgId); if (!org || !org.sso_only) return res.status(400).json({ error: 'Single sign-on is not required for this organization' }); const existing = db.prepare("SELECT id FROM org_sso_only_requests WHERE organization_id = ? AND status = 'pending'").get(req.orgId); if (existing) return res.status(409).json({ error: 'A removal request is already awaiting approval', request_id: existing.id }); const id = crypto.randomUUID(); const reason = String((req.body && req.body.reason) || '').slice(0, 500); db.prepare('INSERT INTO org_sso_only_requests (id, organization_id, requested_by, reason) VALUES (?, ?, ?, ?)') .run(id, req.orgId, req.user.id, reason); notifyOperatorOfRemovalRequest(req, { id, orgId: req.orgId, orgName: org.name, reason }); logActivity(req.user.id, 'org_sso_only_removal_requested', `org=${req.orgId} id=${id}`, null, getClientIp(req)); res.status(202).json({ status: 'pending', request_id: id }); }); /** Withdrawing your own request needs nobody's approval — it only ever keeps SSO required. */ router.delete('/:orgId/sso-only/removal-request/:id', requireOrgAdmin, requireVerifiedAdmin, (req, res) => { const row = db.prepare("SELECT * FROM org_sso_only_requests WHERE id = ? AND organization_id = ? AND status = 'pending'") .get(req.params.id, req.orgId); if (!row) return res.status(404).json({ error: 'Not found' }); db.prepare("UPDATE org_sso_only_requests SET status = 'cancelled', decided_at = strftime('%s','now'), decided_by = ? WHERE id = ?") .run(req.user.id, row.id); res.json({ status: 'cancelled' }); }); /* * The operator's side. * * Approval is an authenticated platform_admin action, NOT a link in an email: a token that acts on * its own turns every forwarded or archived message into a way to re-open password login for a * customer. The mail says what happened and where to go; the decision is made signed in. */ function requirePlatformAdmin(req, res, next) { if (!req.user || req.user.role !== 'platform_admin') return res.status(404).json({ error: 'Not found' }); next(); } router.get('/sso-only/removal-requests', requirePlatformAdmin, (req, res) => { const rows = db.prepare(` SELECT r.id, r.organization_id, r.reason, r.created_at, o.name AS organization_name, u.email AS requested_by_email FROM org_sso_only_requests r LEFT JOIN organizations o ON o.id = r.organization_id LEFT JOIN users u ON u.id = r.requested_by WHERE r.status = 'pending' ORDER BY r.created_at `).all(); res.json({ requests: rows }); }); router.post('/sso-only/removal-requests/:id/:decision', requirePlatformAdmin, (req, res) => { const decision = req.params.decision === 'approve' ? 'approved' : req.params.decision === 'reject' ? 'rejected' : null; if (!decision) return res.status(400).json({ error: 'decision must be approve or reject' }); const row = db.prepare("SELECT * FROM org_sso_only_requests WHERE id = ? AND status = 'pending'").get(req.params.id); if (!row) return res.status(404).json({ error: 'Not found' }); const note = String((req.body && req.body.note) || '').slice(0, 500); db.transaction(() => { db.prepare("UPDATE org_sso_only_requests SET status = ?, decided_by = ?, decided_at = strftime('%s','now'), decision_note = ? WHERE id = ?") .run(decision, req.user.id, note, row.id); // Only an approval changes anything. A rejection leaves SSO required, which is the safe state. if (decision === 'approved') db.prepare('UPDATE organizations SET sso_only = 0 WHERE id = ?').run(row.organization_id); })(); logActivity(req.user.id, `org_sso_only_${decision}`, `org=${row.organization_id} id=${row.id}`, null, getClientIp(req)); console.log(`[org-sso] SSO-only removal ${decision} for org ${row.organization_id} by ${req.user.email}`); res.json({ status: decision, organization_id: row.organization_id }); }); router.delete('/:orgId/sso/:id', requireOrgAdmin, requireVerifiedAdmin, (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' }); /* * Take the domain rows and the accounts with it, in one transaction. * * Deleting only the provider left both behind, and both were unrecoverable in the product: * * - `domain` is globally UNIQUE and a VERIFIED row never expires, so an orphaned row blocked * its own domain forever — for this organization and for every other one — while being * invisible in the API and routing nobody. Re-claiming your own domain returned 409. The only * way out was SQL. * - the users this provider established kept pointing at a slug nothing answers to. They could * not sign in (no provider), could not use a password (auth_provider is not 'local') and * could not register (address taken). * * Both are handled here, at the moment the intent is known, rather than inferred later from the * absence of configuration — which is what made an unset GOOGLE_CLIENT_ID look like a deletion. */ const freed = db.transaction(() => { const domains = db.prepare('DELETE FROM org_sso_domains WHERE provider_id = ?').run(existing.id).changes; // Back to a local account, so the owner can recover it by proving the mailbox — strictly // stronger evidence than the identity-provider assertion that created it. const users = db.prepare("UPDATE users SET auth_provider = 'local', provider_id = NULL WHERE auth_provider = ?") .run(existing.slug).changes; db.prepare('DELETE FROM org_sso_providers WHERE id = ?').run(existing.id); return { domains, users }; })(); logActivity(req.user.id, 'org_sso_deleted', `${existing.name} (${existing.slug}) org=${req.orgId} domains=${freed.domains} users_reset=${freed.users}`, null, getClientIp(req)); console.log(`[org-sso] deleted ${existing.slug}: released ${freed.domains} domain(s), returned ${freed.users} account(s) to local`); res.json({ success: true, domains_released: freed.domains, accounts_returned_to_local: freed.users }); }); module.exports = router;