SSO: prove domain ownership by DNS, and fix what the second review found

A second review pass, run against the previous commit, found four blockers — two of
them introduced by the fixes in that commit. It also confirmed the original account
takeover is closed: a hostile IdP with real TLS, discovery, JWKS and RS256 driving the
real routers now stops at domain_not_allowed, and all 16 bypass variants are refused.

DOMAIN OWNERSHIP (the root cause, not the symptom)

A claimed domain used to mean "nobody else claimed it". It now means the organization
published a record in that domain's own DNS — TXT or CNAME, at a dedicated
_screentinker-verify name rather than the apex, where an edit would sit beside SPF.

  - an unverified domain routes NOBODY and cannot be asserted; it reserves the name
  - an unverified claim LAPSES after 8 hours, so a domain cannot be held against its
    real owner, and lapsing rotates the token so a record left over from an abandoned
    attempt cannot satisfy a later claim
  - a verified domain never expires — re-proving on a timer would log a customer out
    over a DNS edit made months later
  - routing and confinement read the VERIFIED set only, never the typed column
  - configuring SSO now requires a verified email address
  - platform admins are emailed when a domain is claimed; nothing is ever sent to the
    claimed domain, which would let any tenant make this product email third parties

Instance-wide providers are exempt from all of it: they are the operator's own
configuration and keep the trust they have always had.

BLOCKERS FROM THE REVIEW

  - two unauthenticated remote crashes, both one request, both "async handler throws
    before its try": `Cookie: st_oidc_tx=%` (unguarded decodeURIComponent) and the
    fail-closed secret added last commit, which turned a JWT_SECRET rotation into a
    permanent crash loop. Fixed the CLASS with asyncRoute() rather than the instances.
  - the SSRF guard was bypassable via IPv4-mapped IPv6 ([::ffff:127.0.0.1]) and also
    refused every host beginning "fc"/"fd" (fcm.googleapis.com). Addresses are now
    parsed and compared by RANGE. 42 cases verified.
  - the takeover fix had NO test — the test named after it asserted two struct fields
    and passed with the guard deleted. The decision is now a pure function and four
    mutations were confirmed to turn the suite red.
  - the PUT path never received the TOCTOU fix, so two orgs could end up holding one
    domain and forEmail handed routing to the attacker's older row.

ALSO

  - linking compared slugs, so an org could never rotate its own IdP, and fell open on
    an empty auth_provider. It now asks which ORGANIZATION owns the slug.
  - an account stranded by a deleted provider can be reclaimed by password reset —
    proof of the mailbox, which is stronger than the IdP assertion that created it.
  - /sso/claim accepted a pre-TOTP mfa_pending token and returned the full user row;
    it now takes a purpose-built 120s claim token with a pinned algorithm and typ.
  - the rate limiter keyed on a caller-controlled path, so a trailing slash bought a
    fresh bucket — a real login brute-force bypass.
  - domain_not_allowed and account_exists_other_provider rendered as "please try
    again", advice that can never work.
  - malformed asserted addresses are refused rather than trimmed into shape.
  - dead config (microsoftTenantId defaulted to 'common', which the provider code now
    refuses) and the orphaned google-auth-library dependency removed.

1591 tests pass. Domain lifecycle verified end to end against a running server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
This commit is contained in:
ScreenTinker 2026-08-10 19:23:46 -05:00
parent d26aaebef6
commit d4b8d7dad4
15 changed files with 1112 additions and 292 deletions

View file

@ -404,10 +404,10 @@ 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 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. other's. A domain may be claimed by only one organization; a second claim is refused.
⚠️ **A provider may only authenticate emails inside the domains it registered.** An organization ⚠️ **A provider may only authenticate emails inside the domains it has VERIFIED.** An organization
supplies its own issuer and client ID, so it controls that identity provider completely and could supplies its own issuer and client ID, so it controls that identity provider completely and could
otherwise assert any address at all — including another company's, or an administrator's. Confining otherwise assert any address at all — including another company's, or an administrator's. Confining
assertions to registered domains is what makes customer-configurable SSO safe to offer. assertions to verified domains is what makes customer-configurable SSO safe to offer.
⚠️ **Public email providers cannot be claimed.** `gmail.com`, `outlook.com`, `yahoo.com`, `icloud.com` ⚠️ **Public email providers cannot be claimed.** `gmail.com`, `outlook.com`, `yahoo.com`, `icloud.com`
and the rest of the consumer mailboxes are refused (`server/lib/public-email-domains.js`). Claiming and the rest of the consumer mailboxes are refused (`server/lib/public-email-domains.js`). Claiming
@ -415,10 +415,34 @@ one would offer every Gmail user a "sign in with your organization" button point
infrastructure — phishing launched from this product's own login page — and would let one account infrastructure — phishing launched from this product's own login page — and would let one account
deny a public domain to everyone else. deny a public domain to everyone else.
⚠️ **Domain ownership is not yet verified.** A claimed domain currently means "no other organization ### Proving a domain
had claimed it", not "this organization owns it". The blocklist above removes the mass-abuse case,
but proof of control — a DNS TXT record, or a challenge to `postmaster@` — is still the missing A claimed domain **routes nobody and authenticates nobody until DNS proves the organization controls
control, and until it exists a domain claim should be treated as a support-reviewable action. it.** Typing a domain into a form reserves the name and nothing more.
Publish either record — whichever the domain's DNS will accept — then press **Verify**:
```
_screentinker-verify.example.com. IN TXT "st-verify=<token>"
_screentinker-verify.example.com. IN CNAME <token>.verify.screentinker.com.
```
The token is unique per domain, so publishing one proof cannot be replayed to claim a second. A
dedicated `_`-prefixed name is used rather than the apex, where a careless edit would sit alongside
SPF and DMARC and break mail.
**An unverified claim lapses after 8 hours**, and lapsing rotates the token. This is what stops
squatting: a tenant cannot type a company's domain and hold it against the real owner, and a record
left in DNS from an abandoned attempt cannot satisfy a later claim. A verified domain never expires —
re-proving on a timer would log a customer out over a DNS edit made months afterwards.
Platform admins are emailed whenever a domain is claimed. Verification is what makes an unowned
claim worthless; the notification is what makes an attempt visible. Nothing is ever sent to the
claimed domain itself — that would let any tenant make this product email third parties.
⚠️ **Instance-wide providers are exempt from all of the above.** `GOOGLE_CLIENT_ID`, `OIDC_*` and
friends are the operator's own configuration, are not domain-restricted, and require no verification.
Domain proof exists because per-organization providers are supplied by CUSTOMERS.
Signing in through an organization's provider makes the user a member of that organization 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 (`org_member`). Existing members keep whatever role they already have — logging in never promotes or

View file

@ -151,6 +151,16 @@ export default {
'sso.disable': 'Disable', 'sso.disable': 'Disable',
'sso.disabled': 'disabled', 'sso.disabled': 'disabled',
'sso.domains_label': 'Email domains', 'sso.domains_label': 'Email domains',
'sso.domains_heading': 'Sign-in domains',
'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.',
'sso.verify_now': 'Verify',
'sso.verifying': 'Checking DNS…',
'sso.verify_failed': 'Could not verify that domain.',
'sso.domain_verified_toast': '{domain} is verified.',
'sso.dns_instructions': 'Publish ONE of these records in this domain\u2019s DNS, then click Verify. Claims expire after 8 hours.',
'sso.dns_or_cname': 'or, if your DNS will not take a TXT record there:',
'sso.callback_label': 'Redirect URI — add this to your provider', 'sso.callback_label': 'Redirect URI — add this to your provider',
'sso.f_name': 'Display name', 'sso.f_name': 'Display name',
'sso.f_issuer': 'Issuer URL', 'sso.f_issuer': 'Issuer URL',
@ -187,6 +197,10 @@ export default {
'auth.sso_err_account_exists_local': 'An account with this email already exists. Sign in with your password, then link your provider in Settings.', 'auth.sso_err_account_exists_local': 'An account with this email already exists. Sign in with your password, then link your provider in Settings.',
'auth.sso_err_subject_mismatch': 'This email is already linked to a different account at your provider.', 'auth.sso_err_subject_mismatch': 'This email is already linked to a different account at your provider.',
'auth.sso_err_server_error': 'Something went wrong completing sign-in.', '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_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', 'auth.signin_microsoft': 'Sign in with Microsoft',
'auth.back_to_signin': 'Back to Sign In', 'auth.back_to_signin': 'Back to Sign In',
// TOTP 2FA challenge (second login step) // TOTP 2FA challenge (second login step)

View file

@ -581,7 +581,8 @@ function setupHandlers(config, isSetup) {
// rather than failing silently, which is how the previous implementation behaved on every click. // rather than failing silently, which is how the previous implementation behaved on every click.
const known = ['expired', 'bad_state', 'no_code', 'no_email', 'email_unverified', const known = ['expired', 'bad_state', 'no_code', 'no_email', 'email_unverified',
'verification_failed', 'provider_refused', 'provider_unavailable', 'unknown_provider', 'verification_failed', 'provider_refused', 'provider_unavailable', 'unknown_provider',
'registration_disabled', 'account_exists_local', 'subject_mismatch', 'server_error']; 'registration_disabled', 'account_exists_local', 'subject_mismatch', 'server_error',
'domain_not_allowed', 'account_exists_other_provider'];
const key = known.includes(ssoError) ? `auth.sso_err_${ssoError}` : 'auth.sso_failed'; const key = known.includes(ssoError) ? `auth.sso_err_${ssoError}` : 'auth.sso_failed';
showToast(t(key), 'error'); showToast(t(key), 'error');
} }

View file

@ -702,6 +702,9 @@ export async function render(container) {
${p.enabled ? '' : `<span style="font-size:11px;color:var(--text-muted)"> — ${esc(t('sso.disabled'))}</span>`} ${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);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 style="font-size:12px;color:var(--text-muted)">${esc(t('sso.domains_label'))}: ${esc(p.email_domains || '—')}</div>
${(p.domains || []).some((d) => !d.verified)
? `<div style="font-size:12px;color:var(--warning,#b45309);margin-top:2px">⚠️ ${esc(t('sso.unverified_warning'))}</div>`
: ''}
</div> </div>
<div style="display:flex;gap:6px;flex-shrink:0"> <div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn btn-secondary btn-sm" data-sso-test="${esc(p.id)}">${esc(t('sso.test'))}</button> <button class="btn btn-secondary btn-sm" data-sso-test="${esc(p.id)}">${esc(t('sso.test'))}</button>
@ -721,6 +724,32 @@ export async function render(container) {
<!-- Editing is per provider, because an organization may have several (one per domain, or <!-- Editing is per provider, because an organization may have several (one per domain, or
one per identity provider after a merger) and they are configured independently. --> one per identity provider after a merger) and they are configured independently. -->
<!-- Domain proof. A claimed domain routes NOBODY until DNS confirms the organization
controls it, so the state of each one is shown plainly rather than left to be inferred
from a login that silently does not work. -->
${(p.domains || []).length ? `
<div style="margin-top:10px;font-size:12px">
<div style="color:var(--text-muted);margin-bottom:4px">${esc(t('sso.domains_heading'))}</div>
${p.domains.map((d) => `
<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
<div><strong>${esc(d.domain)}</strong>
${d.verified
? `<span style="color:var(--success,#15803d)"> — ${esc(t('sso.domain_verified'))}</span>`
: `<span style="color:var(--warning,#b45309)"> — ${esc(t('sso.domain_pending'))}</span>`}
</div>
${d.verified ? '' : `<button class="btn btn-secondary btn-sm" data-sso-verify="${esc(p.id)}" data-domain="${esc(d.domain)}">${esc(t('sso.verify_now'))}</button>`}
</div>
${d.verified ? '' : `
<div style="margin-top:6px;color:var(--text-muted)">${esc(t('sso.dns_instructions'))}</div>
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px;margin-top:4px">${esc(d.record_name)} TXT ${esc(d.txt_value)}</code>
<div style="margin-top:4px;color:var(--text-muted)">${esc(t('sso.dns_or_cname'))}</div>
<code style="display:block;word-break:break-all;padding:6px;background:var(--bg-secondary);border-radius:4px;margin-top:4px">${esc(d.record_name)} CNAME ${esc(d.cname_value)}</code>
${d.last_error ? `<div style="margin-top:4px;color:var(--danger,#b91c1c)">${esc(d.last_error)}</div>` : ''}`}
<div id="ssoVerify-${esc(p.id)}-${esc(d.domain.replace(/[^a-z0-9]/g, '-'))}" style="margin-top:4px"></div>
</div>`).join('')}
</div>` : ''}
<div id="ssoTest-${esc(p.id)}" style="display:none;margin-top:8px;font-size:12px"></div> <div id="ssoTest-${esc(p.id)}" style="display:none;margin-top:8px;font-size:12px"></div>
<div id="ssoEdit-${esc(p.id)}" style="display:none;margin-top:12px;padding-top:12px;border-top:1px solid var(--border);display:none"> <div id="ssoEdit-${esc(p.id)}" style="display:none;margin-top:12px;padding-top:12px;border-top:1px solid var(--border);display:none">
<div style="display:grid;gap:10px;max-width:560px"> <div style="display:grid;gap:10px;max-width:560px">
@ -757,6 +786,44 @@ export async function render(container) {
await ssoRequest('PUT', `/${btn.dataset.ssoToggle}`, { enabled: btn.dataset.enabled !== '1' }); await ssoRequest('PUT', `/${btn.dataset.ssoToggle}`, { enabled: btn.dataset.enabled !== '1' });
}); });
}); });
/*
* Ask the server to look for the DNS record now. Pull-based on purpose: the admin has just
* 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.
*/
listEl.querySelectorAll('[data-sso-verify]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.ssoVerify;
const domain = btn.dataset.domain;
const out = document.getElementById(`ssoVerify-${id}-${domain.replace(/[^a-z0-9]/g, '-')}`);
btn.disabled = true;
if (out) out.textContent = t('sso.verifying');
try {
const res = await fetch(`/api/organizations/${orgId}/sso/${id}/domains/${encodeURIComponent(domain)}/verify`, {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
const body = await res.json().catch(() => ({}));
if (body.ok) {
showToast(t('sso.domain_verified_toast', { domain }), 'success');
await load(); // re-render: the domain now routes, and the card must say so
return;
}
// An expired claim has already been reissued server-side, so the records on screen are
// stale — reload rather than leaving the admin publishing a value that no longer matches.
if (body.expired) {
showToast(body.error || t('sso.verify_failed'), 'error');
await load();
return;
}
if (out) out.textContent = body.error || t('sso.verify_failed');
} catch {
if (out) out.textContent = t('sso.verify_failed');
} finally {
btn.disabled = false;
}
});
});
listEl.querySelectorAll('[data-sso-test]').forEach((btn) => { listEl.querySelectorAll('[data-sso-test]').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const id = btn.dataset.ssoTest; const id = btn.dataset.ssoTest;

View file

@ -84,11 +84,14 @@ module.exports = {
return secret; return secret;
})(), })(),
jwtExpiry: '7d', jwtExpiry: '7d',
// Google OAuth - set these in env or here /*
googleClientId: process.env.GOOGLE_CLIENT_ID || '', * Google and Microsoft sign-in are configured through lib/oidc-providers.js, which reads
// Microsoft OAuth - set these in env or here * process.env directly there is nothing here for it to read, so these fields were dead, and
microsoftClientId: process.env.MICROSOFT_CLIENT_ID || '', * `microsoftTenantId` defaulting to 'common' actively contradicted the provider code, which now
microsoftTenantId: process.env.MICROSOFT_TENANT_ID || 'common', * REFUSES 'common' (it advertises a template issuer that can never match, and accepting it means
* accepting tokens from every Azure tenant nOAuth). Removed rather than left as a trap for the
* next person who greps for where Microsoft SSO is configured.
*/
// Stripe (optional - for paid subscriptions) // Stripe (optional - for paid subscriptions)
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '', stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',

