mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
A second review pass, run against the previous commit, found four blockers — two of
them introduced by the fixes in that commit. It also confirmed the original account
takeover is closed: a hostile IdP with real TLS, discovery, JWKS and RS256 driving the
real routers now stops at domain_not_allowed, and all 16 bypass variants are refused.
DOMAIN OWNERSHIP (the root cause, not the symptom)
A claimed domain used to mean "nobody else claimed it". It now means the organization
published a record in that domain's own DNS — TXT or CNAME, at a dedicated
_screentinker-verify name rather than the apex, where an edit would sit beside SPF.
- an unverified domain routes NOBODY and cannot be asserted; it reserves the name
- an unverified claim LAPSES after 8 hours, so a domain cannot be held against its
real owner, and lapsing rotates the token so a record left over from an abandoned
attempt cannot satisfy a later claim
- a verified domain never expires — re-proving on a timer would log a customer out
over a DNS edit made months later
- routing and confinement read the VERIFIED set only, never the typed column
- configuring SSO now requires a verified email address
- platform admins are emailed when a domain is claimed; nothing is ever sent to the
claimed domain, which would let any tenant make this product email third parties
Instance-wide providers are exempt from all of it: they are the operator's own
configuration and keep the trust they have always had.
BLOCKERS FROM THE REVIEW
- two unauthenticated remote crashes, both one request, both "async handler throws
before its try": `Cookie: st_oidc_tx=%` (unguarded decodeURIComponent) and the
fail-closed secret added last commit, which turned a JWT_SECRET rotation into a
permanent crash loop. Fixed the CLASS with asyncRoute() rather than the instances.
- the SSRF guard was bypassable via IPv4-mapped IPv6 ([::ffff:127.0.0.1]) and also
refused every host beginning "fc"/"fd" (fcm.googleapis.com). Addresses are now
parsed and compared by RANGE. 42 cases verified.
- the takeover fix had NO test — the test named after it asserted two struct fields
and passed with the guard deleted. The decision is now a pure function and four
mutations were confirmed to turn the suite red.
- the PUT path never received the TOCTOU fix, so two orgs could end up holding one
domain and forEmail handed routing to the attacker's older row.
ALSO
- linking compared slugs, so an org could never rotate its own IdP, and fell open on
an empty auth_provider. It now asks which ORGANIZATION owns the slug.
- an account stranded by a deleted provider can be reclaimed by password reset —
proof of the mailbox, which is stronger than the IdP assertion that created it.
- /sso/claim accepted a pre-TOTP mfa_pending token and returned the full user row;
it now takes a purpose-built 120s claim token with a pinned algorithm and typ.
- the rate limiter keyed on a caller-controlled path, so a trailing slash bought a
fresh bucket — a real login brute-force bypass.
- domain_not_allowed and account_exists_other_provider rendered as "please try
again", advice that can never work.
- malformed asserted addresses are refused rather than trimmed into shape.
- dead config (microsoftTenantId defaulted to 'common', which the provider code now
refuses) and the orphaned google-auth-library dependency removed.
1591 tests pass. Domain lifecycle verified end to end against a running server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
571 lines
27 KiB
JavaScript
571 lines
27 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.
|
|
*/
|
|
function normaliseDomains(raw) {
|
|
const seen = new Set();
|
|
for (const part of String(raw || '').split(/[,\s]+/)) {
|
|
let d = part.trim().toLowerCase().replace(/^@/, '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
if (!d) continue;
|
|
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(d)) {
|
|
throw new Error(`"${part.trim()}" is not a valid email domain`);
|
|
}
|
|
/*
|
|
* 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) {
|
|
stale.delete(d);
|
|
/*
|
|
* Already ours — keep a proof that happened. But a LAPSED claim must not be renewed by
|
|
* simply saving the form again, or the 8-hour limit would mean nothing: the token is rotated,
|
|
* which also means a record left in DNS from the previous attempt no longer matches. An old
|
|
* record lying around proves nothing about the claim being made now.
|
|
*/
|
|
if (domainVerify.isClaimExpired(mine)) {
|
|
db.prepare("UPDATE org_sso_domains SET token = ?, token_issued_at = strftime('%s','now'), last_error = NULL WHERE id = ?")
|
|
.run(domainVerify.newToken(), mine.id);
|
|
}
|
|
continue;
|
|
}
|
|
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, { domain, orgId, providerName }) {
|
|
try {
|
|
if (!emailSvc.isConfigured()) return;
|
|
const admins = db.prepare("SELECT email FROM users WHERE role = 'platform_admin' AND email_alerts = 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';
|
|
const body = [
|
|
`${who} claimed the sign-in domain ${domain}.`,
|
|
'',
|
|
`Organization: ${org ? org.name : orgId} (${orgId})`,
|
|
`Provider: ${providerName}`,
|
|
'',
|
|
'The domain routes nobody until it is verified by a DNS record published in the domain itself,',
|
|
'and the claim lapses after 8 hours if it is not. No action is needed unless this looks wrong.',
|
|
].join('\n');
|
|
for (const a of admins) {
|
|
emailSvc.sendEmail({
|
|
to: a.email,
|
|
subject: `[ScreenTinker] SSO domain claimed: ${domain}`,
|
|
text: body,
|
|
}).catch((e) => console.error('[org-sso] claim notification failed:', e && e.message));
|
|
}
|
|
} catch (e) {
|
|
console.error('[org-sso] claim notification failed:', e && e.message);
|
|
}
|
|
}
|
|
|
|
/** A provider's domains, with the DNS record each unverified one still needs. */
|
|
function domainsFor(providerId) {
|
|
return db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ? ORDER BY domain').all(providerId)
|
|
.map((r) => ({
|
|
domain: r.domain,
|
|
verified: !!r.verified_at,
|
|
verified_at: r.verified_at,
|
|
last_checked_at: r.last_checked_at,
|
|
last_error: r.verified_at ? null : r.last_error,
|
|
// The token is not a secret — it only means anything published in that domain's own DNS.
|
|
...domainVerify.instructions(r.domain, r.token),
|
|
}));
|
|
}
|
|
|
|
/*
|
|
* What an admin is told when discovery fails.
|
|
*
|
|
* The temptation is to hand back the underlying message, because it is genuinely the most useful
|
|
* thing for a real misconfiguration. But the issuer is caller-supplied and fetched server-side, so
|
|
* that message is an SSRF read primitive: `https://internal-host:8080 responded 403` and
|
|
* `discovery issuer mismatch: … document says <X>` both report on services the caller cannot reach
|
|
* directly. The jwks branch of the /test endpoint was already genericised for exactly this reason;
|
|
* these paths were not, which left the scanner intact one line above the comment saying not to.
|
|
*
|
|
* So: the shape of the failure, never the upstream's answer. The full message goes to the log.
|
|
*/
|
|
function discoveryErrorMessage(e, issuer) {
|
|
const raw = String((e && e.message) || '');
|
|
console.warn(`[org-sso] discovery failed for ${issuer}: ${raw}`);
|
|
if (/must use https|not publicly routable|not a URL/i.test(raw)) return raw; // our own guard, no upstream data
|
|
if (/issuer mismatch/i.test(raw)) return 'that URL is not the OpenID issuer it claims to be';
|
|
if (/is missing /i.test(raw)) return 'that issuer published an incomplete OpenID configuration';
|
|
if (/redirected/i.test(raw)) return 'that issuer redirected; the URL must be the final one';
|
|
if (/abort|timeout/i.test(raw)) return 'that issuer did not respond in time';
|
|
return 'no OpenID configuration could be read from that URL';
|
|
}
|
|
|
|
/*
|
|
* Wrap an async handler so a rejection is a 500 rather than a dead server. Express 4 does not await
|
|
* handlers and server.js turns an unhandled rejection into process.exit — see the longer note on
|
|
* asyncRoute in routes/auth.js, which is the same guard for the same reason.
|
|
*/
|
|
function asyncRoute(handler) {
|
|
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch((err) => {
|
|
console.error(`[org-sso] unhandled error in ${req.method} ${req.path}:`, err && err.message);
|
|
if (!res.headersSent) res.status(500).json({ error: 'Something went wrong' });
|
|
});
|
|
}
|
|
|
|
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.
|
|
for (const d of newlyClaimed) notifyOperatorOfClaim(req, { domain: d, 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;
|
|
if (domains !== undefined) {
|
|
try {
|
|
cleanDomains = normaliseDomains(domains);
|
|
assertDomainsFree(cleanDomains, req.orgId, existing.id);
|
|
} catch (e) {
|
|
return res.status(e.status || 400).json({ error: e.message });
|
|
}
|
|
}
|
|
|
|
const nextIssuer = issuer !== undefined ? String(issuer).trim().replace(/\/+$/, '') : existing.issuer;
|
|
if (nextIssuer !== existing.issuer) {
|
|
try { await oidc.discover(nextIssuer); }
|
|
catch (e) { return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${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. Both rows then hold it, and forEmail's
|
|
* `ORDER BY created_at` hands routing to the OLDER row — the attacker's.
|
|
*/
|
|
try {
|
|
db.transaction(() => {
|
|
if (domains !== undefined) 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 (domains !== undefined) 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' });
|
|
}
|
|
|
|
for (const d of newlyClaimed) notifyOperatorOfClaim(req, { domain: d, 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, 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 not checked at all — it is reissued. Checking first would let a squatter keep
|
|
* an expired claim alive indefinitely by leaving one record in place, which is exactly what the
|
|
* time limit exists to prevent, and the new token means the old record no longer matches.
|
|
*/
|
|
if (domainVerify.isClaimExpired(row)) {
|
|
const token = domainVerify.newToken();
|
|
db.prepare("UPDATE org_sso_domains SET token = ?, token_issued_at = strftime('%s','now'), last_error = NULL, last_checked_at = strftime('%s','now') WHERE id = ?")
|
|
.run(token, row.id);
|
|
return res.status(409).json({
|
|
ok: false,
|
|
domain: row.domain,
|
|
verified: false,
|
|
expired: true,
|
|
error: 'That verification expired, so a new record has been issued. Publish the new value and check again.',
|
|
...domainVerify.instructions(row.domain, token),
|
|
});
|
|
}
|
|
|
|
const result = await domainVerify.check(row.domain, row.token);
|
|
|
|
if (result.ok) {
|
|
db.prepare("UPDATE org_sso_domains SET verified_at = strftime('%s','now'), last_checked_at = strftime('%s','now'), last_error = NULL WHERE id = ?")
|
|
.run(row.id);
|
|
logActivity(req.user.id, 'org_sso_domain_verified', `${row.domain} via ${result.via} org=${req.orgId}`, null, getClientIp(req));
|
|
console.log(`[org-sso] ${row.domain} verified via ${result.via} for org ${req.orgId}`);
|
|
return res.json({ ok: true, domain: row.domain, verified: true, via: result.via });
|
|
}
|
|
|
|
db.prepare("UPDATE org_sso_domains SET last_checked_at = strftime('%s','now'), last_error = ? WHERE id = ?")
|
|
.run(result.error, row.id);
|
|
res.status(400).json({
|
|
ok: false,
|
|
domain: row.domain,
|
|
verified: false,
|
|
error: result.error,
|
|
...domainVerify.instructions(row.domain, row.token),
|
|
});
|
|
}));
|
|
|
|
router.delete('/:orgId/sso/:id', requireOrgAdmin, requireVerifiedAdmin, (req, res) => {
|
|
const existing = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId);
|
|
if (!existing) return res.status(404).json({ error: 'Not found' });
|
|
db.prepare('DELETE FROM org_sso_providers WHERE id = ?').run(existing.id);
|
|
logActivity(req.user.id, 'org_sso_deleted', `${existing.name} (${existing.slug}) org=${req.orgId}`, null, getClientIp(req));
|
|
res.json({ success: true });
|
|
});
|
|
|
|
module.exports = router;
|