SSO: per-organization providers, configured by the customer

Instance-wide providers belong to whoever runs the server. These belong to a
CUSTOMER: an organization points ScreenTinker at its own identity provider from
Settings → Single sign-on, with no environment variable and no restart.

The login flow is unchanged. An org provider is resolved through the same
oidc-providers.get(slug) the env ones go through, so there is one authorization
request builder, one token exchange and one verifier — not a second, less
tested path for tenants. That seam is why Phase 1 put provider lookup behind a
single function.

⚠️ An org provider is NEVER published. It is not in /api/auth/providers, because
listing a customer's IdP would both offer it to people it does not belong to and
leak the customer list from the login page. It surfaces only when someone types
an address at one of that organization's domains; otherwise the instance-wide
buttons are what you get.

The discovery endpoint answers with a BOOLEAN and nothing else — no slug, no
display name. Returning "yes, Acme Corp SSO" would turn a guessed domain into
confirmation that Acme buys this product, and the slug would hand out a working
entry point to their tenant. POST /sso/start repeats the lookup server-side and
redirects, so the browser never learns which provider it is being sent to until
the provider says so, and the address travels in a body rather than in a URL
that lands in history, proxy logs and a Referer. Both endpoints rate limited to
10/min.

Other properties, each with a test:
  - slugs are RANDOM, not chosen, so two customers cannot collide on or guess
    each other's URL
  - a domain may be claimed by ONE organization; a second claim is refused, so a
    tenant cannot capture another company's logins
  - the issuer is verified by live discovery BEFORE the row is written, so a
    typo is caught at configuration rather than by a user staring at a failed
    login
  - client secrets are optional (PKCE), stored AES-256-GCM via lib/secretbox,
    never returned; an absent secret on update leaves the stored one alone,
    which is how a settings form that cannot show it avoids blanking it
  - cross-org access answers 404, not 403, so an outsider cannot confirm that an
    organization id exists
  - signing in through an org provider grants membership of that organization,
    but never changes an existing member's role

Verified live end to end: creation against a real issuer, domain normalisation
(`@Acme.CO.UK` → `acme.co.uk`), boolean-only discovery, a rejected domain
squat, a rejected bad issuer, and 404 for a foreign organization.

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 17:33:32 -05:00
parent 252854d31e
commit e97228a502
10 changed files with 751 additions and 3 deletions

View file

@ -374,6 +374,35 @@ refused rather than handing over an account to a recycled mailbox.
⚠️ **TOTP is not prompted on an SSO login.** Second-factor is the identity provider's job in this
flow, matching the long-standing behaviour of the SSO and API-token paths.
#### Per-organization SSO (customer-configured)
The providers above are **instance-wide** — they belong to whoever runs the server and appear as
buttons on the login page for everyone.
An organization can also bring **its own** identity provider, configured by an org owner or admin in
**Settings → Single sign-on**. No environment variable or restart is involved.
A per-org provider is **never listed publicly**. It appears only when someone types an email address
at one of that organization's domains, at which point the login page offers a generic
"Continue with single sign-on" button. The domain lookup answers with a boolean and nothing else —
no provider name, no slug — so a guessed domain cannot confirm who a customer is, and the mapping
back to a provider happens server-side on submit. Both endpoints are rate limited.
Each provider gets a randomly generated redirect URI, shown in Settings, which the admin registers
with their identity provider:
```
https://yourdomain.com/api/auth/oidc/<generated-slug>/callback
```
The slug is generated rather than chosen so two customers cannot collide on — or guess — each
other's. A domain may be claimed by only one organization; a second claim is refused.
Signing in through an organization's provider makes the user a member of that organization
(`org_member`). Existing members keep whatever role they already have — logging in never promotes or
demotes anyone. Client secrets are optional (PKCE), and are stored AES-256-GCM encrypted and never
returned by the API.
#### Email (Microsoft Graph or SMTP)
Email powers offline alerts, welcome/signup mail, admin notifications, and password reset. Two interchangeable transports are supported, selected by `EMAIL_TRANSPORT`:

View file

