mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Per-organization toggle. Enabling is the safe direction and an org admin does it
alone; turning it back off is a REQUEST that a platform admin has to approve, because
that is the direction that re-opens password sign-in — the direction a compromised
admin would take, and the one a customer will demand at their worst moment with the
IdP down.
- requires at least one VERIFIED domain, so nobody can lock a company out of a
domain they only typed, and an org cannot leave its own people with no way in
- the login page HIDES the password field for those domains rather than letting
someone type a password that will be refused and then go reset it
- the refusal is `sso_required`, distinguishable from a wrong password
- the approval email carries NO action link: a token that acts on its own turns
every forwarded copy into a way to switch off a customer's SSO. The decision is
made signed in as a platform admin.
INSTANCE PROVIDERS WERE A SIDE DOOR
Blocking passwords while leaving "Continue with Google" is not requiring single
sign-on, it is renaming the bypass — instance-wide providers are the operator's and
are NOT domain-confined, so one could assert an address at an SSO-only domain and walk
straight past the customer's MFA and deprovisioning. The callback now refuses any
provider other than that organization's own, and the page stops offering them.
Instance-wide stays the default everywhere else: an address whose domain has no org
SSO still gets local plus every configured instance provider. The org only overrides
for its own verified domains.
PLATFORM_ADMIN IS EXEMPT, DELIBERATELY
The operator approves turning this off. If the operator's own address sat at an
SSO-only domain and that IdP broke, nobody could sign in to approve anything and the
instance would be bricked. The exemption is the break-glass, and a test pins it as
source so it is not "tidied away" as a convenience.
BROWSER-FOUND
Hiding the password by hiding its .form-group also hid the organization SSO button,
which lives inside that same group — leaving a login page whose only action was
"Create Account". Only visible by looking at a screenshot. Hides the field now, not
the container.
Player untouched: this branch changes no device, WebSocket or provisioning file, and
the 358 device/player/socket/pairing tests pass.
1603 tests pass. Enforcement, the approval workflow and the login page verified in
real Chrome.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
806 lines
40 KiB
JavaScript
806 lines
40 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);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 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 <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),
|
|
});
|
|
}));
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────────────────────
|
|
* 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;
|