screentinker/server/routes/org-sso.js
ScreenTinker c91b96ab91 SSO: refuse delegated proof names, release lapsed and deleted claims
Third review pass. It confirmed the crash wrapper holds (~13,000 hostile requests,
no fourth crash), the SSRF rewrite holds (77 vectors, every CIDR boundary proven),
the rate-limiter rewrite closed the login brute-force bypass, and /sso/claim rejects
every wrong token kind. It also found that two things I built yesterday did not do
what they claimed.

THE 8-HOUR LIMIT DID NOT BOUND SQUATTING

Pressing Verify on an expired claim REISSUED it in place, renewing the clock — so one
request per window held a domain forever, through the endpoint meant to enforce the
limit. Worse, a renewal was not a new claim, so the operator was notified exactly once,
on day zero: a tenant could sit on a company's domain for a year off a single stale
alert. A lapsed claim is now RELEASED. Re-adding it is an ordinary new claim: new
token, and the operator is told again. Squatting is not impossible; it is loud.

A DELEGATED PROOF NAME COULD FORGE A DOMAIN

A TXT lookup follows CNAMEs, and RFC 4592 means a wildcard `*.victim.com` synthesizes
`_screentinker-verify.victim.com` too — so a wildcard CNAME let whoever controls its
target prove a domain they do not own, turning an ordinary subdomain takeover into
every `@victim.com` login. A reviewer did this against a real authoritative zone. The
proof name is now refused if it is a CNAME, which is stricter than ACME's dns-01, and
the comment that claimed wildcards "cannot be mistaken for a proof" — true only for
wildcard TXT — has been corrected.

MY VERIFY BUTTON REPORTED FAILURE ON SUCCESS

`await load()` — the loader is `loadSso()`. The ReferenceError went into a bare catch,
so a correct DNS proof showed "Could not verify that domain" and left the card stale.
On the expired branch the admin kept publishing a token the server had already rotated.

ALSO FIXED

  - deleting a provider stranded its verified domains (no FK, UNIQUE, never expires) so
    the domain was blocked for EVERY org forever with no in-product recovery, and its
    users could neither sign in nor reset. Delete now releases the domains and returns
    the accounts to local, in one transaction; a cascade FK backstops it.
  - isOrphanedFederated read absence-of-config as proof-of-deletion, so unsetting
    GOOGLE_CLIENT_ID made every Google account password-resettable instance-wide, and
    irreversibly. Restricted to org-provider slugs.
  - `email_domains: null` (not undefined) took the destructive branch and deleted every
    DNS proof an organization had.
  - unbounded domain lists: 400 domains sent 401 emails; now capped at 50, one digest
    per save, and /api/organizations is rate-limited at all for the first time.
  - login and register responses carried password_reset_hash and email_verify_hash —
    live account-takeover credentials handed to the browser. One sanitiser now.
  - trailing-dot hostname (`https://localhost./`) slipped the SSRF guard.
  - asyncRoute's own catch could throw and kill the process it exists to protect.
  - a legacy DB whose typed domains were never verified now says so LOUDLY at boot
    instead of silently locking every federated user out.

TESTS

Two of the previous round's tests passed against the code they were named after: one
asserted UNIQUE against the test harness's own CREATE TABLE rather than the shipped
schema, the other used two different domains so no ordering was exercised. Both
replaced and confirmed load-bearing. Seven mutations now turn the suite red, including
removing the CNAME refusal, the verified_at filter, and the expiry itself.

1598 tests pass. Delete-release, lapse-release and the leak fix verified against a
running server.

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

636 lines
31 KiB
JavaScript

'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);
}
}
/** 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 <X>` 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),
});
}));
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;