diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index f16f6d6..d55a4b9 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -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',
diff --git a/frontend/js/views/login.js b/frontend/js/views/login.js
index 0975205..11768b6 100644
--- a/frontend/js/views/login.js
+++ b/frontend/js/views/login.js
@@ -105,7 +105,7 @@ export async function render(container) {
@@ -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
; 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) {
`;
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);
}
}
diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js
index b552cee..d08da70 100644
--- a/frontend/js/views/settings.js
+++ b/frontend/js/views/settings.js
@@ -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) => `
@@ -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 = `
+
${esc(t('sso.only_heading'))}
+
${esc(t('sso.only_help'))}
+ ${onlyState.sso_only ? `
+
✅ ${esc(t('sso.only_on'))}
+ ${pend
+ ? `
⏳ ${esc(t('sso.only_pending'))}
+ `
+ : `
${esc(t('sso.only_remove_help'))}
+ `}
+ ` : `
+
${esc(t('sso.only_off'))}
+ ${onlyState.verified_domains
+ ? ``
+ : `
⚠️ ${esc(t('sso.only_needs_domain'))}
`}
+ `}`;
+ 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;
diff --git a/server/db/database.js b/server/db/database.js
index ffe7b81..1c6ff1c 100644
--- a/server/db/database.js
+++ b/server/db/database.js
@@ -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
diff --git a/server/lib/oidc-providers.js b/server/lib/oidc-providers.js
index bffebb7..a28a598 100644
--- a/server/lib/oidc-providers.js
+++ b/server/lib/oidc-providers.js
@@ -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,
+};
diff --git a/server/routes/auth.js b/server/routes/auth.js
index 854c05c..8bf3693 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -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' });
diff --git a/server/routes/org-sso.js b/server/routes/org-sso.js
index 9d1e92e..f6a71b7 100644
--- a/server/routes/org-sso.js
+++ b/server/routes/org-sso.js
@@ -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' });
diff --git a/server/test/oidc-sso.test.js b/server/test/oidc-sso.test.js
index cde29d5..dd1fe04 100644
--- a/server/test/oidc-sso.test.js
+++ b/server/test/oidc-sso.test.js
@@ -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');
+});