View file

@ -430,6 +430,40 @@ const migrations = [
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE 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)", "CREATE INDEX IF NOT EXISTS idx_org_sso_org ON org_sso_providers(organization_id)",
/*
* Claimed sign-in domains, and the proof that the claimant controls them.
*
* `org_sso_providers.email_domains` used to be the whole story, and first-claim-wins on a text
* field is not a claim it is a land grab. A tenant could type a domain it had nothing to do
* with and every person at that company typing their work address into the login page would be
* routed to the squatter's identity provider. It also let one account permanently deny a domain
* to its real owner, and strand accounts at addresses it never owned.
*
* So a domain is inert until DNS says otherwise. `verified_at` NULL means claimed but unproven:
* it routes nobody, and the login callback will not accept an assertion for it. The row still
* reserves the name, so two tenants cannot race the same domain, but reserving is all it does.
*
* `token` is what has to appear in DNS. It is per-domain rather than per-organization so that
* publishing one proof cannot be replayed to claim a second domain.
*/
`CREATE TABLE IF NOT EXISTS org_sso_domains (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
provider_id TEXT,
domain TEXT NOT NULL UNIQUE,
token TEXT NOT NULL,
-- When the current token was issued. An UNVERIFIED claim is only good for 8 hours from here:
-- past that the token is dead and the reservation lapses, so a domain nobody can prove cannot
-- be held indefinitely by whoever typed it first. Verified rows ignore this entirely.
token_issued_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
verified_at INTEGER,
last_checked_at INTEGER,
last_error 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_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)",
"ALTER TABLE device_telemetry ADD COLUMN attached_display TEXT", "ALTER TABLE device_telemetry ADD COLUMN attached_display TEXT",
"ALTER TABLE device_telemetry ADD COLUMN video_mode TEXT", "ALTER TABLE device_telemetry ADD COLUMN video_mode TEXT",
// Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because // Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because

138
server/lib/domain-verify.js Normal file
View file

@ -0,0 +1,138 @@
'use strict';
/*
* Proving that a tenant controls a sign-in domain.
*
* Per-organization SSO routes everyone at a domain to that organization's identity provider. That
* is exactly right when the organization owns the domain and an account-takeover primitive when it
* does not and typing a domain into a form is not ownership. A review demonstrated the whole
* chain: claim a company's domain, sign in as a named address there, and the real owner is left
* unable to reach an account bearing their own address.
*
* DNS is the check, because control of a domain's DNS is what "owning a domain" means in the only
* sense that matters here. It is also the mechanism every other vendor uses, so the instructions
* are already familiar to the person who has to follow them.
*
* TWO RECORD FORMS, both at the same name, because organizations differ in what their DNS lets
* them add some providers refuse TXT at a subdomain, some refuse CNAME anywhere useful:
*
* _screentinker-verify.example.com. IN TXT "st-verify=<token>"
* _screentinker-verify.example.com. IN CNAME <token>.verify.screentinker.com.
*
* A dedicated `_`-prefixed name is used rather than the apex on purpose: an apex TXT record sits
* alongside SPF and DMARC, where a careless edit breaks mail, and it is the one record set an
* administrator is most reluctant to touch.
*/
const dns = require('dns').promises;
const crypto = require('crypto');
const RECORD_PREFIX = '_screentinker-verify';
const TXT_PREFIX = 'st-verify=';
const CNAME_SUFFIX = '.verify.screentinker.com';
// A DNS answer that never arrives must not hold an HTTP request open. The resolver's own retries
// sit under this, so it is a ceiling on the whole lookup rather than on one query.
const LOOKUP_TIMEOUT_MS = 5000;
/*
* How long an UNVERIFIED claim is worth anything.
*
* A claim reserves the domain so two tenants cannot race it but a reservation that never lapses
* is squatting with extra steps: type a company's domain, prove nothing, and hold it against its
* real owner forever. Eight hours is comfortably longer than a DNS change takes to publish and
* propagate, and short enough that an unprovable claim is gone by the next working day.
*
* The token dies with the claim. Trying again mints a NEW token, so an old record left in DNS from
* a lapsed attempt proves nothing, and a domain that changed hands cannot be verified with the
* previous holder's value.
*
* A VERIFIED domain is not affected proof already happened, and re-proving on a timer would log
* out a customer over a DNS edit made months later.
*/
const CLAIM_TTL_S = 8 * 60 * 60;
/** True when an unverified claim has run out of time and no longer reserves anything. */
function isClaimExpired(row, nowS = Math.floor(Date.now() / 1000)) {
if (!row || row.verified_at) return false;
return (Number(row.token_issued_at) || 0) + CLAIM_TTL_S <= nowS;
}
/** Tokens are compared, so they are random and long enough that guessing is not a strategy. */
const newToken = () => crypto.randomBytes(16).toString('hex');
const recordName = (domain) => `${RECORD_PREFIX}.${domain}`;
/** Exactly what the admin has to publish — shown in the UI, so it is built in one place. */
function instructions(domain, token) {
return {
record_name: recordName(domain),
txt_value: `${TXT_PREFIX}${token}`,
cname_value: `${token}${CNAME_SUFFIX}`,
};
}
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('DNS lookup timed out')), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
/*
* Look for the proof.
*
* Both record types are queried together and either one is enough. NXDOMAIN and "no such record"
* are ordinary answers here the overwhelmingly common case is an admin checking before the record
* has propagated so they are reported as "not found yet", never as an error to be alarmed by.
*
* Resolution uses the system resolver, which is the same view of DNS the operator already
* trusts. A tenant that can poison that resolver can forge a proof, but a tenant that can do that
* has already won something larger.
*/
async function check(domain, token) {
const name = recordName(domain);
const wantTxt = `${TXT_PREFIX}${token}`;
const wantCname = `${token}${CNAME_SUFFIX}`;
const results = await Promise.allSettled([
withTimeout(dns.resolveTxt(name), LOOKUP_TIMEOUT_MS),
withTimeout(dns.resolveCname(name), LOOKUP_TIMEOUT_MS),
]);
const [txtRes, cnameRes] = results;
if (txtRes.status === 'fulfilled') {
// resolveTxt returns arrays of string chunks — a long value is split, so join before comparing.
for (const chunks of txtRes.value) {
if (chunks.join('').trim() === wantTxt) return { ok: true, via: 'TXT' };
}
}
if (cnameRes.status === 'fulfilled') {
for (const target of cnameRes.value) {
// DNS names are case-insensitive and may or may not carry the root dot.
if (target.replace(/\.$/, '').toLowerCase() === wantCname.toLowerCase()) return { ok: true, via: 'CNAME' };
}
}
// Nothing matched. Say which of the two failure shapes it is, because the fixes differ: a record
// that is absent needs publishing, a record that is present but wrong needs correcting.
const found = [];
if (txtRes.status === 'fulfilled') found.push(...txtRes.value.map((c) => `TXT ${c.join('')}`));
if (cnameRes.status === 'fulfilled') found.push(...cnameRes.value.map((c) => `CNAME ${c}`));
if (found.length) {
return { ok: false, error: `${name} exists but does not match. Found: ${found.join('; ')}` };
}
const timedOut = results.some((r) => r.status === 'rejected' && /timed out/i.test(r.reason && r.reason.message));
if (timedOut) return { ok: false, error: 'the DNS lookup timed out — try again shortly' };
return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` };
}
module.exports = {
check, instructions, newToken, recordName, isClaimExpired,
CLAIM_TTL_S, RECORD_PREFIX, TXT_PREFIX, CNAME_SUFFIX,
};

