mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Four HIGH findings. Two were mine, and one was a composition of two of my own fixes.
ONE EXTRA SLASH DEFEATED EVERY /api/auth LIMITER
`/api/auth//login` still reaches the login handler — Express normalises the mount
boundary for the router — but `app.use('/api/auth/login', rateLimit(...))` does not
match it, so the limiter never runs. A review got a real session after 60 unthrottled
password attempts. Same for //totp/verify (unlimited 6-digit brute force),
//forgot-password (unlimited reset mail to any address) and //sso/discover (the
customer-enumeration cap, gone). Fixing the limiter KEY could never help, because the
middleware was never invoked: the path is now collapsed to one canonical form before
routing. Pre-existing, and it falsified this file's own warning about walking past the
login limiter.
STORED XSS: I ESCAPED ONE COPY OF THE TABLE
My earlier fix patched views/admin.js line 357 and missed line 372 in the same
function — and missed views/settings.js entirely, which renders a SECOND copy of the
platform users table from the same endpoint, including the email in a raw text node.
The write path was `POST /api/admin/users`, whose EMAIL_RE barred only whitespace, so
an org or workspace admin (not a platform admin) could choose an address that executed
in the operator's session. Both tables escaped, both regexes tightened to reject markup
characters, verified against 11 address shapes.
I KILLED THE BREAK-GLASS WHILE CLOSING AN ORACLE
Hoisting the domain check above the account lookup — my fix for the enumeration oracle
— made `user.role !== 'platform_admin'` unreachable for enforced domains. On a
self-host the operator IS the org owner, and my would_lock_out_actor guard GUARANTEES
their address is inside the enforced set, so the recovery loop closed on itself:
approving a removal request needs a signed-in platform admin. Both properties hold now
by letting the operator through on a CORRECT PASSWORD only — every wrong answer is the
identical 403 whether the address exists, does not exist, or is theirs. Verified: 200 /
403 / 403 / 403.
Also fixed: enabling SSO-only locked out every password-holding member including the
admin who pressed the button (password refused by policy, SSO refused by
account_exists_local). An org provider now adopts a password account at a domain it has
PROVED by DNS when the org requires SSO — which is what a verified domain means, and
what every hosted identity product does.
SSO USERS WERE LANDING IN A PERSONAL ORG
The membership write added organization_members but no workspace_members, and
ensureDefaultOrgForUser looks at workspaces — so it minted each SSO user a private
organization and made it their current one. The customer's Members page read
"Members (1)" while their staff signed in successfully and were invisible.
ALSO: bcrypt on a NULL password_hash 500'd with a stack (and was an oracle for accounts
a provider deletion had returned to local); stranded_members was returned by the server
and discarded by the UI; a provider with zero domains was the one useless state with no
warning; two limiter shapes were missing (removal-request shared the garbage bucket —
an unauthenticated flood could deny the SSO break-glass path); doubled mail subject
prefixes; a DELETE that toasted "Saved"; a decided request left in the DOM with live
listeners; and a confirm dialog promising "immediately" when sessions already open
survive.
1609 tests, three clean runs. Limiter, break-glass, oracle parity and null-password all
verified against a running server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
940 lines
46 KiB
JavaScript
940 lines
46 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
|
|
? `SSO domain claimed: ${domains[0]}`
|
|
: `${domains.length} SSO domains claimed`; // services/email.js adds the [ScreenTinker] prefix
|
|
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: `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);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* An SSO-only organization may not dismantle its own enforcement sideways.
|
|
*
|
|
* `sso_only` is honoured only while a provider is enabled AND a domain is verified, so disabling
|
|
* the provider, clearing its domains, or deleting it all switch enforcement off — with `sso_only`
|
|
* still reading `true`, no request filed and the operator never told. A review used each of the
|
|
* three, and the delete variant additionally rewrites every federated account to `local`, after
|
|
* which a password reset takes over accounts the identity provider was supposed to own.
|
|
*
|
|
* That made the approval workflow decorative: anyone who could file a request could instead just
|
|
* turn the provider off. So the same interlock guards every route that would leave the tenant with
|
|
* nothing enforcing, and points at the request as the way through.
|
|
*/
|
|
/** The domains that currently ENFORCE for an organization: verified, on an enabled provider. */
|
|
function enforcingDomains(orgId, excludeProviderId = null) {
|
|
return db.prepare(`
|
|
SELECT d.domain 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
|
|
AND (? IS NULL OR p.id != ?)
|
|
`).all(orgId, excludeProviderId, excludeProviderId).map((r) => r.domain);
|
|
}
|
|
|
|
/*
|
|
* An SSO-only organization may not shrink the set of domains that enforce.
|
|
*
|
|
* The first version of this asked "would ANY provider still enforce?", which was the wrong
|
|
* question twice over, and a review defeated it both ways:
|
|
*
|
|
* SWAP it only fired when the resulting domain list was EMPTY, so replacing
|
|
* `acme.test` with `decoy.test` removed every proof and sailed through — two PUTs
|
|
* and the customer's domain no longer required anything.
|
|
* SIBLING it counted PROVIDERS, so with two configured you could disable the one that owns
|
|
* your staff's domain while the other, covering a domain nobody signs in at, kept the
|
|
* answer "yes, something still enforces".
|
|
*
|
|
* The question that matters is per-DOMAIN: after this change, is every domain that enforces today
|
|
* still enforcing? Losing one is exactly what needs the operator, whichever route gets you there.
|
|
*/
|
|
function assertEnforcementNotReduced(orgId, providerId, nextDomainsFor, what) {
|
|
const org = db.prepare('SELECT sso_only FROM organizations WHERE id = ?').get(orgId);
|
|
if (!org || !org.sso_only) return;
|
|
|
|
const before = new Set(enforcingDomains(orgId));
|
|
const after = new Set(enforcingDomains(orgId, providerId));
|
|
// Whatever this provider will still contribute afterwards, as VERIFIED domains only — a domain
|
|
// being re-added is unverified, so it does not count as still enforcing.
|
|
for (const d of nextDomainsFor) after.add(d);
|
|
|
|
const lost = [...before].filter((d) => !after.has(d));
|
|
if (!lost.length) return;
|
|
|
|
const e = new Error(`Your organization requires single sign-on, so ${what} would stop `
|
|
+ `${lost.join(', ')} from being covered and leave those people unable to sign in. `
|
|
+ 'Ask the people who run this server to approve stopping the requirement first.');
|
|
e.status = 409;
|
|
e.code = 'sso_only_locked';
|
|
throw e;
|
|
}
|
|
|
|
/** A provider's domains, with the DNS record each unverified one still needs. *//** 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.
|
|
*/
|
|
// Disabling this provider, or removing the domains it enforces through, is the same act as
|
|
// turning the requirement off — and that needs the operator.
|
|
try {
|
|
const willBeEnabled = enabled === undefined ? !!existing.enabled : !!enabled;
|
|
/*
|
|
* What this provider still covers afterwards: nothing if it is being disabled, otherwise the
|
|
* domains it keeps that are ALREADY verified. A domain typed back in arrives unverified and
|
|
* enforces nobody, which is precisely how the swap bypass worked.
|
|
*/
|
|
let keeps = [];
|
|
if (willBeEnabled) {
|
|
const kept = domainsSupplied ? new Set(cleanDomains.split(',').filter(Boolean)) : null;
|
|
keeps = db.prepare("SELECT domain FROM org_sso_domains WHERE provider_id = ? AND verified_at IS NOT NULL")
|
|
.all(existing.id).map((r) => r.domain)
|
|
.filter((d) => (kept ? kept.has(d) : true));
|
|
}
|
|
const what = !willBeEnabled ? 'disabling this provider' : 'changing its sign-in domains';
|
|
assertEnforcementNotReduced(req.orgId, existing.id, keeps, what);
|
|
} catch (e) {
|
|
return res.status(e.status || 400).json({ error: e.message, code: e.code });
|
|
}
|
|
|
|
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',
|
|
});
|
|
}
|
|
|
|
/*
|
|
* ⚠️ Do not let the person pressing this button lock themselves out.
|
|
*
|
|
* The commonest onboarding shape is: sign up with a personal or consultancy address, create the
|
|
* organization, verify the company domain, turn this on. Enforcement then covers them (they are
|
|
* a member) while their own address is outside the verified domains — so passwords are refused
|
|
* AND their org's provider will not assert for them either, because assertions are confined to
|
|
* verified domains. There is no self-service way back: no route removes a membership, and
|
|
* password reset succeeds but login still refuses. A review walked into it on the happy path.
|
|
*
|
|
* They are told exactly which address is the problem, and what to do about it.
|
|
*/
|
|
const domains = enforcingDomains(req.orgId);
|
|
const at = String(req.user.email || '').lastIndexOf('@');
|
|
const ownDomain = at === -1 ? '' : String(req.user.email).slice(at + 1).toLowerCase().replace(/\.+$/, '');
|
|
if (!domains.includes(ownDomain)) {
|
|
return res.status(400).json({
|
|
error: `Your own address (${req.user.email}) is not at a verified domain (${domains.join(', ')}), `
|
|
+ 'so requiring single sign-on would lock you out with no way back. Verify that domain first, '
|
|
+ 'or hand ownership to someone whose address is covered.',
|
|
code: 'would_lock_out_actor',
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Everyone ELSE in the same position is reported rather than refused — they may be exactly the
|
|
* contractors this is meant to shut out. But the admin must find out here, not from a support
|
|
* ticket after the fact.
|
|
*/
|
|
const stranded = db.prepare(`
|
|
SELECT DISTINCT u.email FROM users u
|
|
WHERE u.id IN (
|
|
SELECT m.user_id FROM organization_members m WHERE m.organization_id = ?
|
|
UNION
|
|
SELECT wm.user_id FROM workspace_members wm JOIN workspaces w ON w.id = wm.workspace_id
|
|
WHERE w.organization_id = ?
|
|
)
|
|
`).all(req.orgId, req.orgId)
|
|
.map((r) => r.email)
|
|
.filter((e) => {
|
|
const i = String(e).lastIndexOf('@');
|
|
return i === -1 || !domains.includes(String(e).slice(i + 1).toLowerCase().replace(/\.+$/, ''));
|
|
});
|
|
db.prepare('UPDATE organizations SET sso_only = 1 WHERE id = ?').run(req.orgId);
|
|
logActivity(req.user.id, 'org_sso_only_enabled',
|
|
`org=${req.orgId} stranded=${stranded.length}`, null, getClientIp(req));
|
|
console.log(`[org-sso] SSO-only ENABLED for org ${req.orgId} by ${req.user.email}`
|
|
+ (stranded.length ? ` — ${stranded.length} member(s) outside the verified domains: ${stranded.join(', ')}` : ''));
|
|
res.json({ sso_only: true, stranded_members: stranded });
|
|
});
|
|
|
|
/*
|
|
* 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.
|
|
*/
|
|
try {
|
|
assertEnforcementNotReduced(req.orgId, existing.id, [], 'deleting this provider');
|
|
} catch (e) {
|
|
return res.status(e.status || 400).json({ error: e.message, code: e.code });
|
|
}
|
|
|
|
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;
|