SSO-only: an org may require its own identity provider, operator approves removal

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
This commit is contained in:
ScreenTinker 2026-08-10 22:19:40 -05:00
parent 481a44c15a
commit 0e8ffa5444
8 changed files with 478 additions and 6 deletions

View file

@ -152,6 +152,21 @@ export default {
'sso.disabled': 'disabled',
'sso.domains_label': 'Email domains',
'sso.domains_heading': 'Sign-in domains',
'sso.only_heading': 'Require single sign-on',
'sso.only_help': 'When required, people at your verified domains can only sign in through your identity provider — a password will not work. Your provider keeps control of MFA and of removing access.',
'sso.only_on': 'Single sign-on is required for your verified domains.',
'sso.only_off': 'Password sign-in is still allowed alongside single sign-on.',
'sso.only_enable': 'Require single sign-on',
'sso.only_confirm': 'Require single sign-on for everyone at your verified domains?\n\nPasswords will stop working for them immediately. Turning this back off needs approval from the people who run this server, so make sure your identity provider is working first.',
'sso.only_needs_domain': 'Verify a sign-in domain first — otherwise nobody would be able to sign in.',
'sso.only_remove_help': 'Turning this off re-opens password sign-in, so it needs approval from the people who run this server.',
'sso.only_request': 'Request to stop requiring single sign-on',
'sso.only_reason_prompt': 'Why do you need password sign-in re-opened? (optional, but it helps the reviewer)',
'sso.only_requested': 'Request sent. Single sign-on stays required until it is approved.',
'sso.only_pending': 'A request to stop requiring single sign-on is awaiting approval. Nothing changes until then.',
'sso.only_cancel': 'Withdraw request',
'sso.only_cancelled': 'Request withdrawn.',
'sso.only_failed': 'That did not work.',
'sso.domain_verified': 'verified',
'sso.domain_pending': 'not verified — routes nobody yet',
'sso.unverified_warning': 'Some domains are not verified yet, so nobody is routed to this provider by email address.',
@ -198,6 +213,7 @@ export default {
'auth.sso_err_server_error': 'Something went wrong completing sign-in.',
// Both of these used to fall through to "please try again", which is advice that can never work:
// retrying is exactly what will not help, and the user needs to be told who to talk to instead.
'auth.sso_required': 'Your organization requires single sign-on. Use \u201cContinue with single sign-on\u201d above \u2014 your password will not work here.',
'auth.sso_err_domain_not_allowed': 'Your organization has not verified that email domain for sign-in. Ask your administrator to verify it in ScreenTinker.',
'auth.sso_err_account_exists_other_provider': 'An account with this email already exists and signs in through a different provider. Use that provider, or ask your administrator.',
'auth.signin_microsoft': 'Sign in with Microsoft',

View file

@ -105,7 +105,7 @@ export async function render(container) {
<input type="email" id="loginEmail" class="input" placeholder="${t('auth.placeholder_email')}" autocomplete="email">
</div>
<div class="form-group">
<label>${t('auth.password')}</label>
<label id="loginPasswordLabel">${t('auth.password')}</label>
<!-- Filled in only when the typed email belongs to an organization that has configured
its own identity provider. A customer's IdP is never listed to everyone: the button
appears for the people it belongs to and nobody else, which also keeps the customer
@ -195,6 +195,9 @@ export async function render(container) {
third-party script origin. Google and Microsoft are ordinary entries in this list.
The icon is chosen by slug where we have one and falls back to a generic mark, so a
self-hoster's Keycloak or Authentik still gets a real-looking button. -->
<!-- Wrapped so the whole set can be hidden at once: an organization that REQUIRES its own
identity provider must not be shown the operator's, which are not domain-confined. -->
<div id="instanceProviders">
${(config.providers || []).map((p) => `
<a class="btn btn-secondary" href="/api/auth/oidc/${encodeURIComponent(p.slug)}/start"
id="sso-${esc(p.slug)}"
@ -204,6 +207,7 @@ export async function render(container) {
</a>
`).join('')}
</div>
</div>
</div>
<!-- Support Access (collapsible) -->
@ -306,6 +310,12 @@ function setupHandlers(config, isSetup) {
body: JSON.stringify({ email, password })
});
const data = await res.json();
/*
* The organization requires its identity provider, so this is not a credential failure and
* must not read like one "invalid password" sends the user to reset a password that will
* never work again. Point them at the control that does work.
*/
if (!res.ok && data.code === 'sso_required') { showError(t('auth.sso_required')); return; }
if (!res.ok) { showError(data.error); return; }
// Unverified account (hosted hard-gate): no session — prompt to check email.
if (data.verification_required) { showVerifyNotice(data.email || email); return; }
@ -497,13 +507,49 @@ function setupHandlers(config, isSetup) {
let lastDomainAsked = '';
const orgSlot = () => document.getElementById('orgSsoSlot');
/*
* Show or hide the password half of the sign-in form.
*
* Presentation only the server refuses a password for these accounts regardless. Restoring it
* on every negative answer matters as much as hiding it: someone who types an SSO-only address,
* then corrects it to their own, must get the password box back.
*/
function setPasswordVisible(visible) {
/*
* Hide the password FIELD, never its .form-group the organization SSO slot lives inside
* that same group, so hiding the container took the single sign-on button down with it and left
* a login page whose only action was "Create Account". Found by looking at a screenshot.
*/
const show = visible ? '' : 'none';
for (const id of ['loginPassword', 'loginPasswordLabel', 'loginBtn']) {
const el = document.getElementById(id);
if (el) el.style.display = show;
}
/*
* The instance's own providers go too. They are the operator's, not this organization's, and
* they are not domain-confined so offering "Continue with Google" to someone whose company
* requires its own identity provider is offering them the bypass. The server refuses it either
* way; this stops the page inviting it.
*/
const instance = document.getElementById('instanceProviders');
if (instance) instance.style.display = show;
// "Forgot your password?" sits in its own <p>; hide the wrapper so no empty gap is left.
const forgot = document.getElementById('forgotLink');
if (forgot) {
const wrap = forgot.parentElement && forgot.parentElement.tagName === 'P' ? forgot.parentElement : forgot;
wrap.style.display = show;
}
}
async function lookupOrgSso(email) {
const at = String(email || '').lastIndexOf('@');
const domain = at === -1 ? '' : email.slice(at + 1).trim().toLowerCase();
const slot = orgSlot();
if (!slot) return;
// Nothing to ask about until there is a domain with a dot in it.
if (!domain || !domain.includes('.')) { slot.style.display = 'none'; slot.innerHTML = ''; lastDomainAsked = ''; return; }
if (!domain || !domain.includes('.')) {
slot.style.display = 'none'; slot.innerHTML = ''; lastDomainAsked = ''; setPasswordVisible(true); return;
}
if (domain === lastDomainAsked) return;
try {
const res = await fetch(`/api/auth/sso/discover?email=${encodeURIComponent(email)}`);
@ -511,7 +557,14 @@ function setupHandlers(config, isSetup) {
// Remembered only after a SUCCESSFUL answer. Recording it before the fetch meant a 5xx or a
// tripped rate limit poisoned that domain for the rest of the page's life.
lastDomainAsked = domain;
if (!data.sso) { slot.style.display = 'none'; slot.innerHTML = ''; return; }
if (!data.sso) { slot.style.display = 'none'; slot.innerHTML = ''; setPasswordVisible(true); return; }
/*
* When the organization REQUIRES its identity provider, the password box is not merely going
* to fail it is the wrong thing to offer. Showing it invites someone to type a password,
* be refused, and go and reset a password that will never work again. Hidden, not disabled,
* so there is one obvious way forward.
*/
setPasswordVisible(!data.required);
/*
* A FORM, not a link, and a deliberately generic label.
*
@ -532,9 +585,11 @@ function setupHandlers(config, isSetup) {
</div>`;
slot.style.display = '';
} catch {
// A failed lookup must never block a password login — the form still works.
// A failed lookup must never block a password login — the form still works, and the password
// box comes back rather than leaving someone staring at a form with no way to submit it.
slot.style.display = 'none';
slot.innerHTML = '';
setPasswordVisible(true);
}
}

View file

@ -693,6 +693,16 @@ export async function render(container) {
return;
}
// Requiring SSO is a separate decision from having it, so it gets its own block rather than
// hiding inside a provider — an organization may have several providers and one answer.
let onlyState = null;
try {
const r = await fetch(`/api/organizations/${orgId}/sso-only`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
if (r.ok) onlyState = await r.json();
} catch { /* the providers still render; the toggle simply does not appear */ }
const origin = `${window.location.protocol}//${window.location.host}`;
listEl.innerHTML = providers.map((p) => `
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px">
@ -793,6 +803,61 @@ export async function render(container) {
* edited DNS and wants an answer, and a failure has to say WHICH failure not published yet,
* published wrong, or the claim expired and the record has changed underneath them.
*/
if (onlyState) {
const pend = onlyState.pending_removal_request;
const box = document.createElement('div');
box.style.cssText = 'border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-top:4px';
box.innerHTML = `
<div style="font-weight:600;margin-bottom:4px">${esc(t('sso.only_heading'))}</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">${esc(t('sso.only_help'))}</div>
${onlyState.sso_only ? `
<div style="font-size:13px;margin-bottom:8px"> ${esc(t('sso.only_on'))}</div>
${pend
? `<div style="font-size:12px;color:var(--warning,#b45309)">⏳ ${esc(t('sso.only_pending'))}</div>
<button class="btn btn-secondary btn-sm" id="ssoOnlyCancel" data-req="${esc(pend.id)}" style="margin-top:6px">${esc(t('sso.only_cancel'))}</button>`
: `<div style="font-size:12px;color:var(--text-muted);margin-bottom:6px">${esc(t('sso.only_remove_help'))}</div>
<button class="btn btn-secondary btn-sm" id="ssoOnlyRequest">${esc(t('sso.only_request'))}</button>`}
` : `
<div style="font-size:13px;margin-bottom:8px">${esc(t('sso.only_off'))}</div>
${onlyState.verified_domains
? `<button class="btn btn-secondary btn-sm" id="ssoOnlyEnable">${esc(t('sso.only_enable'))}</button>`
: `<div style="font-size:12px;color:var(--warning,#b45309)">⚠️ ${esc(t('sso.only_needs_domain'))}</div>`}
`}`;
listEl.appendChild(box);
const post = async (url, body, method = 'POST') => {
const r = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: body ? JSON.stringify(body) : undefined,
});
const j = await r.json().catch(() => ({}));
if (!r.ok) { showToast(j.error || t('sso.only_failed'), 'error'); return null; }
return j;
};
const enableBtn = box.querySelector('#ssoOnlyEnable');
if (enableBtn) enableBtn.addEventListener('click', async () => {
// Confirmed, because it removes the only way in for everyone at these domains, and the way
// back needs the operator rather than this button.
if (!window.confirm(t('sso.only_confirm'))) return;
if (await post(`/api/organizations/${orgId}/sso-only`)) { showToast(t('sso.only_on'), 'success'); await loadSso(); }
});
const reqBtn = box.querySelector('#ssoOnlyRequest');
if (reqBtn) reqBtn.addEventListener('click', async () => {
const reason = window.prompt(t('sso.only_reason_prompt')) || '';
const r = await post(`/api/organizations/${orgId}/sso-only/removal-request`, { reason });
if (r) { showToast(t('sso.only_requested'), 'success'); await loadSso(); }
});
const cancelBtn = box.querySelector('#ssoOnlyCancel');
if (cancelBtn) cancelBtn.addEventListener('click', async () => {
const r = await post(`/api/organizations/${orgId}/sso-only/removal-request/${cancelBtn.dataset.req}`, null, 'DELETE');
if (r) { showToast(t('sso.only_cancelled'), 'success'); await loadSso(); }
});
}
listEl.querySelectorAll('[data-sso-verify]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.ssoVerify;

View file

@ -470,6 +470,32 @@ const migrations = [
)`,
"CREATE INDEX IF NOT EXISTS idx_org_sso_domains_org ON org_sso_domains(organization_id)",
"CREATE INDEX IF NOT EXISTS idx_org_sso_domains_provider ON org_sso_domains(provider_id)",
/*
* SSO-ONLY: an organization may require its people to use its identity provider, so a password
* is no longer an alternative way in. That is the point of buying SSO the IdP holds the MFA,
* the conditional access and the instant deprovisioning, and a password box beside it is a way
* around all three.
*
* Asymmetric on purpose. Turning it ON is the safe direction and an org admin does it alone.
* Turning it OFF is how a compromised admin would re-open password login, and it is also what
* an org will demand at its worst moment IdP down, nobody can work which is exactly when a
* self-service switch gets flipped under pressure. So removal goes through the operator: the
* request is recorded here and a platform admin has to approve it.
*/
"ALTER TABLE organizations ADD COLUMN sso_only INTEGER NOT NULL DEFAULT 0",
`CREATE TABLE IF NOT EXISTS org_sso_only_requests (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
requested_by TEXT,
reason TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | rejected | cancelled
decided_by TEXT,
decided_at INTEGER,
decision_note TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
)`,
"CREATE INDEX IF NOT EXISTS idx_sso_only_req_status ON org_sso_only_requests(status, organization_id)",
"ALTER TABLE device_telemetry ADD COLUMN attached_display TEXT",
"ALTER TABLE device_telemetry ADD COLUMN video_mode TEXT",
// Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because

View file

@ -298,4 +298,34 @@ function forEmail(email) {
return null;
}
module.exports = { list, get, publicList, getOrgProvider, ownerOf, forEmail, DEFAULT_SCOPES, SLUG_RE };
/**
* Is this address inside an organization that REQUIRES its identity provider?
*
* Only a VERIFIED domain can compel anyone: an org must not be able to switch off password login
* for a domain it merely typed, which would be a denial-of-service against a company it has nothing
* to do with. Enabled providers only, for the same reason a disabled provider routes nobody.
*/
function ssoOnlyForEmail(email) {
const conn = db();
if (!conn) return null;
const at = String(email || '').lastIndexOf('@');
if (at === -1) return null;
const domain = String(email).slice(at + 1).toLowerCase().trim();
if (!domain) return null;
try {
return conn.prepare(`
SELECT o.id AS organization_id, o.name AS organization_name, p.slug
FROM org_sso_domains d
JOIN org_sso_providers p ON p.id = d.provider_id
JOIN organizations o ON o.id = d.organization_id
WHERE d.domain = ? AND d.verified_at IS NOT NULL AND p.enabled = 1 AND o.sso_only = 1
`).get(domain) || null;
} catch (e) {
if (/no such table|no such column/i.test(e.message)) return null;
throw e;
}
}
module.exports = {
list, get, publicList, getOrgProvider, ownerOf, forEmail, ssoOnlyForEmail, DEFAULT_SCOPES, SLUG_RE,
};

View file

@ -172,6 +172,33 @@ router.post('/login', (req, res) => {
return res.status(401).json({ error: 'Invalid email or password' });
}
/*
* SSO-ONLY. The organization that owns this VERIFIED domain requires its identity provider, so a
* password is not an alternative way in otherwise the MFA, conditional access and instant
* deprovisioning the customer bought are all reachable around.
*
* platform_admin is exempt, and that exemption is load-bearing rather than a convenience. The
* operator is the one who approves turning this OFF. If the operator's own address sits at an
* SSO-only domain and that identity provider breaks, nobody can sign in to approve anything and
* the instance is bricked with no path out. The exemption is the break-glass; it applies to the
* people who run the server, never to a customer's own admins.
*
* Said plainly rather than as "invalid email or password": this is not a credential failure and
* pretending otherwise sends the user to reset a password that will never work. The domain
* already answered `sso: true` publicly, so naming it reveals nothing new.
*/
if (user.role !== 'platform_admin') {
const enforced = oidcProviders.ssoOnlyForEmail(user.email);
if (enforced) {
logFailedLogin(email, getClientIp(req), 'Password login refused: organization requires SSO');
return res.status(403).json({
error: 'Your organization requires single sign-on. Use the single sign-on button to continue.',
code: 'sso_required',
sso_start: '/api/auth/sso/start',
});
}
}
// Per-ACCOUNT brute-force lockout (lib/login-lockout), on top of the per-IP limiter in
// server.js. Checked BEFORE bcrypt so a locked account costs no hashing work.
//
@ -1051,7 +1078,20 @@ router.get('/providers', (req, res) => {
* walked to enumerate users.
*/
router.get('/sso/discover', (req, res) => {
res.json({ sso: !!oidcProviders.forEmail(req.query.email) });
const provider = oidcProviders.forEmail(req.query.email);
/*
* `required` says the organization has turned off password sign-in for this domain, so the login
* page can hide the password box instead of letting someone type a password that is going to be
* refused. It is only ever present when `sso` is already true, so it tells an outsider nothing
* they could not learn by asking the same question one field earlier.
*
* Presentation only. The refusal is enforced in POST /login a hidden field is a courtesy,
* not a control, and anyone can post the form directly.
*/
res.json({
sso: !!provider,
required: provider ? !!oidcProviders.ssoOnlyForEmail(req.query.email) : false,
});
});
/*
@ -1191,6 +1231,22 @@ router.get('/oidc/:slug/callback', asyncRoute(async (req, res) => {
* The domains are the VERIFIED ones proved by a DNS record published in the domain itself so
* this is confinement to what the tenant demonstrably controls, not to what they typed.
*/
/*
* SSO-ONLY applies to EVERY route in, not just the password box.
*
* Confinement stops an org provider speaking for domains it does not own. This is the mirror
* image: when an organization requires its identity provider, no OTHER provider may speak for
* its people either including the instance's own Google or Microsoft, which are not
* domain-confined and would otherwise be an open side door around the MFA and deprovisioning the
* customer turned this on for. Blocking passwords while leaving "Continue with Google" is not
* requiring single sign-on; it is renaming the bypass.
*/
const enforcedOrg = oidcProviders.ssoOnlyForEmail(email);
if (enforcedOrg && enforcedOrg.slug !== provider.slug) {
console.warn(`[oidc] ${provider.slug} asserted ${email}, but that organization requires ${enforcedOrg.slug}`);
return backToApp(res, { sso_error: 'sso_required' });
}
if (!emailAllowedForProvider(provider, email)) {
console.warn(`[oidc] ${provider.slug} asserted ${email}, outside its verified domains [${provider.emailDomains}]`);
return backToApp(res, { sso_error: 'domain_not_allowed' });

View file

@ -273,6 +273,49 @@ function notifyOperatorOfClaim(req, { domains, orgId, providerName }) {
}
}
/*
* 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)
@ -596,6 +639,133 @@ router.post('/:orgId/sso/:id/domains/:domain/verify', requireOrgAdmin, requireVe
});
}));
/*
* 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' });

View file

@ -294,6 +294,7 @@ function orgDb() {
scopes TEXT NOT NULL DEFAULT 'openid email profile', email_domains TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0);
CREATE TABLE organizations (id TEXT PRIMARY KEY, name TEXT, sso_only INTEGER NOT NULL DEFAULT 0);
CREATE TABLE org_sso_domains (
id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, provider_id TEXT, domain TEXT NOT NULL UNIQUE,
token TEXT NOT NULL, token_issued_at INTEGER NOT NULL DEFAULT 0, verified_at INTEGER,
@ -311,6 +312,8 @@ function withOrgDb(rows, fn) {
const d = orgDb();
let n = 0;
for (const r of rows) {
d.prepare('INSERT OR IGNORE INTO organizations (id, name, sso_only) VALUES (?, ?, ?)')
.run(r.org, r.org, r.ssoOnly ? 1 : 0);
const typed = [...(r.domains || '').split(','), ...(r.pending || '').split(',')].filter(Boolean).join(',');
d.prepare(`INSERT INTO org_sso_providers (id, organization_id, slug, name, issuer, client_id, email_domains, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
@ -718,3 +721,54 @@ test('a user object never leaves the server carrying a reset or verify hash', ()
// And nothing may hand-roll the old partial strip again.
assert.ok(!/totp_last_step,\s*\.\.\.safeUser/.test(src), 'use publicUser(), not an inline destructure');
});
// ---------------------------------------------------------------------------------------------
// SSO-only: an organization requiring its own identity provider.
test('SSO-ONLY applies to a VERIFIED domain', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.test', ssoOnly: true }], (m) => {
const hit = m.ssoOnlyForEmail('staff@acme.test');
assert.ok(hit, 'password login must be refused for this address');
assert.equal(hit.organization_id, 'org-a');
assert.equal(m.ssoOnlyForEmail('staff@ACME.TEST').organization_id, 'org-a', 'case-insensitive');
assert.equal(m.ssoOnlyForEmail('someone@elsewhere.test'), null, 'and nobody else is affected');
});
});
test('SSO-ONLY CANNOT be imposed through a domain that was only claimed', () => {
/*
* The dangerous shape: switching off password login for a domain the tenant never proved would
* be a denial-of-service against a company they have nothing to do with every account at that
* address locked out of a product the squatter does not own.
*/
withOrgDb([{ id: '1', org: 'org-x', slug: 'orgxxx', name: 'Squatter', pending: 'victim-corp.test', ssoOnly: true }], (m) => {
assert.equal(m.ssoOnlyForEmail('ceo@victim-corp.test'), null, 'an unproved domain compels nobody');
});
});
test('SSO-ONLY stops applying when the provider is disabled', () => {
// Otherwise disabling a broken provider would leave its users with no way in at all: no SSO
// (disabled) and no password (still enforced).
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.test', enabled: 0, ssoOnly: true }], (m) => {
assert.equal(m.ssoOnlyForEmail('staff@acme.test'), null);
});
});
test('SSO-ONLY is off unless the organization turned it on', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.test' }], (m) => {
assert.equal(m.ssoOnlyForEmail('staff@acme.test'), null, 'having SSO is not the same as requiring it');
});
});
test('the login gate exempts platform_admin, and that exemption is deliberate', () => {
/*
* The operator approves turning SSO-only OFF. If the operator's own address sat at an SSO-only
* domain and that identity provider broke, nobody could sign in to approve anything and the
* instance would be bricked. Pinned as source because it is a security-relevant exemption that
* must not be "tidied away" by someone who reads it as a convenience.
*/
const src = fs.readFileSync(require.resolve('../routes/auth.js'), 'utf8');
assert.match(src, /user\.role !== 'platform_admin'[\s\S]{0,200}ssoOnlyForEmail/,
'the break-glass exemption must guard the ssoOnlyForEmail check');
assert.match(src, /code: 'sso_required'/, 'and the refusal must be distinguishable from a bad password');
});