View file

@ -29,6 +29,14 @@ const DEFAULT_SCOPES = 'openid email profile';
/** A slug has to be safe in a URL path and in an env var name. */ /** A slug has to be safe in a URL path and in an env var name. */
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,30}$/; const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,30}$/;
/*
* `local` is what users.auth_provider says for a password account, so a provider by that name would
* make every federated login look like a password login to the linking rules and would put a NULL
* password_hash on rows that POST /login then feeds straight to bcrypt.compareSync. Reserved rather
* than merely discouraged.
*/
const RESERVED_SLUGS = new Set(['local', 'recovery']);
function envKey(slug, suffix) { function envKey(slug, suffix) {
return `OIDC_${slug.toUpperCase().replace(/-/g, '_')}_${suffix}`; return `OIDC_${slug.toUpperCase().replace(/-/g, '_')}_${suffix}`;
} }
@ -118,7 +126,7 @@ function list(env = process.env) {
for (const raw of String(env.OIDC_PROVIDERS || '').split(',')) { for (const raw of String(env.OIDC_PROVIDERS || '').split(',')) {
const slug = raw.trim().toLowerCase(); const slug = raw.trim().toLowerCase();
if (!slug || seen.has(slug)) continue; if (!slug || seen.has(slug)) continue;
if (!SLUG_RE.test(slug)) continue; // ignore rather than crash a boot over a typo if (!SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug)) continue; // ignore rather than crash a boot over a typo
const p = fromEnv(env, slug); const p = fromEnv(env, slug);
if (p) { out.push(p); seen.add(slug); } if (p) { out.push(p); seen.add(slug); }
} }
@ -179,11 +187,31 @@ function rowToProvider(row, secretbox) {
scopes: row.scopes || DEFAULT_SCOPES, scopes: row.scopes || DEFAULT_SCOPES,
source: 'org', source: 'org',
organizationId: row.organization_id, organizationId: row.organization_id,
// Carried so the callback can refuse an assertion outside the domains this customer registered. /*
emailDomains: row.email_domains || '', * VERIFIED domains only never org_sso_providers.email_domains.
*
* That column is what an admin typed. This is what they PROVED, by publishing a record in the
* domain's own DNS, and it is the only thing the login callback may confine an assertion to.
* Reading the typed column here would reduce the whole verification feature to a decoration:
* a tenant could type any company's domain and immediately assert addresses in it.
*/
emailDomains: verifiedDomainsFor(row.id).join(','),
}; };
} }
/** The domains a provider has actually proved it controls. */
function verifiedDomainsFor(providerId) {
const conn = db();
if (!conn) return [];
try {
return conn.prepare('SELECT domain FROM org_sso_domains WHERE provider_id = ? AND verified_at IS NOT NULL')
.all(providerId).map((r) => r.domain);
} catch (e) {
if (/no such table/i.test(e.message)) return [];
throw e;
}
}
/** One org provider by its (globally unique) slug, or null. */ /** One org provider by its (globally unique) slug, or null. */
function getOrgProvider(slug) { function getOrgProvider(slug) {
const conn = db(); const conn = db();
@ -204,6 +232,31 @@ function getOrgProvider(slug) {
} }
} }
/**
* Who owns a provider slug without decrypting anything, and regardless of whether it is enabled.
*
* The linking rules need to know which ORGANIZATION established an account, not how to talk to its
* provider, and asking get() for that has two problems: it fails closed on an undecryptable secret
* (right for a login, wrong for an ownership question) and it hides disabled rows, which still own
* the accounts they created.
*
* null means "nothing here owns that slug" either it never existed or the provider has since been
* deleted, and those are deliberately the same answer.
*/
function ownerOf(slug) {
if (!slug || !SLUG_RE.test(String(slug))) return null;
if (list().some((p) => p.slug === slug)) return { source: 'env', organizationId: null };
const conn = db();
if (!conn) return null;
try {
const row = conn.prepare('SELECT organization_id FROM org_sso_providers WHERE slug = ?').get(String(slug));
return row ? { source: 'org', organizationId: row.organization_id } : null;
} catch (e) {
if (/no such table/i.test(e.message)) return null;
throw e;
}
}
/** /**
* Which provider, if any, owns an email address. * Which provider, if any, owns an email address.
* *
@ -222,14 +275,27 @@ function forEmail(email) {
const domain = String(email).slice(at + 1).toLowerCase().trim(); const domain = String(email).slice(at + 1).toLowerCase().trim();
if (!domain) return null; if (!domain) return null;
try { try {
const rows = conn.prepare("SELECT * FROM org_sso_providers WHERE enabled = 1 AND email_domains != '' ORDER BY created_at, id").all(); /*
const secretbox = require('./secretbox'); * Routing is driven by the VERIFIED domain table, not by the text an admin typed, and the join
for (const row of rows) { * is what enforces it an unverified claim cannot send anyone anywhere. Ordering by the
const domains = String(row.email_domains || '').split(',').map((d) => d.trim().toLowerCase()).filter(Boolean); * verification time makes the winner of any residual tie the one who PROVED it first, rather
if (domains.includes(domain)) return rowToProvider(row, secretbox); * than whichever row a table scan reached.
} */
} catch { /* table not migrated yet */ } const row = conn.prepare(`
SELECT p.* FROM org_sso_domains d
JOIN org_sso_providers p ON p.id = d.provider_id
WHERE d.domain = ? AND d.verified_at IS NOT NULL AND p.enabled = 1
ORDER BY d.verified_at, d.id
LIMIT 1
`).get(domain);
if (row) return rowToProvider(row, require('./secretbox'));
} catch (e) {
// Only a missing table is a null — anything else (a secret that will not decrypt, a schema
// drift) must surface rather than silently answering "this domain has no SSO", which is how a
// fail-closed guarantee turns back into a fail-open one.
if (!/no such table/i.test(e.message)) throw e;
}
return null; return null;
} }
module.exports = { list, get, publicList, getOrgProvider, forEmail, DEFAULT_SCOPES, SLUG_RE }; module.exports = { list, get, publicList, getOrgProvider, ownerOf, forEmail, DEFAULT_SCOPES, SLUG_RE };

View file

@ -25,6 +25,7 @@
*/ */
const crypto = require('crypto'); const crypto = require('crypto');
const net = require('net');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
/* /*
@ -52,21 +53,78 @@ const jwksCache = new Map(); // jwks_uri -> { at, keys }
* Two rules, both cheap: * Two rules, both cheap:
* https only an http:// target is a plaintext credential leak as well as a way to reach * https only an http:// target is a plaintext credential leak as well as a way to reach
* services that never expected a request from inside the network. * services that never expected a request from inside the network.
* public hosts only loopback, RFC1918, link-local (169.254.169.254 is cloud metadata) and the * public hosts only loopback, RFC1918, CGNAT, link-local (169.254.169.254 is cloud metadata),
* IPv6 equivalents are refused outright. * multicast and reserved ranges, in BOTH address families, including the
* IPv4-mapped IPv6 forms that a prefix match misses.
* *
* This is a literal-address check, not full SSRF protection: a hostname that RESOLVES to a * This is a literal-address check, not full SSRF protection: a hostname that RESOLVES to a
* private address still passes, because refusing that needs resolve-then-pin plumbing that Node's * private address still passes, because refusing that needs resolve-then-pin plumbing that Node's
* fetch does not expose. It raises the bar from "type an internal URL" to "control public DNS", * fetch does not expose. It raises the bar from "type an internal URL" to "control public DNS".
* and the README says so rather than implying more. * README.md documents this limitation under per-organization SSO.
*/ */
const BLOCKED_HOST = /^(localhost|.*\.localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?|\[?f[cd])/i; /*
* Addresses are parsed as ADDRESSES and compared by range. This started life as a prefix regex,
* which was wrong in both directions: it missed `[::ffff:127.0.0.1]` the entire IPv4 space
* re-encoded, which WHATWG URL normalises to `[::ffff:7f00:1]` so no dotted-quad prefix can match,
* and a review reached a loopback service straight through it while also matching plain TEXT, so
* every hostname beginning "fc" or "fd" was refused (fcm.googleapis.com, fcps.edu).
*/
const BLOCKED_V4 = [
['0.0.0.0', 8], // "this network"
['10.0.0.0', 8], // RFC1918
['100.64.0.0', 10], // CGNAT / Tailscale
['127.0.0.0', 8], // loopback
['169.254.0.0', 16], // link-local — 169.254.169.254 is cloud metadata
['172.16.0.0', 12], // RFC1918
['192.0.0.0', 24], // IETF protocol assignments
['192.168.0.0', 16], // RFC1918
['198.18.0.0', 15], // benchmarking
['224.0.0.0', 4], // multicast
['240.0.0.0', 4], // reserved
];
const v4ToInt = (ip) => ip.split('.').reduce((acc, o) => (acc * 256) + Number(o), 0);
function isBlockedV4(ip) {
const addr = v4ToInt(ip);
return BLOCKED_V4.some(([base, bits]) => {
const mask = bits === 0 ? 0 : (-1 << (32 - bits)) >>> 0;
return (addr & mask) >>> 0 === (v4ToInt(base) & mask) >>> 0;
});
}
function isBlockedV6(ip) {
const low = ip.toLowerCase();
// An IPv4-mapped or IPv4-compatible address is an IPv4 address wearing a hat — judge the IPv4.
const mapped = low.match(/^::(ffff:)?(\d+\.\d+\.\d+\.\d+)$/)
|| low.match(/^::(ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (mapped) {
if (mapped[2] && mapped[2].includes('.')) return isBlockedV4(mapped[2]);
const hi = parseInt(mapped[2], 16), lo = parseInt(mapped[3], 16);
return isBlockedV4([hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.'));
}
if (low === '::' || low === '::1') return true; // unspecified (= loopback on Linux), loopback
if (/^f[cd]/.test(low)) return true; // fc00::/7 unique-local
if (/^fe[89ab]/.test(low)) return true; // fe80::/10 link-local
if (/^ff/.test(low)) return true; // multicast
return false;
}
function assertFetchable(url) { function assertFetchable(url) {
let u; let u;
try { u = new URL(url); } catch { throw new Error(`not a URL: ${url}`); } try { u = new URL(url); } catch { throw new Error(`not a URL: ${url}`); }
if (u.protocol !== 'https:') throw new Error('provider URLs must use https'); if (u.protocol !== 'https:') throw new Error('provider URLs must use https');
if (BLOCKED_HOST.test(u.hostname)) throw new Error('provider host is not publicly routable');
const host = u.hostname;
// URL keeps IPv6 literals in brackets; net.isIP does not want them.
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
const family = net.isIP(bare);
const blocked = family === 4 ? isBlockedV4(bare)
: family === 6 ? isBlockedV6(bare)
: /^(localhost|.*\.localhost)$/i.test(host);
if (blocked) throw new Error('provider host is not publicly routable');
return u; return u;
} }
@ -257,6 +315,7 @@ async function fetchJwks(jwksUri) {
module.exports = { module.exports = {
discover, discover,
assertFetchable,
fetchJwks, fetchJwks,
verifyIdToken, verifyIdToken,
exchangeCode, exchangeCode,

174
server/package-lock.json generated
View file

@ -15,7 +15,6 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.3.1", "express-rate-limit": "^8.3.1",
"google-auth-library": "^10.6.2",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
@ -931,6 +930,7 @@
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
@ -1314,15 +1314,6 @@
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
} }
}, },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bindings": { "node_modules/bindings": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@ -1845,15 +1836,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -2347,12 +2329,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extract-zip": { "node_modules/extract-zip": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@ -2415,29 +2391,6 @@
"pend": "~1.2.0" "pend": "~1.2.0"
} }
}, },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-uri-to-path": { "node_modules/file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@ -2491,18 +2444,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -2550,34 +2491,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/gaxios": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -2717,32 +2630,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -2857,6 +2744,7 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "^7.1.2",
@ -2870,6 +2758,7 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -2887,6 +2776,7 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
@ -3022,15 +2912,6 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/jsonfile": { "node_modules/jsonfile": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
@ -3371,44 +3252,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-int64": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -4845,15 +4688,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webdriver-bidi-protocol": { "node_modules/webdriver-bidi-protocol": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",

View file

@ -17,7 +17,6 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.3.1", "express-rate-limit": "^8.3.1",
"google-auth-library": "^10.6.2",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
@ -32,7 +31,7 @@
}, },
"devDependencies": { "devDependencies": {
"js-yaml": "^4.2.0", "js-yaml": "^4.2.0",
"socket.io-client": "^4.8.3", "puppeteer-core": "^24.43.1",
"puppeteer-core": "^24.43.1" "socket.io-client": "^4.8.3"
} }
} }