@ -134,6 +134,32 @@ export default {
'auth.signin_google': 'Sign in with Google',
'auth.signin_with': 'Continue with {provider}',
'auth.sso_failed': 'Single sign-on failed. Please try again.',
'auth.sso_org_hint': 'Your organization uses single sign-on.',
'auth.signin_sso': 'Continue with single sign-on',
'sso.title': 'Single sign-on',
'sso.blurb': 'Let your team sign in with your own identity provider. Anyone using an email address at one of your domains will be sent there instead of being asked for a password.',
'sso.add': 'Add a provider',
'sso.none': 'No provider configured yet.',
'sso.create': 'Add provider',
'sso.saved': 'Saved',
'sso.save_failed': 'Could not save that provider.',
'sso.load_failed': 'Could not load single sign-on settings.',
'sso.missing_fields': 'Name, issuer and client ID are required.',
'sso.confirm_delete': 'Remove this provider? Anyone who signs in with it will lose that route.',
'sso.delete': 'Remove',
'sso.enable': 'Enable',
'sso.disable': 'Disable',
'sso.disabled': 'disabled',
'sso.domains_label': 'Email domains',
'sso.callback_label': 'Redirect URI — add this to your provider',
'sso.f_name': 'Display name',
'sso.f_issuer': 'Issuer URL',
'sso.f_issuer_hint': 'The base URL whose /.well-known/openid-configuration describes your provider. We check it before saving.',
'sso.f_client_id': 'Client ID',
'sso.f_client_secret': 'Client secret (optional)',
'sso.f_client_secret_hint': 'Leave blank for a public client — we use PKCE, so a secret is not required. Stored encrypted and never shown again.',
'sso.f_domains': 'Email domains',
'sso.f_domains_hint': 'Comma separated. Anyone with an address at these domains is sent to this provider.',
'auth.sso_err_expired': 'That sign-in took too long. Please try again.',
'auth.sso_err_bad_state': 'Sign-in could not be verified. Please start again.',
'auth.sso_err_no_code': 'The provider did not return an authorization code.',

View file

@ -105,6 +105,11 @@ export async function render(container) {
</div>
<div class="form-group">
<label>${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
list off the login page. -->
<div id="orgSsoSlot" style="display:none;margin-bottom:12px"></div>
<input type="password" id="loginPassword" class="input" placeholder="${t('auth.placeholder_password')}" autocomplete="current-password">
</div>
${isSetup ? `
@ -478,6 +483,64 @@ function setupHandlers(config, isSetup) {
* log and it is stripped from the address bar before anything else happens, so a shared screen
* or a copied URL does not carry a live session.
*/
/*
* Email-first SSO for organizations.
*
* Instance-wide providers are always on the page. An ORG provider is different it belongs to
* one customer so it is fetched by domain once the address looks complete, and only then.
*
* Debounced because this fires while someone types, and the endpoint is rate limited; asking on
* every keystroke would spend a user's whole budget before they finished their own address.
*/
let ssoLookupTimer = null;
let lastDomainAsked = '';
const orgSlot = () => document.getElementById('orgSsoSlot');
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 === lastDomainAsked) return;
lastDomainAsked = domain;
try {
const res = await fetch(`/api/auth/sso/discover?email=${encodeURIComponent(email)}`);
const data = await res.json();
if (!data.sso) { slot.style.display = 'none'; slot.innerHTML = ''; return; }
/*
* A FORM, not a link, and a deliberately generic label.
*
* The lookup tells us only that this domain uses SSO never which provider or whose it is,
* because that would identify a customer to anyone who guessed a domain. The server does the
* mapping again on submit, so the slug is never published to the page. POST keeps the address
* out of the URL, browser history and any Referer the provider's page would send.
*/
slot.innerHTML = `
<form method="POST" action="/api/auth/sso/start">
<input type="hidden" name="email" value="${esc(email)}">
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;padding:10px">
${t('auth.signin_sso')}
</button>
</form>
<div style="font-size:11px;color:var(--text-muted);margin-top:6px;text-align:center">
${t('auth.sso_org_hint')}
</div>`;
slot.style.display = '';
} catch {
// A failed lookup must never block a password login — the form still works.
slot.style.display = 'none';
slot.innerHTML = '';
}
}
document.getElementById('loginEmail')?.addEventListener('input', (e) => {
clearTimeout(ssoLookupTimer);
const value = e.target.value;
ssoLookupTimer = setTimeout(() => lookupOrgSso(value), 400);
});
const ssoParams = new URLSearchParams((window.location.hash.split('?')[1] || ''));
const ssoToken = ssoParams.get('sso_token');
const ssoError = ssoParams.get('sso_error');

View file

@ -66,6 +66,35 @@ export async function render(container) {
</div>
</div>
<!-- Per-organization SSO. Hidden unless the signed-in user administers an organization: this
is the most security-relevant setting a tenant has, so it is not shown to members who
cannot change it. Instance-wide providers are the operator's business and are configured
by environment, not here. -->
<div class="settings-section" id="ssoCard" style="display:none">
<h3>${t('sso.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('sso.blurb')}</p>
<div id="ssoList"></div>
<details id="ssoAddDetails" style="margin-top:12px">
<summary style="cursor:pointer;font-size:13px">${t('sso.add')}</summary>
<div style="margin-top:12px;display:grid;gap:10px;max-width:560px">
<div class="form-group"><label>${t('sso.f_name')}</label>
<input type="text" id="ssoName" class="input" placeholder="Acme SSO"></div>
<div class="form-group"><label>${t('sso.f_issuer')}</label>
<input type="url" id="ssoIssuer" class="input" placeholder="https://login.example.com">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_issuer_hint')}</div></div>
<div class="form-group"><label>${t('sso.f_client_id')}</label>
<input type="text" id="ssoClientId" class="input"></div>
<div class="form-group"><label>${t('sso.f_client_secret')}</label>
<input type="password" id="ssoClientSecret" class="input" autocomplete="new-password">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_client_secret_hint')}</div></div>
<div class="form-group"><label>${t('sso.f_domains')}</label>
<input type="text" id="ssoDomains" class="input" placeholder="acme.com, acme.co.uk">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('sso.f_domains_hint')}</div></div>
<div><button class="btn btn-primary btn-sm" id="ssoCreateBtn">${t('sso.create')}</button></div>
</div>
</details>
</div>
<div class="settings-section">
<h3>${t('apitoken.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.desc')}</p>
@ -634,6 +663,119 @@ export async function render(container) {
}
});
/* Per-organization SSO
*
* Only an org owner/admin sees this. The server enforces the same rule (and answers 404, not
* 403, so an outsider learns nothing) this just avoids showing a card the user cannot use.
*/
const orgId = user.current_organization?.id;
const canManageSso = orgId && ['org_owner', 'org_admin'].includes(user.current_org_role);
async function loadSso() {
const card = document.getElementById('ssoCard');
if (!card || !canManageSso) return;
card.style.display = '';
const listEl = document.getElementById('ssoList');
let providers = [];
try {
const res = await fetch(`/api/organizations/${orgId}/sso`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
if (!res.ok) throw new Error('load failed');
providers = (await res.json()).providers || [];
} catch {
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${esc(t('sso.load_failed'))}</p>`;
return;
}
if (!providers.length) {
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${esc(t('sso.none'))}</p>`;
return;
}
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">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
<div>
<strong>${esc(p.name)}</strong>
${p.enabled ? '' : `<span style="font-size:11px;color:var(--text-muted)"> — ${esc(t('sso.disabled'))}</span>`}
<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(p.issuer)}</div>
<div style="font-size:12px;color:var(--text-muted)">${esc(t('sso.domains_label'))}: ${esc(p.email_domains || '—')}</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn btn-secondary btn-sm" data-sso-toggle="${esc(p.id)}" data-enabled="${p.enabled ? '1' : '0'}">
${esc(p.enabled ? t('sso.disable') : t('sso.enable'))}
</button>
<button class="btn btn-danger btn-sm" data-sso-delete="${esc(p.id)}">${esc(t('sso.delete'))}</button>
</div>
</div>
<!-- The admin has to paste this into their identity provider, and it must match character
for character, so it is shown rather than described. -->
<div style="margin-top:8px;font-size:12px">
<div style="color:var(--text-muted)">${esc(t('sso.callback_label'))}</div>
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px">${esc(origin + p.callback_url)}</code>
</div>
</div>`).join('');
listEl.querySelectorAll('[data-sso-toggle]').forEach((btn) => {
btn.addEventListener('click', async () => {
await ssoRequest('PUT', `/${btn.dataset.ssoToggle}`, { enabled: btn.dataset.enabled !== '1' });
});
});
listEl.querySelectorAll('[data-sso-delete]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm(t('sso.confirm_delete'))) return;
await ssoRequest('DELETE', `/${btn.dataset.ssoDelete}`);
});
});
}
async function ssoRequest(method, path = '', body) {
try {
const res = await fetch(`/api/organizations/${orgId}/sso${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
// The server's message is the useful one here — a bad issuer or a domain already claimed by
// another organization both say exactly what went wrong, and a generic failure would not.
if (!res.ok) { showToast(data.error || t('sso.save_failed'), 'error'); return false; }
showToast(t('sso.saved'), 'success');
await loadSso();
return true;
} catch {
showToast(t('sso.save_failed'), 'error');
return false;
}
}
document.getElementById('ssoCreateBtn')?.addEventListener('click', async () => {
const payload = {
name: document.getElementById('ssoName').value.trim(),
issuer: document.getElementById('ssoIssuer').value.trim(),
client_id: document.getElementById('ssoClientId').value.trim(),
client_secret: document.getElementById('ssoClientSecret').value,
email_domains: document.getElementById('ssoDomains').value.trim(),
};
if (!payload.name || !payload.issuer || !payload.client_id) {
showToast(t('sso.missing_fields'), 'error');
return;
}
if (await ssoRequest('POST', '', payload)) {
['ssoName', 'ssoIssuer', 'ssoClientId', 'ssoClientSecret', 'ssoDomains']
.forEach((id) => { document.getElementById(id).value = ''; });
document.getElementById('ssoAddDetails').open = false;
}
});
loadSso();
document.getElementById('createTokenBtn')?.addEventListener('click', async () => {
const name = document.getElementById('tokName').value.trim();
const scope = document.getElementById('tokScope').value;

View file

@ -396,6 +396,40 @@ const migrations = [
// Per-telemetry-row rather than on `devices` because a display can be swapped, unplugged or
// renegotiated without the player re-registering, and because a dual-output player registers ONE
// ROW PER OUTPUT (see output_index) — each row must carry its own screen, not the box's first.
/*
* Per-organization SSO.
*
* Instance-wide providers come from the environment and belong to whoever runs the server. These
* belong to a CUSTOMER: an organization brings its own identity provider, and its people sign in
* with it without the operator touching a config file.
*
* `slug` is globally unique and randomly generated rather than chosen, because it is a URL path
* segment (/api/auth/oidc/<slug>/start) and two organizations both wanting "okta" must not be
* able to collide or to guess each other's. The admin only ever sees `name`.
*
* `client_secret_enc` is AES-256-GCM via lib/secretbox, the same at-rest treatment as TOTP
* secrets and BYOK AI keys. PKCE means a secret is optional, so a public client stores NULL.
*
* `email_domains` drives routing: a user typing name@customer.com is sent to that customer's
* provider instead of being shown a password box. It is a plain comma list because it is small,
* edited as a unit, and never joined against.
*/
`CREATE TABLE IF NOT EXISTS org_sso_providers (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
issuer TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret_enc TEXT,
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 (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
)`,
"CREATE INDEX IF NOT EXISTS idx_org_sso_org ON org_sso_providers(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

@ -104,7 +104,11 @@ function list(env = process.env) {
/** One provider by slug, or null. This is the seam per-org SSO will extend. */
function get(slug, env = process.env) {
if (!slug || !SLUG_RE.test(String(slug))) return null;
return list(env).find((p) => p.slug === slug) || null;
const fromEnvList = list(env).find((p) => p.slug === slug);
if (fromEnvList) return fromEnvList;
// Instance providers win a name clash, which cannot happen in practice (org slugs are random)
// but decides it deterministically if it ever did.
return getOrgProvider(slug);
}
/**
@ -116,4 +120,73 @@ function publicList(env = process.env) {
return list(env).map((p) => ({ slug: p.slug, name: p.name }));
}
module.exports = { list, get, publicList, DEFAULT_SCOPES, SLUG_RE };
/*
* Per-organization providers.
*
* Loaded lazily so this module stays usable (and testable) without a database the env-only paths
* above never touch it. An org provider is an ordinary provider once loaded: the login flow cannot
* tell the difference, which is the whole point of resolving everything through get().
*/
let _db = null;
function db() {
if (_db === null) {
try { _db = require('../db/database').db; } catch { _db = false; }
}
return _db || null;
}
function rowToProvider(row, secretbox) {
return {
slug: row.slug,
name: row.name,
issuer: String(row.issuer).replace(/\/+$/, ''),
clientId: row.client_id,
clientSecret: row.client_secret_enc ? secretbox.decrypt(row.client_secret_enc) : null,
scopes: row.scopes || DEFAULT_SCOPES,
source: 'org',
organizationId: row.organization_id,
};
}
/** One org provider by its (globally unique) slug, or null. */
function getOrgProvider(slug) {
const conn = db();
if (!conn || !slug || !SLUG_RE.test(String(slug))) return null;
try {
const row = conn.prepare('SELECT * FROM org_sso_providers WHERE slug = ? AND enabled = 1').get(String(slug));
if (!row) return null;
return rowToProvider(row, require('./secretbox'));
} catch { return null; } // table not migrated yet
}
/**
* Which provider, if any, owns an email address.
*
* Domain routing is what makes per-org SSO usable: a customer's staff type their work address and
* are sent to their own identity provider rather than being asked for a password they do not have.
*
* Matched on the domain ONLY, never on whether the address exists. Answering "yes, that domain
* uses SSO" tells an attacker nothing they could not learn from the customer's website; answering
* "yes, that USER exists" would be an account-enumeration oracle on the login page.
*/
function forEmail(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 {
const rows = conn.prepare("SELECT * FROM org_sso_providers WHERE enabled = 1 AND email_domains != ''").all();
const secretbox = require('./secretbox');
for (const row of rows) {
const domains = String(row.email_domains || '').split(',').map((d) => d.trim().toLowerCase()).filter(Boolean);
if (domains.includes(domain)) return rowToProvider(row, secretbox);
}
} catch { /* table not migrated yet */ }
return null;
}
module.exports = { list, get, publicList, getOrgProvider, forEmail, DEFAULT_SCOPES, SLUG_RE };

View file

@ -1,7 +1,6 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const https = require('https');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const { generateToken, generateMfaPendingToken, verifyMfaPendingToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES } = require('../middleware/auth');
@ -881,6 +880,41 @@ router.get('/providers', (req, res) => {
res.json({ providers: oidcProviders.publicList() });
});
/*
* Does this email address belong to an organization with its own identity provider?
*
* Answers with a BOOLEAN and nothing else. It deliberately does not return the provider's slug
* or its display name, because both identify a CUSTOMER: a lookup that answered
* "yes — Acme Corp SSO" would turn a guessed domain into confirmation that Acme buys this product,
* and the slug would hand out a working entry point to their tenant's login.
*
* "example.com uses SSO" is the smallest answer that still lets the page draw the right button, and
* it is something anyone could infer by watching an employee log in. The domain-to-provider mapping
* stays server-side: POST /sso/start does the lookup again and redirects, so the browser never
* learns which provider it is being sent to until the provider itself says so.
*
* It also never reveals whether the ACCOUNT exists only the domain is matched so this cannot be
* walked to enumerate users.
*/
router.get('/sso/discover', (req, res) => {
res.json({ sso: !!oidcProviders.forEmail(req.query.email) });
});
/*
* Begin an organization SSO login for an email address.
*
* POST, so the address travels in a body rather than in a URL that lands in browser history, proxy
* logs and any Referer sent by the provider's page. The lookup happens here rather than in the
* browser for the reason above: the slug is never published.
*/
router.post('/sso/start', express.urlencoded({ extended: false }), (req, res) => {
const provider = oidcProviders.forEmail((req.body && req.body.email) || req.query.email);
// An unknown domain is answered exactly like a known one that is disabled: back to the login page
// with nothing learned.
if (!provider) return res.redirect('/app#/login?sso_error=unknown_provider');
res.redirect(`/api/auth/oidc/${encodeURIComponent(provider.slug)}/start`);
});
router.get('/oidc/:slug/start', async (req, res) => {
const provider = oidcProviders.get(req.params.slug);
if (!provider) return backToApp(res, { sso_error: 'unknown_provider' });
@ -987,6 +1021,27 @@ router.get('/oidc/:slug/callback', async (req, res) => {
if (result.error) return backToApp(res, { sso_error: result.error });
const { user, isNew } = result;
/*
* A provider that belongs to an ORGANIZATION vouches for its own people, so anyone who signs in
* through it becomes a member of that organization otherwise a customer would configure SSO,
* their staff would authenticate successfully, and each would land in a fresh empty org of their
* own, which is the opposite of what they asked for.
*
* Membership is added, never changed: an existing member keeps whatever role they already have,
* so an org_owner cannot be demoted by logging in, and a plain member cannot be promoted by one.
* Instance-wide providers do none of this they say nothing about which tenant anyone is in.
*/
if (provider.organizationId) {
const already = db.prepare(
'SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ?'
).get(provider.organizationId, user.id);
if (!already) {
db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_member')")
.run(provider.organizationId, user.id);
logActivity(user.id, 'org_sso_joined', `via ${provider.name}`, provider.organizationId, getClientIp(req));
}
}
logSuccessfulLogin(user.id, user.email, getClientIp(req));
const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup });
const token = generateToken(user, workspaceId);

217
server/routes/org-sso.js Normal file
View file

@ -0,0 +1,217 @@
'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');
/*
* 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();
}
/*
* 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 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`);
}
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, excludeId) {
if (!domains) return;
const wanted = domains.split(',');
const rows = db.prepare("SELECT id, organization_id, email_domains FROM org_sso_providers WHERE email_domains != ''").all();
for (const row of rows) {
if (row.id === excludeId) continue;
const held = String(row.email_domains).split(',');
for (const d of wanted) {
if (held.includes(d) && row.organization_id !== orgId) {
const e = new Error(`the domain ${d} is already used for sign-in by another organization`);
e.status = 409;
throw e;
}
}
}
}
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, 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: ${e.message}` });
}
const id = crypto.randomUUID();
const slug = newSlug();
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);
logActivity(req.user.id, 'org_sso_created', `${name} (${slug})`, req.orgId, getClientIp(req));
res.status(201).json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(id)));
});
router.put('/:orgId/sso/:id', requireOrgAdmin, 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: ${e.message}` }); }
}
/*
* 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.
*/
const secretEnc = clientSecret === undefined ? existing.client_secret_enc
: (clientSecret === '' ? null : secretbox.encrypt(String(clientSecret)));
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,
);
logActivity(req.user.id, 'org_sso_updated', `${existing.name} (${existing.slug})`, req.orgId, getClientIp(req));
res.json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(existing.id)));
});
router.delete('/:orgId/sso/:id', requireOrgAdmin, (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})`, req.orgId, getClientIp(req));
res.json({ success: true });
});
module.exports = router;

View file

@ -573,6 +573,10 @@ app.use('/api/auth/register', rateLimit(60000, 5)); // 5 registrations per minut
app.use('/api/auth/totp/verify', rateLimit(60000, 10));
// Email-verification resend: cap so it can't be used to spray mail at an address.
app.use('/api/auth/resend-verification', rateLimit(60000, 5));
// Domain lookup is unauthenticated by necessity (it runs before login). Rate limited so it
// cannot be walked to enumerate which customers use SSO.
app.use('/api/auth/sso/discover', rateLimit(60000, 10));
app.use('/api/auth/sso/start', rateLimit(60000, 10));
// Self-service password reset. The request endpoint is the spray surface (it sends mail to
// an address the caller supplies), so it gets the tighter cap; the redeem endpoint is a
// 32-byte-token guess, capped mostly to keep the bcrypt work bounded.
@ -583,6 +587,9 @@ app.use('/api/auth/reset-password', rateLimit(60000, 10));
// path prefix first, so this fires before /api/auth catches the request.
app.use('/api/auth/users', rateLimit(60000, 20));
app.use('/api/auth', require('./routes/auth'));
// Per-organization SSO configuration. Mounted under /api/organizations so the org id is the
// route's own subject, which is what the org_owner/org_admin check keys on.
app.use('/api/organizations', require('./routes/org-sso'));
// Rate limit pairing to prevent brute force (5 attempts per minute per IP).
// #88: bind this to the whole /api/provision surface, not just /pair - the bare
// POST /api/provision (routes/provisioning.js) is a second pairing endpoint that

View file

@ -260,3 +260,105 @@ test('the browser is told slugs and names only — never a client id or secret',
assert.ok(!serialised.includes('shh'));
assert.deepEqual(Object.keys(pub[0]).sort(), ['name', 'slug']);
});
// ---------------------------------------------------------------------------------------------
// Per-organization SSO.
//
// Instance providers belong to whoever runs the server; these belong to a CUSTOMER. Two properties
// matter more than the feature itself: one organization must not be able to capture another's
// logins, and the login page must not become a way to enumerate who the customers are.
const Database = require('better-sqlite3');
function orgDb() {
const d = new Database(':memory:');
d.exec(`
CREATE TABLE org_sso_providers (
id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL, issuer TEXT NOT NULL, client_id TEXT NOT NULL, client_secret_enc TEXT,
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);
`);
return d;
}
function withOrgDb(rows, fn) {
const d = orgDb();
for (const r of rows) {
d.prepare(`INSERT INTO org_sso_providers (id, organization_id, slug, name, issuer, client_id, email_domains, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(r.id, r.org, r.slug, r.name, r.issuer || ISSUER, r.clientId || 'cid', r.domains || '', r.enabled === undefined ? 1 : r.enabled);
}
// Swap the module's lazily-resolved connection for this in-memory one.
const real = require('../db/database');
const saved = real.db;
real.db = d;
delete require.cache[require.resolve('../lib/oidc-providers')];
const mod = require('../lib/oidc-providers');
try { return fn(mod); } finally {
real.db = saved;
delete require.cache[require.resolve('../lib/oidc-providers')];
}
}
test('an org provider is found by the email DOMAIN', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com,acme.co.uk' }], (m) => {
assert.equal(m.forEmail('someone@acme.com').name, 'Acme SSO');
assert.equal(m.forEmail('someone@ACME.CO.UK').name, 'Acme SSO', 'case-insensitive');
assert.equal(m.forEmail('someone@other.com'), null);
assert.equal(m.forEmail('not-an-email'), null);
});
});
test('a disabled provider stops answering for its domain', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.com', enabled: 0 }], (m) => {
assert.equal(m.forEmail('x@acme.com'), null);
assert.equal(m.getOrgProvider('orgaaa'), null, 'and cannot be started directly either');
});
});
test('ORG PROVIDERS ARE NEVER PUBLISHED to the whole internet', () => {
// The login page lists instance-wide providers only. Listing a customer's IdP would both offer it
// to people it does not belong to and leak the customer list.
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com' }], (m) => {
const pub = m.publicList({ GOOGLE_CLIENT_ID: 'g' });
assert.deepEqual(pub.map((p) => p.slug), ['google']);
assert.ok(!JSON.stringify(pub).includes('Acme'), 'no customer name anywhere in the public list');
});
});
test('an org provider is still resolvable by slug, so the shared login flow can run it', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com' }], (m) => {
const p = m.get('orgaaa', {});
assert.equal(p.name, 'Acme SSO');
assert.equal(p.organizationId, 'org-a', 'carries its org so the callback can grant membership');
assert.equal(p.source, 'org');
});
});
test('an instance provider wins a slug clash with an org one', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'google', name: 'Impostor', domains: 'evil.com' }], (m) => {
// Org slugs are randomly generated so this cannot happen by accident — but if it ever did, a
// tenant must not be able to shadow the platform's own Google button.
assert.equal(m.get('google', { GOOGLE_CLIENT_ID: 'real' }).name, 'Google');
});
});
test('the first organization to claim a domain keeps it', () => {
// Two rows, same domain. forEmail must be deterministic rather than returning whichever the
// database happened to hand back first — the API refuses the second claim, and this is the
// backstop if a row ever gets in another way.
withOrgDb([
{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'First', domains: 'shared.com' },
{ id: '2', org: 'org-b', slug: 'orgbbb', name: 'Second', domains: 'shared.com' },
], (m) => {
assert.equal(m.forEmail('x@shared.com').name, 'First');
});
});
test('no database means no org providers, and no crash', () => {
// The env-only paths must keep working on an instance where the table has not been migrated yet.
const m = require('../lib/oidc-providers');
assert.doesNotThrow(() => m.publicList({ GOOGLE_CLIENT_ID: 'g' }));
});