View file

@ -278,12 +278,80 @@ router.post('/resend-verification', (req, res) => {
// would turn "read one email" into a full session and quietly bypass MFA. // would turn "read one email" into a full session and quietly bypass MFA.
const RESET_GENERIC_OK = { ok: true, message: 'If that address has an account, a reset link is on its way.' }; const RESET_GENERIC_OK = { ok: true, message: 'If that address has an account, a reset link is on its way.' };
/*
* An account whose identity provider no longer exists and why it may reset a password.
*
* A federated row normally must NOT be resettable: the identity provider owns that account, and
* offering a password would be a way around it. But a provider can be deleted, and the row it
* created outlives it, pointing at a slug nothing answers to. Such an account cannot log in by any
* route: no provider to authenticate against, no password to reset, and registration refuses the
* address as taken.
*
* That is not only an accident. A tenant can claim a domain it does not own (claims are not yet
* verified see the README), sign in as an address there, delete its provider, and leave the real
* owner permanently unable to reach an account bearing their own address.
*
* Proving control of the MAILBOX is the right way out, and it is strictly stronger evidence than
* the identity-provider assertion that created the row. So an orphaned account may reset, and doing
* so returns it to a local account. A row whose provider still exists is untouched by this.
*/
/*
* What an identity provider is allowed to call an email address.
*
* Exactly one @, no whitespace, no control characters, a domain with at least one dot. Deliberately
* stricter than the RFC this is not validating what may exist in the world, it is deciding what
* this system will key an ACCOUNT on, and every exotic form is a way for two spellings to look like
* one address to a human and two to the database.
*/
const ASSERTED_EMAIL_RE = /^[^\s@\x00-\x1f]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
/**
* May this provider speak for this address?
*
* A pure function on purpose: the confinement it implements is the single control standing between
* per-organization SSO and an account-takeover primitive, and a control that can only be exercised
* by standing up a hostile identity provider is a control that does not get tested. It was in fact
* shipped untested once the test named after it asserted only that a provider row carried two
* fields, and passed with the guard deleted.
*
* `provider.emailDomains` is the VERIFIED set (see rowToProvider), so this cannot be satisfied by a
* domain the tenant merely typed.
*/
function emailAllowedForProvider(provider, email) {
// Instance-wide providers are the operator's own choice and keep the trust they have always had.
if (!provider.organizationId) return true;
const addr = String(email || '').toLowerCase();
/*
* Malformed addresses are refused rather than tidied. `victim@evil.test@acme.test\n` used to pass
* lastIndexOf('@') took `acme.test\n`, and trimming turned it into an allowed domain so an
* address that is not one thing got treated as belonging to a domain it only ended with. Anything
* carrying whitespace, control characters or a second @ is not an address this will reason about.
*/
if (!ASSERTED_EMAIL_RE.test(addr)) return false;
const at = addr.lastIndexOf('@');
if (at === -1) return false;
const domain = addr.slice(at + 1).trim();
if (!domain) return false;
// Lowercased on both sides: forEmail lowercases when routing, and a row that differed in case
// would otherwise route a user in and then reject them at the callback.
const allowed = String(provider.emailDomains || '').split(',').map((d) => d.trim().toLowerCase()).filter(Boolean);
return allowed.includes(domain);
}
function isOrphanedFederated(user) {
if (!user || user.auth_provider === 'local') return false;
return !oidcProviders.ownerOf(user.auth_provider);
}
router.post('/forgot-password', (req, res) => { router.post('/forgot-password', (req, res) => {
const email = String(req.body?.email || '').toLowerCase().trim(); const email = String(req.body?.email || '').toLowerCase().trim();
// Respond identically no matter what happens below. // Respond identically no matter what happens below.
try { try {
if (email) { if (email) {
const user = db.prepare("SELECT * FROM users WHERE email = ? AND auth_provider = 'local'").get(email); const candidate = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
// A local account, or one stranded by a deleted provider — see isOrphanedFederated above.
const user = candidate && (candidate.auth_provider === 'local' || isOrphanedFederated(candidate))
? candidate : null;
if (user) { if (user) {
if (!emailSvc.isConfigured()) { if (!emailSvc.isConfigured()) {
// Loud, because the user will wait for an email that can never arrive and the // Loud, because the user will wait for an email that can never arrive and the
@ -313,7 +381,17 @@ router.post('/reset-password', (req, res) => {
// Someone who locked themselves out guessing must not stay locked out after proving // Someone who locked themselves out guessing must not stay locked out after proving
// control of the mailbox and choosing a new password. // control of the mailbox and choosing a new password.
loginLockout.reset(userId); loginLockout.reset(userId);
const u = db.prepare('SELECT email FROM users WHERE id = ?').get(userId); const u = db.prepare('SELECT email, auth_provider FROM users WHERE id = ?').get(userId);
/*
* Return a stranded federated row to a local account. Without this the reset would "succeed" and
* change nothing anyone can use: POST /login only ever looks at auth_provider = 'local', so the
* new password would be unreachable and the account still lost.
*/
if (isOrphanedFederated(u)) {
db.prepare("UPDATE users SET auth_provider = 'local', provider_id = NULL WHERE id = ?").run(userId);
console.log(`[password-reset] ${u.email} reclaimed from deleted provider ${u.auth_provider}`);
logActivity(userId, 'auth:federated_account_reclaimed', `was ${u.auth_provider}`, null, getClientIp(req));
}
logActivity(userId, 'auth:password_reset_completed', null, null, getClientIp(req)); logActivity(userId, 'auth:password_reset_completed', null, null, getClientIp(req));
console.log(`[password-reset] password changed for ${u ? u.email : userId}`); console.log(`[password-reset] password changed for ${u ? u.email : userId}`);
// No session on purpose — see above. // No session on purpose — see above.
@ -365,7 +443,7 @@ router.get('/totp/status', requireAuth, (req, res) => {
// Step 1: mint a pending secret + return the otpauth:// URI + a ready-to-render QR // Step 1: mint a pending secret + return the otpauth:// URI + a ready-to-render QR
// data URL (drawn server-side with the already-bundled `qrcode` lib, same as the // data URL (drawn server-side with the already-bundled `qrcode` lib, same as the
// device-owner provisioning QR). The raw secret is also returned for manual entry. // device-owner provisioning QR). The raw secret is also returned for manual entry.
router.post('/totp/setup', requireAuth, async (req, res) => { router.post('/totp/setup', requireAuth, asyncRoute(async (req, res) => {
const u = db.prepare('SELECT auth_provider, totp_enabled, email FROM users WHERE id = ?').get(req.user.id); const u = db.prepare('SELECT auth_provider, totp_enabled, email FROM users WHERE id = ?').get(req.user.id);
if (u.auth_provider !== 'local') return res.status(400).json({ error: 'TOTP is only for password accounts; your identity provider manages MFA.' }); if (u.auth_provider !== 'local') return res.status(400).json({ error: 'TOTP is only for password accounts; your identity provider manages MFA.' });
if (u.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled. Disable it first to re-enroll.' }); if (u.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled. Disable it first to re-enroll.' });
@ -382,7 +460,7 @@ router.post('/totp/setup', requireAuth, async (req, res) => {
try { qr_data_url = await QRCode.toDataURL(otpauth_uri, { errorCorrectionLevel: 'M', margin: 1, width: 240 }); } try { qr_data_url = await QRCode.toDataURL(otpauth_uri, { errorCorrectionLevel: 'M', margin: 1, width: 240 }); }
catch (e) { /* fall through with qr_data_url = null */ } catch (e) { /* fall through with qr_data_url = null */ }
res.json({ otpauth_uri, secret, qr_data_url }); res.json({ otpauth_uri, secret, qr_data_url });
}); }));
// Step 2: confirm a code from the user's app, THEN enable + issue recovery codes (once). // Step 2: confirm a code from the user's app, THEN enable + issue recovery codes (once).
router.post('/totp/enable', requireAuth, (req, res) => { router.post('/totp/enable', requireAuth, (req, res) => {
@ -851,11 +929,39 @@ function readCookie(req, name) {
for (const part of raw.split(';')) { for (const part of raw.split(';')) {
const eq = part.indexOf('='); const eq = part.indexOf('=');
if (eq === -1) continue; if (eq === -1) continue;
if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim()); if (part.slice(0, eq).trim() !== name) continue;
const value = part.slice(eq + 1).trim();
/*
* decodeURIComponent THROWS on a malformed escape `Cookie: st_oidc_tx=%` is a URIError.
* Anyone can send that, and this function is called before the handler's try block, so the
* throw used to reach the async boundary and take the process down (see asyncRoute below).
* A cookie we cannot decode is a cookie we do not have.
*/
try { return decodeURIComponent(value); } catch { return null; }
} }
return null; return null;
} }
/*
* Wrap an async handler so a rejection becomes a 500 instead of killing the server.
*
* Express 4 does not await handlers, so an async one that throws produces an unhandled rejection,
* and server.js turns that into process.exit(1) one malformed request, one dead instance, on a
* restart loop. This has now bitten three separate times on these routes (a state comparison, a
* cookie decode, a provider whose secret would not decrypt), each time because something threw
* OUTSIDE the handler's own try block. Fixing the individual throws does not fix the shape, so
* every async route here goes through this instead.
*/
function asyncRoute(handler) {
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch((err) => {
console.error(`[auth] unhandled error in ${req.method} ${req.path}:`, err && err.message);
if (res.headersSent) return;
// These two routes are browser redirects, not API calls; a JSON body would be shown as text.
if (req.path.startsWith('/oidc/')) return backToApp(res, { sso_error: 'server_error' });
res.status(500).json({ error: 'Something went wrong' });
});
}
/* /*
* The origin the provider will redirect back to. APP_URL pins it, exactly as the signup and invite * The origin the provider will redirect back to. APP_URL pins it, exactly as the signup and invite
* mails do, because the redirect_uri must match what is registered with the provider CHARACTER FOR * mails do, because the redirect_uri must match what is registered with the provider CHARACTER FOR
@ -917,7 +1023,7 @@ router.post('/sso/start', express.urlencoded({ extended: false }), (req, res) =>
res.redirect(`/api/auth/oidc/${encodeURIComponent(provider.slug)}/start`); res.redirect(`/api/auth/oidc/${encodeURIComponent(provider.slug)}/start`);
}); });
router.get('/oidc/:slug/start', async (req, res) => { router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
const provider = oidcProviders.get(req.params.slug); const provider = oidcProviders.get(req.params.slug);
if (!provider) return backToApp(res, { sso_error: 'unknown_provider' }); if (!provider) return backToApp(res, { sso_error: 'unknown_provider' });
@ -956,9 +1062,9 @@ router.get('/oidc/:slug/start', async (req, res) => {
console.error(`[oidc] ${req.params.slug} start failed:`, err.message); console.error(`[oidc] ${req.params.slug} start failed:`, err.message);
backToApp(res, { sso_error: 'provider_unavailable' }); backToApp(res, { sso_error: 'provider_unavailable' });
} }
}); }));
router.get('/oidc/:slug/callback', async (req, res) => { router.get('/oidc/:slug/callback', asyncRoute(async (req, res) => {
const provider = oidcProviders.get(req.params.slug); const provider = oidcProviders.get(req.params.slug);
if (!provider) return backToApp(res, { sso_error: 'unknown_provider' }); if (!provider) return backToApp(res, { sso_error: 'unknown_provider' });
@ -1036,17 +1142,12 @@ router.get('/oidc/:slug/callback', async (req, res) => {
* always had. An org provider is chosen by a customer, so it is confined to the domains that * always had. An org provider is chosen by a customer, so it is confined to the domains that
* customer registered and a domain cannot be registered while another organization holds it. * customer registered and a domain cannot be registered while another organization holds it.
* *
* This bounds the damage to domains a tenant claimed; it does NOT prove they own them. * The domains are the VERIFIED ones proved by a DNS record published in the domain itself so
* Claiming an unheld public domain is still possible and needs DNS verification. See the README. * this is confinement to what the tenant demonstrably controls, not to what they typed.
*/ */
if (provider.organizationId) { if (!emailAllowedForProvider(provider, email)) {
const at = email.lastIndexOf('@'); console.warn(`[oidc] ${provider.slug} asserted ${email}, outside its verified domains [${provider.emailDomains}]`);
const domain = at === -1 ? '' : email.slice(at + 1); return backToApp(res, { sso_error: 'domain_not_allowed' });
const allowed = String(provider.emailDomains || '').split(',').map((d) => d.trim()).filter(Boolean);
if (!domain || !allowed.includes(domain)) {
console.warn(`[oidc] ${provider.slug} asserted ${email}, outside its domains [${allowed.join(', ')}]`);
return backToApp(res, { sso_error: 'domain_not_allowed' });
}
} }
/* /*
* An unverified email is refused. The whole account model keys on email linking, invites, * An unverified email is refused. The whole account model keys on email linking, invites,
@ -1080,7 +1181,9 @@ router.get('/oidc/:slug/callback', async (req, res) => {
if (!already) { if (!already) {
db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_member')") db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_member')")
.run(provider.organizationId, user.id); .run(provider.organizationId, user.id);
logActivity(user.id, 'org_sso_joined', `via ${provider.name}`, provider.organizationId, getClientIp(req)); // (userId, action, details, deviceId, ipAddress, workspaceId) — the org id is NOT the 4th
// arg; it was landing in device_id, which has no FK to catch it.
logActivity(user.id, 'org_sso_joined', `via ${provider.name} org=${provider.organizationId}`, null, getClientIp(req));
} }
} }
@ -1101,7 +1204,19 @@ router.get('/oidc/:slug/callback', async (req, res) => {
* page exchanges it at /sso/claim. A link cannot forge that cookie, so a token can only be * page exchanges it at /sso/claim. A link cannot forge that cookie, so a token can only be
* claimed by the browser that actually completed the login. * claimed by the browser that actually completed the login.
*/ */
res.cookie(SSO_CLAIM_COOKIE, token, { /*
* The cookie carries a CLAIM token, not the session token itself. Two reasons, both learned:
* every token here is signed with the same secret, so a token minted for another purpose (a
* pre-TOTP `mfa_pending` one, say) was accepted by /sso/claim and returned the full user row;
* and the session token lives for days, so a copy of it sitting in a Set-Cookie header is worth
* stealing long after the login. This wrapper is good for 120 seconds and for nothing else.
*/
const claimToken = jwt.sign(
{ typ: 'sso-claim', tok: token, wsp: workspaceId || null },
config.jwtSecret,
{ algorithm: 'HS256', expiresIn: 120 },
);
res.cookie(SSO_CLAIM_COOKIE, claimToken, {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure: req.protocol === 'https', secure: req.protocol === 'https',
@ -1113,13 +1228,17 @@ router.get('/oidc/:slug/callback', async (req, res) => {
console.error(`[oidc] ${provider.slug} sign-in failed:`, err.message); console.error(`[oidc] ${provider.slug} sign-in failed:`, err.message);
backToApp(res, { sso_error: 'server_error' }); backToApp(res, { sso_error: 'server_error' });
} }
}); }));
/* /*
* Exchange the one-shot cookie for the session token. * Exchange the one-shot cookie for the session token.
* *
* POST so it cannot be triggered by a link or an <img>, and the cookie is cleared on the way out so * POST so it cannot be triggered by a link or an <img>, and the cookie is cleared on the way out.
* a second attempt gets nothing a token that leaks from a log or a back button is already spent. *
* Clearing a cookie asks the BROWSER to forget it; it does not invalidate anything. What bounds
* a leaked copy is the claim token's own 120-second expiry, which is why the session token is
* wrapped rather than handed over directly. Do not restore the comment that used to claim this was
* "already spent" it was not, and a review demonstrated the same cookie claiming twice.
*/ */
router.post('/sso/claim', (req, res) => { router.post('/sso/claim', (req, res) => {
const token = readCookie(req, SSO_CLAIM_COOKIE); const token = readCookie(req, SSO_CLAIM_COOKIE);
@ -1128,15 +1247,27 @@ router.post('/sso/claim', (req, res) => {
let claims; let claims;
try { try {
claims = jwt.verify(token, config.jwtSecret); // Pinned algorithm and an explicit `typ`: two token kinds signed with one secret must never be
// interchangeable, and this endpoint accepted anything the secret had touched.
claims = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
} catch { } catch {
return res.status(401).json({ error: 'That sign-in has expired' }); return res.status(401).json({ error: 'That sign-in has expired' });
} }
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(claims.id); if (claims.typ !== 'sso-claim' || !claims.tok) {
return res.status(401).json({ error: 'That sign-in has expired' });
}
let session;
try {
session = jwt.verify(claims.tok, config.jwtSecret, { algorithms: ['HS256'] });
} catch {
return res.status(401).json({ error: 'That sign-in has expired' });
}
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(session.id);
if (!user) return res.status(401).json({ error: 'That sign-in has expired' }); if (!user) return res.status(401).json({ error: 'That sign-in has expired' });
const { password_hash, totp_secret_enc, totp_last_step, ...safeUser } = user; const { password_hash, totp_secret_enc, totp_last_step, ...safeUser } = user;
res.json({ token, user: safeUser, current_workspace_id: claims.current_workspace_id || null }); res.json({ token: claims.tok, user: safeUser, current_workspace_id: claims.wsp || null });
}); });
/* /*
@ -1175,8 +1306,28 @@ function upsertFederatedUser({ claims, email, provider, req }) {
* one. An ORG provider therefore never adopts an account another provider established; the user * one. An ORG provider therefore never adopts an account another provider established; the user
* links it deliberately instead. * links it deliberately instead.
*/ */
if (provider.organizationId && existing.auth_provider && existing.auth_provider !== 'local') { if (provider.organizationId) {
return { error: 'account_exists_other_provider' }; /*
* `existing.auth_provider && … !== 'local'` failed OPEN on an empty string, and compared
* SLUGS, which got the two interesting cases backwards:
*
* - a customer replacing their identity provider (or an admin who deleted one and made
* another) got a new random slug, so their own org could no longer sign its own people in
* every SSO account in the tenant bricked, with no recovery route;
* - meanwhile an account owned by a DELETED provider looked adoptable to everyone.
*
* Ownership is therefore asked of the ORGANIZATION behind the slug, and the only states an
* org provider may take over are its own org's, and `local` with no password an invited
* user who has not set one yet, which is a real and wanted case.
*
* An account established by a provider that no longer exists is deliberately NOT adoptable:
* see the squatting note in the callback. It is recovered by proving control of the email
* through password reset, not by another identity provider asserting it.
*/
const owner = oidcProviders.ownerOf(existing.auth_provider);
const sameOrg = !!(owner && owner.organizationId && owner.organizationId === provider.organizationId);
const neverFederated = existing.auth_provider === 'local';
if (!sameOrg && !neverFederated) return { error: 'account_exists_other_provider' };
} }
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?') db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?')
.run(provider.slug, String(claims.sub), claims.picture || existing.avatar_url, existing.id); .run(provider.slug, String(claims.sub), claims.picture || existing.avatar_url, existing.id);
@ -1199,3 +1350,8 @@ function upsertFederatedUser({ claims, email, provider, req }) {
module.exports = router; module.exports = router;
// Exported for tests: these two carry the security decisions of the SSO flow, and testing them
// through a live identity provider only is how they shipped unverified the first time.
module.exports.emailAllowedForProvider = emailAllowedForProvider;
module.exports.upsertFederatedUser = upsertFederatedUser;
module.exports.isOrphanedFederated = isOrphanedFederated;

View file

@ -22,6 +22,8 @@ const secretbox = require('../lib/secretbox');
const oidc = require('../lib/oidc'); const oidc = require('../lib/oidc');
const { logActivity, getClientIp } = require('../services/activity'); const { logActivity, getClientIp } = require('../services/activity');
const { isPublicEmailDomain } = require('../lib/public-email-domains'); const { isPublicEmailDomain } = require('../lib/public-email-domains');
const domainVerify = require('../lib/domain-verify');
const emailSvc = require('../services/email');
/* /*
* Only an org owner/admin may configure how their people sign in it is the most security-relevant * Only an org owner/admin may configure how their people sign in it is the most security-relevant
@ -42,6 +44,29 @@ function requireOrgAdmin(req, res, next) {
next(); next();
} }
/*
* Configuring SSO requires a VERIFIED email address, on top of being an org admin.
*
* Everything else here rests on the identity of the person doing it: they claim domains, they point
* the organization at an identity provider, and they are who the operator's claim notification
* names. An unverified address is an assertion nobody has checked, so without this the entire
* feature including domain claims is reachable by anyone who can type an address into the
* signup form and never open the mail.
*
* Reads are deliberately NOT gated: seeing your own organization's configuration changes nothing,
* and locking an admin out of the screen that explains why sign-in is broken helps no one.
*/
function requireVerifiedAdmin(req, res, next) {
const row = db.prepare('SELECT email_verified FROM users WHERE id = ?').get(req.user.id);
if (!row || !row.email_verified) {
return res.status(403).json({
error: 'Verify your email address before configuring single sign-on.',
code: 'email_unverified',
});
}
next();
}
/* /*
* The slug is a URL path segment and is generated, never chosen. * The slug is a URL path segment and is generated, never chosen.
* *
@ -64,6 +89,9 @@ function toPublic(row) {
enabled: !!row.enabled, enabled: !!row.enabled,
login_url: `/api/auth/oidc/${row.slug}/start`, login_url: `/api/auth/oidc/${row.slug}/start`,
callback_url: `/api/auth/oidc/${row.slug}/callback`, callback_url: `/api/auth/oidc/${row.slug}/callback`,
// Domains and their proof state. A provider whose domains are all unverified can be saved and
// looks configured, but routes nobody — the UI needs this to say so rather than imply success.
domains: domainsFor(row.id),
}; };
} }
@ -105,28 +133,169 @@ function normaliseDomains(raw) {
* that company's logins the worst failure this feature could have. First claim wins; the loser is * 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. * told which domain clashed and nothing about who holds it.
*/ */
function assertDomainsFree(domains, orgId, excludeId) { function assertDomainsFree(domains, orgId, excludeProviderId) {
if (!domains) return; if (!domains) return;
const wanted = domains.split(','); for (const d of domains.split(',')) {
const rows = db.prepare("SELECT id, organization_id, email_domains FROM org_sso_providers WHERE email_domains != ''").all(); const row = db.prepare('SELECT * FROM org_sso_domains WHERE domain = ?').get(d);
for (const row of rows) { if (!row) continue;
if (row.id === excludeId) continue; if (row.provider_id && row.provider_id === excludeProviderId) continue;
const held = String(row.email_domains).split(','); /*
for (const d of wanted) { * A lapsed unverified claim reserves nothing. Clearing it here rather than on a timer means the
if (held.includes(d)) { * domain frees itself the moment someone else asks for it, and there is no sweeper to forget to
// Same-org duplicates were allowed and should not have been: two providers claiming one * run a squatter's unprovable claim simply stops being an obstacle.
// domain makes routing depend on table-scan order, so half a company's staff get sent to an */
// identity provider that has never heard of them. if (domainVerify.isClaimExpired(row)) {
const e = new Error(row.organization_id === orgId db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(row.id);
? `the domain ${d} is already used by another of your providers` continue;
: `the domain ${d} is already used for sign-in by another organization`);
e.status = 409;
throw e;
}
} }
// Same-org duplicates were allowed and should not have been: two providers claiming one
// domain makes routing depend on table-scan order, so half a company's staff get sent to an
// identity provider that has never heard of them.
const e = new Error(row.organization_id === orgId
? `the domain ${d} is already used by another of your providers`
: `the domain ${d} is already used for sign-in by another organization`);
e.status = 409;
throw e;
} }
} }
/*
* Bring the claimed-domain rows in line with what the admin typed.
*
* A newly claimed domain arrives UNVERIFIED and stays inert until DNS proves the claim it routes
* nobody and the login callback refuses assertions for it. Re-typing an existing domain must not
* reset that proof, which is why this diffs rather than deleting and re-inserting: a save on the
* name field would otherwise silently un-verify every domain the customer had already proved, and
* log their whole company out.
*
* Runs inside the caller's transaction so a domain cannot be reserved by two organizations at once.
*/
function syncDomains(providerId, orgId, domains) {
const wanted = domains ? domains.split(',').filter(Boolean) : [];
const existing = db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ?').all(providerId);
const stale = new Map(existing.map((r) => [r.domain, r]));
const claimed = []; // newly claimed, for the operator notification — sent AFTER the transaction
for (const d of wanted) {
const mine = stale.get(d);
if (mine) {
stale.delete(d);
/*
* Already ours keep a proof that happened. But a LAPSED claim must not be renewed by
* simply saving the form again, or the 8-hour limit would mean nothing: the token is rotated,
* which also means a record left in DNS from the previous attempt no longer matches. An old
* record lying around proves nothing about the claim being made now.
*/
if (domainVerify.isClaimExpired(mine)) {
db.prepare("UPDATE org_sso_domains SET token = ?, token_issued_at = strftime('%s','now'), last_error = NULL WHERE id = ?")
.run(domainVerify.newToken(), mine.id);
}
continue;
}
assertDomainsFree(d, orgId, providerId);
db.prepare(`INSERT INTO org_sso_domains (id, organization_id, provider_id, domain, token)
VALUES (?, ?, ?, ?, ?)`)
.run(crypto.randomUUID(), orgId, providerId, d, domainVerify.newToken());
claimed.push(d);
}
for (const row of stale.values()) {
db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(row.id);
}
return claimed;
}
/*
* Tell the operator that a tenant has claimed a domain.
*
* DNS verification makes a claim worthless without control of the domain, so this is not what stops
* abuse it is what makes abuse VISIBLE. A tenant claiming `microsoft.com` will never verify it,
* but an operator still wants to know somebody tried, and the notification is the difference
* between finding that out now and finding it out from the company involved.
*
* Deliberately NOT sent to postmaster@ the claimed domain. That would mean this product emails
* third parties who never signed up for it, on input any tenant can supply a spam cannon with a
* ScreenTinker return address. The operator can contact a domain owner; the server should not do it
* unprompted.
*
* Failure to send is logged and swallowed: a mail outage must not stop a customer configuring SSO.
*/
function notifyOperatorOfClaim(req, { domain, orgId, providerName }) {
try {
if (!emailSvc.isConfigured()) return;
const admins = db.prepare("SELECT email FROM users WHERE role = 'platform_admin' AND email_alerts = 1").all();
if (!admins.length) return;
const org = db.prepare('SELECT name FROM organizations WHERE id = ?').get(orgId);
const who = req.user && req.user.email ? req.user.email : 'an administrator';
const body = [
`${who} claimed the sign-in domain ${domain}.`,
'',
`Organization: ${org ? org.name : orgId} (${orgId})`,
`Provider: ${providerName}`,
'',
'The domain routes nobody until it is verified by a DNS record published in the domain itself,',
'and the claim lapses after 8 hours if it is not. No action is needed unless this looks wrong.',
].join('\n');
for (const a of admins) {
emailSvc.sendEmail({
to: a.email,
subject: `[ScreenTinker] SSO domain claimed: ${domain}`,
text: body,
}).catch((e) => console.error('[org-sso] claim notification failed:', e && e.message));
}
} catch (e) {
console.error('[org-sso] claim notification failed:', e && e.message);
}
}
/** A provider's domains, with the DNS record each unverified one still needs. */
function domainsFor(providerId) {
return db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ? ORDER BY domain').all(providerId)
.map((r) => ({
domain: r.domain,
verified: !!r.verified_at,
verified_at: r.verified_at,
last_checked_at: r.last_checked_at,
last_error: r.verified_at ? null : r.last_error,
// The token is not a secret — it only means anything published in that domain's own DNS.
...domainVerify.instructions(r.domain, r.token),
}));
}
/*
* What an admin is told when discovery fails.
*
* The temptation is to hand back the underlying message, because it is genuinely the most useful
* thing for a real misconfiguration. But the issuer is caller-supplied and fetched server-side, so
* that message is an SSRF read primitive: `https://internal-host:8080 responded 403` and
* `discovery issuer mismatch: … document says <X>` both report on services the caller cannot reach
* directly. The jwks branch of the /test endpoint was already genericised for exactly this reason;
* these paths were not, which left the scanner intact one line above the comment saying not to.
*
* So: the shape of the failure, never the upstream's answer. The full message goes to the log.
*/
function discoveryErrorMessage(e, issuer) {
const raw = String((e && e.message) || '');
console.warn(`[org-sso] discovery failed for ${issuer}: ${raw}`);
if (/must use https|not publicly routable|not a URL/i.test(raw)) return raw; // our own guard, no upstream data
if (/issuer mismatch/i.test(raw)) return 'that URL is not the OpenID issuer it claims to be';
if (/is missing /i.test(raw)) return 'that issuer published an incomplete OpenID configuration';
if (/redirected/i.test(raw)) return 'that issuer redirected; the URL must be the final one';
if (/abort|timeout/i.test(raw)) return 'that issuer did not respond in time';
return 'no OpenID configuration could be read from that URL';
}
/*
* Wrap an async handler so a rejection is a 500 rather than a dead server. Express 4 does not await
* handlers and server.js turns an unhandled rejection into process.exit see the longer note on
* asyncRoute in routes/auth.js, which is the same guard for the same reason.
*/
function asyncRoute(handler) {
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch((err) => {
console.error(`[org-sso] unhandled error in ${req.method} ${req.path}:`, err && err.message);
if (!res.headersSent) res.status(500).json({ error: 'Something went wrong' });
});
}
router.use(requireAuth, resolveTenancy); router.use(requireAuth, resolveTenancy);
// List an organization's providers. // List an organization's providers.
@ -135,7 +304,7 @@ router.get('/:orgId/sso', requireOrgAdmin, (req, res) => {
res.json({ providers: rows.map(toPublic) }); res.json({ providers: rows.map(toPublic) });
}); });
router.post('/:orgId/sso', requireOrgAdmin, async (req, res) => { router.post('/:orgId/sso', requireOrgAdmin, requireVerifiedAdmin, asyncRoute(async (req, res) => {
const { name, issuer, client_id: clientId, client_secret: clientSecret, scopes, email_domains: domains } = req.body || {}; const { name, issuer, client_id: clientId, client_secret: clientSecret, scopes, email_domains: domains } = req.body || {};
if (!name || !issuer || !clientId) { if (!name || !issuer || !clientId) {
return res.status(400).json({ error: 'name, issuer and client_id are required' }); return res.status(400).json({ error: 'name, issuer and client_id are required' });
@ -158,11 +327,12 @@ router.post('/:orgId/sso', requireOrgAdmin, async (req, res) => {
try { try {
await oidc.discover(String(issuer).trim().replace(/\/+$/, '')); await oidc.discover(String(issuer).trim().replace(/\/+$/, ''));
} catch (e) { } catch (e) {
return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${e.message}` }); return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${discoveryErrorMessage(e, issuer)}` });
} }
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const slug = newSlug(); const slug = newSlug();
let newlyClaimed = [];
/* /*
* Re-check the domains INSIDE the transaction. The first check happened before `await * Re-check the domains INSIDE the transaction. The first check happened before `await
* oidc.discover()`, which yields the event loop for a network round trip the caller's own IdP * oidc.discover()`, which yields the event loop for a network round trip the caller's own IdP
@ -178,17 +348,24 @@ router.post('/:orgId/sso', requireOrgAdmin, async (req, res) => {
`).run(id, req.orgId, slug, String(name).trim(), String(issuer).trim().replace(/\/+$/, ''), String(clientId).trim(), `).run(id, req.orgId, slug, String(name).trim(), String(issuer).trim().replace(/\/+$/, ''), String(clientId).trim(),
clientSecret ? secretbox.encrypt(String(clientSecret)) : null, clientSecret ? secretbox.encrypt(String(clientSecret)) : null,
String(scopes || 'openid email profile').trim(), cleanDomains); String(scopes || 'openid email profile').trim(), cleanDomains);
newlyClaimed = syncDomains(id, req.orgId, cleanDomains);
})(); })();
} catch (e) { } catch (e) {
return res.status(e.status || 500).json({ error: e.message }); if (e.status) return res.status(e.status).json({ error: e.message });
console.error('[org-sso] create failed:', e.message);
return res.status(500).json({ error: 'Could not save that provider' });
} }
// Notified after the transaction commits, so an operator is never told about a claim that rolled
// back — and never inside it, where a slow mail path would hold a write lock.
for (const d of newlyClaimed) notifyOperatorOfClaim(req, { domain: d, orgId: req.orgId, providerName: String(name).trim() });
// (userId, action, details, deviceId, ipAddress, workspaceId) — the org id is NOT the 4th arg. // (userId, action, details, deviceId, ipAddress, workspaceId) — the org id is NOT the 4th arg.
logActivity(req.user.id, 'org_sso_created', `${name} (${slug}) org=${req.orgId}`, null, getClientIp(req)); logActivity(req.user.id, 'org_sso_created', `${name} (${slug}) org=${req.orgId}`, null, getClientIp(req));
res.status(201).json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(id))); 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) => { router.put('/:orgId/sso/:id', requireOrgAdmin, requireVerifiedAdmin, asyncRoute(async (req, res) => {
const existing = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId); 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' }); if (!existing) return res.status(404).json({ error: 'Not found' });
@ -207,7 +384,7 @@ router.put('/:orgId/sso/:id', requireOrgAdmin, async (req, res) => {
const nextIssuer = issuer !== undefined ? String(issuer).trim().replace(/\/+$/, '') : existing.issuer; const nextIssuer = issuer !== undefined ? String(issuer).trim().replace(/\/+$/, '') : existing.issuer;
if (nextIssuer !== existing.issuer) { if (nextIssuer !== existing.issuer) {
try { await oidc.discover(nextIssuer); } try { await oidc.discover(nextIssuer); }
catch (e) { return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${e.message}` }); } catch (e) { return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${discoveryErrorMessage(e, nextIssuer)}` }); }
} }
/* /*
@ -215,28 +392,51 @@ router.put('/:orgId/sso/:id', requireOrgAdmin, async (req, res) => {
* returns the secret, so a UI that round-trips a form would otherwise blank it on every save * 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. * the classic way a settings page silently breaks the thing it is editing.
*/ */
let newlyClaimed = [];
const secretEnc = clientSecret === undefined ? existing.client_secret_enc const secretEnc = clientSecret === undefined ? existing.client_secret_enc
: (clientSecret === '' ? null : secretbox.encrypt(String(clientSecret))); : (clientSecret === '' ? null : secretbox.encrypt(String(clientSecret)));
db.prepare(` /*
UPDATE org_sso_providers * Same transaction, same re-check, and for the same reason as the create path above this one was
SET name = ?, issuer = ?, client_id = ?, client_secret_enc = ?, scopes = ?, email_domains = ?, enabled = ?, * missed when that was fixed, which left the race fully open on the route an attacker would
updated_at = strftime('%s','now') * actually pick: `await oidc.discover()` on a CHANGED issuer is a round trip whose length the
WHERE id = ? * caller's own IdP decides, so it can be held open for the full fetch timeout while a victim
`).run( * organization claims the domain legitimately. Both rows then hold it, and forEmail's
name !== undefined ? String(name).trim() : existing.name, * `ORDER BY created_at` hands routing to the OLDER row the attacker's.
nextIssuer, */
clientId !== undefined ? String(clientId).trim() : existing.client_id, try {
secretEnc, db.transaction(() => {
scopes !== undefined ? String(scopes).trim() : existing.scopes, if (domains !== undefined) assertDomainsFree(cleanDomains, req.orgId, existing.id);
cleanDomains, db.prepare(`
enabled === undefined ? existing.enabled : (enabled ? 1 : 0), UPDATE org_sso_providers
existing.id, SET name = ?, issuer = ?, client_id = ?, client_secret_enc = ?, scopes = ?, email_domains = ?, enabled = ?,
); updated_at = strftime('%s','now')
WHERE id = ?
`).run(
name !== undefined ? String(name).trim() : existing.name,
nextIssuer,
clientId !== undefined ? String(clientId).trim() : existing.client_id,
secretEnc,
scopes !== undefined ? String(scopes).trim() : existing.scopes,
cleanDomains,
enabled === undefined ? existing.enabled : (enabled ? 1 : 0),
existing.id,
);
if (domains !== undefined) newlyClaimed = syncDomains(existing.id, req.orgId, cleanDomains);
})();
} catch (e) {
// A thrown assertDomainsFree carries its own status; anything else is ours and stays generic
// rather than returning a raw SQLite message to the caller.
if (e.status) return res.status(e.status).json({ error: e.message });
console.error('[org-sso] update failed:', e.message);
return res.status(500).json({ error: 'Could not save that provider' });
}
for (const d of newlyClaimed) notifyOperatorOfClaim(req, { domain: d, orgId: req.orgId, providerName: existing.name });
logActivity(req.user.id, 'org_sso_updated', `${existing.name} (${existing.slug}) org=${req.orgId}`, null, getClientIp(req)); logActivity(req.user.id, 'org_sso_updated', `${existing.name} (${existing.slug}) org=${req.orgId}`, null, getClientIp(req));
res.json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(existing.id))); res.json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(existing.id)));
}); }));
/* /*
* Check a provider without making anyone log in. * Check a provider without making anyone log in.
@ -251,7 +451,7 @@ router.put('/:orgId/sso/:id', requireOrgAdmin, async (req, res) => {
* matches, or that the redirect URI is registered only a real authorization round trip does that, * matches, or that the redirect URI is registered only a real authorization round trip does that,
* and the response says so rather than implying a green tick means "SSO works". * and the response says so rather than implying a green tick means "SSO works".
*/ */
router.post('/:orgId/sso/:id/test', requireOrgAdmin, async (req, res) => { router.post('/:orgId/sso/:id/test', requireOrgAdmin, asyncRoute(async (req, res) => {
const row = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId); const row = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId);
if (!row) return res.status(404).json({ error: 'Not found' }); if (!row) return res.status(404).json({ error: 'Not found' });
@ -261,7 +461,7 @@ router.post('/:orgId/sso/:id/test', requireOrgAdmin, async (req, res) => {
doc = await oidc.discover(row.issuer); doc = await oidc.discover(row.issuer);
checks.push({ name: 'discovery', ok: true, detail: doc.issuer }); checks.push({ name: 'discovery', ok: true, detail: doc.issuer });
} catch (e) { } catch (e) {
checks.push({ name: 'discovery', ok: false, detail: e.message }); checks.push({ name: 'discovery', ok: false, detail: discoveryErrorMessage(e, row.issuer) });
return res.json({ ok: false, checks }); return res.json({ ok: false, checks });
} }
@ -295,9 +495,71 @@ router.post('/:orgId/sso/:id/test', requireOrgAdmin, async (req, res) => {
// Said plainly so a passing test is not mistaken for a working login. // Said plainly so a passing test is not mistaken for a working login.
note: 'unverifiable_by_test', note: 'unverifiable_by_test',
}); });
}); }));
router.delete('/:orgId/sso/:id', requireOrgAdmin, (req, res) => { /*
* Check DNS for the proof, and record the answer.
*
* Verification is the whole point of the domain table: until this succeeds the domain routes nobody
* and the login callback refuses to accept an assertion for it, so a claim on a domain the tenant
* does not control buys them nothing at all.
*
* Deliberately pull-based rather than a background sweep. The admin has just edited DNS and wants to
* know now, and a per-request check means there is no scheduler to fall over quietly and no window
* where a verified domain sits unnoticed.
*/
router.post('/:orgId/sso/:id/domains/:domain/verify', requireOrgAdmin, requireVerifiedAdmin, asyncRoute(async (req, res) => {
const provider = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?')
.get(req.params.id, req.orgId);
if (!provider) return res.status(404).json({ error: 'Not found' });
const row = db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ? AND domain = ?')
.get(provider.id, String(req.params.domain).toLowerCase());
if (!row) return res.status(404).json({ error: 'Not found' });
if (row.verified_at) return res.json({ ok: true, domain: row.domain, verified: true, already: true });
/*
* A lapsed claim is not checked at all it is reissued. Checking first would let a squatter keep
* an expired claim alive indefinitely by leaving one record in place, which is exactly what the
* time limit exists to prevent, and the new token means the old record no longer matches.
*/
if (domainVerify.isClaimExpired(row)) {
const token = domainVerify.newToken();
db.prepare("UPDATE org_sso_domains SET token = ?, token_issued_at = strftime('%s','now'), last_error = NULL, last_checked_at = strftime('%s','now') WHERE id = ?")
.run(token, row.id);
return res.status(409).json({
ok: false,
domain: row.domain,
verified: false,
expired: true,
error: 'That verification expired, so a new record has been issued. Publish the new value and check again.',
...domainVerify.instructions(row.domain, token),
});
}
const result = await domainVerify.check(row.domain, row.token);
if (result.ok) {
db.prepare("UPDATE org_sso_domains SET verified_at = strftime('%s','now'), last_checked_at = strftime('%s','now'), last_error = NULL WHERE id = ?")
.run(row.id);
logActivity(req.user.id, 'org_sso_domain_verified', `${row.domain} via ${result.via} org=${req.orgId}`, null, getClientIp(req));
console.log(`[org-sso] ${row.domain} verified via ${result.via} for org ${req.orgId}`);
return res.json({ ok: true, domain: row.domain, verified: true, via: result.via });
}
db.prepare("UPDATE org_sso_domains SET last_checked_at = strftime('%s','now'), last_error = ? WHERE id = ?")
.run(result.error, row.id);
res.status(400).json({
ok: false,
domain: row.domain,
verified: false,
error: result.error,
...domainVerify.instructions(row.domain, row.token),
});
}));
router.delete('/:orgId/sso/:id', requireOrgAdmin, requireVerifiedAdmin, (req, res) => {
const existing = db.prepare('SELECT * FROM org_sso_providers WHERE id = ? AND organization_id = ?').get(req.params.id, req.orgId); 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' }); if (!existing) return res.status(404).json({ error: 'Not found' });
db.prepare('DELETE FROM org_sso_providers WHERE id = ?').run(existing.id); db.prepare('DELETE FROM org_sso_providers WHERE id = ?').run(existing.id);

View file

@ -529,7 +529,23 @@ function rateLimit(windowMs, maxRequests) {
// req.path was '/' for ALL of them - i.e. /login, /register, /totp/verify shared // req.path was '/' for ALL of them - i.e. /login, /register, /totp/verify shared
// ONE per-IP counter (coupled limits; the /totp/verify brute-force limit wasn't // ONE per-IP counter (coupled limits; the /totp/verify brute-force limit wasn't
// actually independent). originalUrl keeps each endpoint's limit separate. // actually independent). originalUrl keeps each endpoint's limit separate.
const key = getClientIp(req) + (req.originalUrl || req.url || req.path).split('?')[0]; /*
* NORMALISE THE PATH, or the key is caller-controlled and the limit is decorative.
*
* Express routes non-strictly, so `/api/auth/login/` reaches the same handler as
* `/api/auth/login` with a different originalUrl, hence a different bucket, hence a fresh ten
* attempts. A review walked straight past the login limiter that way. Any path segment the
* caller chooses does the same thing, and `/api/auth/oidc/:slug/...` has one by design, so the
* slug is folded out too: one bucket per IP per ENDPOINT, not per spelling of it.
*/
const rawPath = (req.originalUrl || req.url || req.path).split('?')[0];
const normalisedPath = rawPath
.replace(/\/{2,}/g, '/') // collapse doubled separators
.replace(/\/+$/, '') // ignore a trailing slash
.toLowerCase()
.replace(/^(\/api\/auth\/oidc)\/[^/]+/, '$1') // the slug is not a distinct endpoint
|| '/';
const key = getClientIp(req) + normalisedPath;
const now = Date.now(); const now = Date.now();
const windowStart = now - windowMs; const windowStart = now - windowMs;
let hits = rateLimits.get(key) || []; let hits = rateLimits.get(key) || [];

View file

@ -293,16 +293,34 @@ function orgDb() {
scopes TEXT NOT NULL DEFAULT 'openid email profile', email_domains TEXT NOT NULL DEFAULT '', scopes TEXT NOT NULL DEFAULT 'openid email profile', email_domains TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0); created_at INTEGER NOT NULL DEFAULT 0, updated_at 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,
last_checked_at INTEGER, last_error TEXT, created_at INTEGER NOT NULL DEFAULT 0);
`); `);
return d; return d;
} }
/*
* `domains` are VERIFIED (DNS proof recorded); `pending` are claimed but unproven. The distinction
* is the whole point of the domain table, so the harness makes it impossible to write a test that
* blurs the two: a test that wants routing must say which state it is testing.
*/
function withOrgDb(rows, fn) { function withOrgDb(rows, fn) {
const d = orgDb(); const d = orgDb();
let n = 0;
for (const r of rows) { for (const r of rows) {
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) d.prepare(`INSERT INTO org_sso_providers (id, organization_id, slug, name, issuer, client_id, email_domains, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(r.id, r.org, r.slug, r.name, r.issuer || ISSUER, r.clientId || 'cid', r.domains || '', r.enabled === undefined ? 1 : r.enabled); .run(r.id, r.org, r.slug, r.name, r.issuer || ISSUER, r.clientId || 'cid', typed, r.enabled === undefined ? 1 : r.enabled);
const addDomain = (dom, verifiedAt) => d.prepare(
`INSERT INTO org_sso_domains (id, organization_id, provider_id, domain, token, token_issued_at, verified_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(`dom${++n}`, r.org, r.id, dom, `tok${n}`, Math.floor(Date.now() / 1000), verifiedAt);
// Verified in claim order unless the test pins it, so "who proved it first" stays testable.
for (const dom of (r.domains || '').split(',').filter(Boolean)) addDomain(dom, (r.verifiedAt || 1000) + n);
for (const dom of (r.pending || '').split(',').filter(Boolean)) addDomain(dom, null);
} }
// Swap the module's lazily-resolved connection for this in-memory one. // Swap the module's lazily-resolved connection for this in-memory one.
const real = require('../db/database'); const real = require('../db/database');
@ -359,16 +377,16 @@ test('an instance provider wins a slug clash with an org one', () => {
}); });
}); });
test('the first organization to claim a domain keeps it', () => { test('a domain can only be held by one organization AT THE DATABASE', () => {
// Two rows, same domain. forEmail must be deterministic rather than returning whichever the // Uniqueness used to be enforced only by a check in the route, which a race defeated twice in
// database happened to hand back first — the API refuses the second claim, and this is the // review. It is now a UNIQUE constraint, so a second claim cannot exist even if the check is
// backstop if a row ever gets in another way. // bypassed entirely — the strongest form of "first claim wins" available here.
withOrgDb([ assert.throws(() => {
{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'First', domains: 'shared.com' }, withOrgDb([
{ id: '2', org: 'org-b', slug: 'orgbbb', name: 'Second', domains: 'shared.com' }, { id: '1', org: 'org-a', slug: 'orgaaa', name: 'First', domains: 'shared.com' },
], (m) => { { id: '2', org: 'org-b', slug: 'orgbbb', name: 'Second', domains: 'shared.com' },
assert.equal(m.forEmail('x@shared.com').name, 'First'); ], () => {});
}); }, /UNIQUE/);
}); });
test('no database means no org providers, and no crash', () => { test('no database means no org providers, and no crash', () => {
@ -395,6 +413,7 @@ test('TAKEOVER: an org provider may not assert an email outside its own domains'
withOrgDb([{ id: '1', org: 'org-evil', slug: 'orgevil', name: 'Evil', domains: 'evil.test' }], (m) => { withOrgDb([{ id: '1', org: 'org-evil', slug: 'orgevil', name: 'Evil', domains: 'evil.test' }], (m) => {
const p = m.getOrgProvider('orgevil'); const p = m.getOrgProvider('orgevil');
assert.equal(p.emailDomains, 'evil.test', 'the callback cannot confine what it cannot see'); assert.equal(p.emailDomains, 'evil.test', 'the callback cannot confine what it cannot see');
assert.ok(!p.emailDomains.includes('victim'), 'and only ever the domains that were PROVED');
assert.equal(p.organizationId, 'org-evil', 'and must know this is a tenant provider, not the operator\'s'); assert.equal(p.organizationId, 'org-evil', 'and must know this is a tenant provider, not the operator\'s');
}); });
}); });
@ -406,15 +425,43 @@ test('an INSTANCE provider carries no organization, so it is not domain-confined
assert.equal(g.source, 'env'); assert.equal(g.source, 'env');
}); });
test('domain routing is deterministic, not table-scan order', () => { test('domain routing follows who VERIFIED first, not table-scan order', () => {
// forEmail used an unordered SELECT, so deleting and re-adding a provider silently flipped which /*
// IdP an entire domain routed to. Ordering makes the answer stable. * forEmail used an unordered SELECT, so deleting and re-adding a provider silently flipped which
* IdP an entire domain routed to. The earlier version of this test asserted only that two calls
* agreed with each other, which an unordered scan satisfies within one process it passed with
* the ordering removed and was therefore worth nothing. Assert the WINNER.
*/
withOrgDb([ withOrgDb([
{ id: 'b', org: 'org-a', slug: 'orgbbb', name: 'Second', domains: 'shared.test' }, { id: 'b', org: 'org-a', slug: 'orgbbb', name: 'Later', domains: 'later.test', verifiedAt: 9000 },
{ id: 'a', org: 'org-a', slug: 'orgaaa', name: 'First', domains: 'shared.test' }, { id: 'a', org: 'org-a', slug: 'orgaaa', name: 'Earlier', domains: 'earlier.test', verifiedAt: 1000 },
], (m) => { ], (m) => {
const first = m.forEmail('x@shared.test').name; assert.equal(m.forEmail('x@earlier.test').name, 'Earlier');
assert.equal(m.forEmail('x@shared.test').name, first, 'same answer every time'); assert.equal(m.forEmail('x@later.test').name, 'Later');
});
});
test('AN UNVERIFIED DOMAIN ROUTES NOBODY', () => {
/*
* The point of DNS verification. A tenant may type any domain including a company they have
* nothing to do with and until a record proves control it must buy them nothing: no routing,
* and (see the callback tests) no ability to assert an address inside it.
*/
withOrgDb([{ id: '1', org: 'org-x', slug: 'orgxxx', name: 'Squatter', pending: 'victim-corp.test' }], (m) => {
assert.equal(m.forEmail('ceo@victim-corp.test'), null, 'a claim is not a proof');
const p = m.getOrgProvider('orgxxx');
assert.equal(p.emailDomains, '', 'and the callback is given nothing it may confine to');
});
});
test('verifying one domain does not carry over to the others claimed with it', () => {
withOrgDb([{
id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme',
domains: 'acme.test', pending: 'acme-partner.test',
}], (m) => {
assert.equal(m.forEmail('x@acme.test').name, 'Acme');
assert.equal(m.forEmail('x@acme-partner.test'), null);
assert.equal(m.getOrgProvider('orgaaa').emailDomains, 'acme.test');
}); });
}); });
@ -454,3 +501,103 @@ test('the blocklist is case- and whitespace-insensitive', () => {
assert.ok(isPublicEmailDomain(' GMAIL.COM ')); assert.ok(isPublicEmailDomain(' GMAIL.COM '));
assert.ok(isPublicEmailDomain('Outlook.Com')); assert.ok(isPublicEmailDomain('Outlook.Com'));
}); });
// ---------------------------------------------------------------------------------------------
// The confinement itself.
//
// Everything above tests the DATA the callback confines against. These test the DECISION, which is
// what actually stops the takeover — and each one below was checked by reverting the guard and
// confirming the test goes red. A security test that passes against the vulnerable code is worse
// than no test, because it is read as coverage.
const authRoutes = require('../routes/auth');
const { emailAllowedForProvider } = authRoutes;
const orgProvider = (domains) => ({ slug: 'orgabc', organizationId: 'org-a', emailDomains: domains });
test('CONFINEMENT: an org provider may only assert inside its verified domains', () => {
const p = orgProvider('acme.test');
assert.equal(emailAllowedForProvider(p, 'staff@acme.test'), true);
assert.equal(emailAllowedForProvider(p, 'victim@other.test'), false, 'THE TAKEOVER');
assert.equal(emailAllowedForProvider(p, 'admin@screentinker.com'), false);
});
test('CONFINEMENT: a provider with nothing verified may assert NOTHING', () => {
// The squatting case. A tenant types a domain, proves nothing, and must get nowhere — including
// for the domain they typed.
const p = orgProvider('');
assert.equal(emailAllowedForProvider(p, 'ceo@victim-corp.test'), false);
assert.equal(emailAllowedForProvider(p, 'anyone@anywhere.test'), false);
});
test('CONFINEMENT: the domain cannot be smuggled past the check', () => {
const p = orgProvider('acme.test');
for (const evil of [
'victim@other.test', // plainly outside
'victim@acme.test.evil.test', // suffix, not the domain
'victim@evil.test@acme.test\n', // trailing newline
'victim@sub.acme.test', // subdomain is a different domain
'victim@acme.test.', // trailing dot
'victim@ACME.TEST.EVIL.TEST',
'no-at-sign',
'victim@',
'',
]) {
assert.equal(emailAllowedForProvider(p, evil), false, `must refuse: ${JSON.stringify(evil)}`);
}
// ...while the legitimate forms still work, including the ones case normalisation must handle.
assert.equal(emailAllowedForProvider(p, 'Staff@Acme.Test'), true);
assert.equal(emailAllowedForProvider(p, 'a.b+tag@acme.test'), true);
});
test('CONFINEMENT: an INSTANCE provider is exempt, because the operator chose it', () => {
// Per-org verification is for tenant-supplied providers only. The instance's own Google or Okta
// is the operator's decision and is not domain-restricted — the same trust it has always had.
const instance = { slug: 'google', emailDomains: '' };
assert.equal(emailAllowedForProvider(instance, 'anyone@anywhere.test'), true);
assert.equal(emailAllowedForProvider(instance, 'admin@gmail.com'), true);
});
// ---------------------------------------------------------------------------------------------
// Domain ownership.
//
// A claim is not a proof. These pin the part that makes that true: an unverified domain routes
// nobody, a claim lapses so it cannot be held forever, and a lapsed claim's token is dead so a
// record left behind from an earlier attempt cannot satisfy a later one.
const domainVerify = require('../lib/domain-verify');
const NOW = 1800000000;
test('an unverified claim lapses after 8 hours; a verified one never does', () => {
const claim = (agoS, verified) => ({ token_issued_at: NOW - agoS, verified_at: verified ? NOW - 99 : null });
assert.equal(domainVerify.isClaimExpired(claim(60, false), NOW), false, 'a minute old');
assert.equal(domainVerify.isClaimExpired(claim(8 * 3600 - 30, false), NOW), false, 'just inside');
assert.equal(domainVerify.isClaimExpired(claim(8 * 3600 + 1, false), NOW), true, 'just outside');
// Proof does not rot. Re-verifying on a timer would log a customer out over a DNS edit made
// months after they legitimately proved the domain.
assert.equal(domainVerify.isClaimExpired(claim(365 * 86400, true), NOW), false, 'verified, a year old');
});
test('the DNS record is per-domain and per-claim, so an old record proves nothing', () => {
const a = domainVerify.newToken();
const b = domainVerify.newToken();
assert.notEqual(a, b, 'two claims never share a token');
assert.ok(a.length >= 32, 'not guessable');
const one = domainVerify.instructions('acme.test', a);
const two = domainVerify.instructions('acme.test', b);
assert.equal(one.record_name, '_screentinker-verify.acme.test');
assert.notEqual(one.txt_value, two.txt_value, 'reissuing changes what must be published');
assert.notEqual(one.cname_value, two.cname_value);
// The record lives at a dedicated name, never the apex, where it would sit beside SPF and DMARC.
assert.ok(!domainVerify.instructions('acme.test', a).record_name.startsWith('acme.test'));
});
test('a lapsed claim frees the domain for someone else', () => {
// The anti-squat property: a domain nobody can prove cannot be held indefinitely by whoever typed
// it first. Modelled here on the same predicate the route uses to decide whether a row blocks.
const squatter = { domain: 'victim-corp.test', token_issued_at: NOW - (9 * 3600), verified_at: null };
const owner = { domain: 'victim-corp.test', token_issued_at: NOW - 60, verified_at: NOW };
assert.equal(domainVerify.isClaimExpired(squatter, NOW), true, 'the squatter no longer blocks it');
assert.equal(domainVerify.isClaimExpired(owner, NOW), false, 'the real owner, having proved it, does');
});