mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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:
parent
d26aaebef6
commit
d4b8d7dad4
36
README.md
36
README.md
|
|
@ -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
|
||||
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
|
||||
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`
|
||||
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
|
||||
deny a public domain to everyone else.
|
||||
|
||||
⚠️ **Domain ownership is not yet verified.** A claimed domain currently means "no other organization
|
||||
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
|
||||
control, and until it exists a domain claim should be treated as a support-reviewable action.
|
||||
### Proving a domain
|
||||
|
||||
A claimed domain **routes nobody and authenticates nobody until DNS proves the organization controls
|
||||
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
|
||||
(`org_member`). Existing members keep whatever role they already have — logging in never promotes or
|
||||
|
|
|
|||
|
|
@ -151,6 +151,16 @@ export default {
|
|||
'sso.disable': 'Disable',
|
||||
'sso.disabled': 'disabled',
|
||||
'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.f_name': 'Display name',
|
||||
'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_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.',
|
||||
// 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.back_to_signin': 'Back to Sign In',
|
||||
// TOTP 2FA challenge (second login step)
|
||||
|
|
|
|||
|
|
@ -581,7 +581,8 @@ function setupHandlers(config, isSetup) {
|
|||
// 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',
|
||||
'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';
|
||||
showToast(t(key), 'error');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`}
|
||||
<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>
|
||||
${(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 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>
|
||||
|
|
@ -721,6 +724,32 @@ export async function render(container) {
|
|||
|
||||
<!-- 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. -->
|
||||
<!-- 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="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">
|
||||
|
|
@ -757,6 +786,44 @@ export async function render(container) {
|
|||
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) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.ssoTest;
|
||||
|
|
|
|||
|
|
@ -84,11 +84,14 @@ module.exports = {
|
|||
return secret;
|
||||
})(),
|
||||
jwtExpiry: '7d',
|
||||
// Google OAuth - set these in env or here
|
||||
googleClientId: process.env.GOOGLE_CLIENT_ID || '',
|
||||
// Microsoft OAuth - set these in env or here
|
||||
microsoftClientId: process.env.MICROSOFT_CLIENT_ID || '',
|
||||
microsoftTenantId: process.env.MICROSOFT_TENANT_ID || 'common',
|
||||
/*
|
||||
* Google and Microsoft sign-in are configured through lib/oidc-providers.js, which reads
|
||||
* process.env directly — there is nothing here for it to read, so these fields were dead, and
|
||||
* `microsoftTenantId` defaulting to 'common' actively contradicted the provider code, which now
|
||||
* 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)
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
|
|
|
|||
|
|
@ -430,6 +430,40 @@ const migrations = [
|
|||
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)",
|
||||
/*
|
||||
* 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 video_mode TEXT",
|
||||
// Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because
|
||||
|
|
|
|||
138
server/lib/domain-verify.js
Normal file
138
server/lib/domain-verify.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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. */
|
||||
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) {
|
||||
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(',')) {
|
||||
const slug = raw.trim().toLowerCase();
|
||||
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);
|
||||
if (p) { out.push(p); seen.add(slug); }
|
||||
}
|
||||
|
|
@ -179,11 +187,31 @@ function rowToProvider(row, secretbox) {
|
|||
scopes: row.scopes || DEFAULT_SCOPES,
|
||||
source: 'org',
|
||||
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. */
|
||||
function getOrgProvider(slug) {
|
||||
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.
|
||||
*
|
||||
|
|
@ -222,14 +275,27 @@ function forEmail(email) {
|
|||
const domain = String(email).slice(at + 1).toLowerCase().trim();
|
||||
if (!domain) return null;
|
||||
try {
|
||||
const rows = conn.prepare("SELECT * FROM org_sso_providers WHERE enabled = 1 AND email_domains != '' ORDER BY created_at, id").all();
|
||||
const secretbox = require('./secretbox');
|
||||
for (const row of rows) {
|
||||
const domains = String(row.email_domains || '').split(',').map((d) => d.trim().toLowerCase()).filter(Boolean);
|
||||
if (domains.includes(domain)) return rowToProvider(row, secretbox);
|
||||
}
|
||||
} catch { /* table not migrated yet */ }
|
||||
/*
|
||||
* Routing is driven by the VERIFIED domain table, not by the text an admin typed, and the join
|
||||
* is what enforces it — an unverified claim cannot send anyone anywhere. Ordering by the
|
||||
* verification time makes the winner of any residual tie the one who PROVED it first, rather
|
||||
* than whichever row a table scan reached.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
module.exports = { list, get, publicList, getOrgProvider, forEmail, DEFAULT_SCOPES, SLUG_RE };
|
||||
module.exports = { list, get, publicList, getOrgProvider, ownerOf, forEmail, DEFAULT_SCOPES, SLUG_RE };
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const net = require('net');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
/*
|
||||
|
|
@ -52,21 +53,78 @@ const jwksCache = new Map(); // jwks_uri -> { at, keys }
|
|||
* Two rules, both cheap:
|
||||
* 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.
|
||||
* public hosts only — loopback, RFC1918, link-local (169.254.169.254 is cloud metadata) and the
|
||||
* IPv6 equivalents are refused outright.
|
||||
* public hosts only — loopback, RFC1918, CGNAT, link-local (169.254.169.254 is cloud metadata),
|
||||
* 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
|
||||
* 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",
|
||||
* and the README says so rather than implying more.
|
||||
* fetch does not expose. It raises the bar from "type an internal URL" to "control public DNS".
|
||||
* 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) {
|
||||
let u;
|
||||
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 (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;
|
||||
}
|
||||
|
||||
|
|
@ -257,6 +315,7 @@ async function fetchJwks(jwksUri) {
|
|||
|
||||
module.exports = {
|
||||
discover,
|
||||
assertFetchable,
|
||||
fetchJwks,
|
||||
verifyIdToken,
|
||||
exchangeCode,
|
||||
|
|
|
|||
174
server/package-lock.json
generated
174
server/package-lock.json
generated
|
|
@ -15,7 +15,6 @@
|
|||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
|
|
@ -931,6 +930,7 @@
|
|||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
|
|
@ -1314,15 +1314,6 @@
|
|||
"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": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
|
|
@ -1845,15 +1836,6 @@
|
|||
"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": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
|
@ -2347,12 +2329,6 @@
|
|||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
|
|
@ -2415,29 +2391,6 @@
|
|||
"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": {
|
||||
"version": "1.0.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
|
|
@ -2550,34 +2491,6 @@
|
|||
"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": {
|
||||
"version": "2.0.5",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
|
|
@ -2857,6 +2744,7 @@
|
|||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
|
|
@ -2870,6 +2758,7 @@
|
|||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
|
|
@ -2887,6 +2776,7 @@
|
|||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
|
|
@ -3022,15 +2912,6 @@
|
|||
"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": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
||||
|
|
@ -3371,44 +3252,6 @@
|
|||
"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": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
|
|
@ -4845,15 +4688,6 @@
|
|||
"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": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
|
|
@ -32,7 +31,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,12 +278,80 @@ router.post('/resend-verification', (req, res) => {
|
|||
// 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.' };
|
||||
|
||||
/*
|
||||
* 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) => {
|
||||
const email = String(req.body?.email || '').toLowerCase().trim();
|
||||
// Respond identically no matter what happens below.
|
||||
try {
|
||||
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 (!emailSvc.isConfigured()) {
|
||||
// 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
|
||||
// control of the mailbox and choosing a new password.
|
||||
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));
|
||||
console.log(`[password-reset] password changed for ${u ? u.email : userId}`);
|
||||
// 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
|
||||
// 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.
|
||||
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);
|
||||
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.' });
|
||||
|
|
@ -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 }); }
|
||||
catch (e) { /* fall through with qr_data_url = null */ }
|
||||
res.json({ otpauth_uri, secret, qr_data_url });
|
||||
});
|
||||
}));
|
||||
|
||||
// Step 2: confirm a code from the user's app, THEN enable + issue recovery codes (once).
|
||||
router.post('/totp/enable', requireAuth, (req, res) => {
|
||||
|
|
@ -851,11 +929,39 @@ function readCookie(req, name) {
|
|||
for (const part of raw.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
* 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
|
||||
* 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`);
|
||||
});
|
||||
|
||||
router.get('/oidc/:slug/start', async (req, res) => {
|
||||
router.get('/oidc/:slug/start', asyncRoute(async (req, res) => {
|
||||
const provider = oidcProviders.get(req.params.slug);
|
||||
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);
|
||||
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);
|
||||
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
|
||||
* 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.
|
||||
* Claiming an unheld public domain is still possible and needs DNS verification. See the README.
|
||||
* The domains are the VERIFIED ones — proved by a DNS record published in the domain itself — so
|
||||
* this is confinement to what the tenant demonstrably controls, not to what they typed.
|
||||
*/
|
||||
if (provider.organizationId) {
|
||||
const at = email.lastIndexOf('@');
|
||||
const domain = at === -1 ? '' : email.slice(at + 1);
|
||||
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' });
|
||||
}
|
||||
if (!emailAllowedForProvider(provider, email)) {
|
||||
console.warn(`[oidc] ${provider.slug} asserted ${email}, outside its verified domains [${provider.emailDomains}]`);
|
||||
return backToApp(res, { sso_error: 'domain_not_allowed' });
|
||||
}
|
||||
/*
|
||||
* 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) {
|
||||
db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_member')")
|
||||
.run(provider.organizationId, user.id);
|
||||
logActivity(user.id, 'org_sso_joined', `via ${provider.name}`, provider.organizationId, getClientIp(req));
|
||||
// (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
|
||||
* 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,
|
||||
sameSite: 'lax',
|
||||
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);
|
||||
backToApp(res, { sso_error: 'server_error' });
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/*
|
||||
* 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
|
||||
* a second attempt gets nothing — a token that leaks from a log or a back button is already spent.
|
||||
* POST so it cannot be triggered by a link or an <img>, and the cookie is cleared on the way out.
|
||||
*
|
||||
* ⚠️ 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) => {
|
||||
const token = readCookie(req, SSO_CLAIM_COOKIE);
|
||||
|
|
@ -1128,15 +1247,27 @@ router.post('/sso/claim', (req, res) => {
|
|||
|
||||
let claims;
|
||||
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 {
|
||||
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' });
|
||||
|
||||
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
|
||||
* links it deliberately instead.
|
||||
*/
|
||||
if (provider.organizationId && existing.auth_provider && existing.auth_provider !== 'local') {
|
||||
return { error: 'account_exists_other_provider' };
|
||||
if (provider.organizationId) {
|
||||
/*
|
||||
* `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 = ?')
|
||||
.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;
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ const secretbox = require('../lib/secretbox');
|
|||
const oidc = require('../lib/oidc');
|
||||
const { logActivity, getClientIp } = require('../services/activity');
|
||||
const { isPublicEmailDomain } = require('../lib/public-email-domains');
|
||||
const domainVerify = require('../lib/domain-verify');
|
||||
const emailSvc = require('../services/email');
|
||||
|
||||
/*
|
||||
* Only an org owner/admin may configure how their people sign in — it is the most security-relevant
|
||||
|
|
@ -42,6 +44,29 @@ function requireOrgAdmin(req, res, 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.
|
||||
*
|
||||
|
|
@ -64,6 +89,9 @@ function toPublic(row) {
|
|||
enabled: !!row.enabled,
|
||||
login_url: `/api/auth/oidc/${row.slug}/start`,
|
||||
callback_url: `/api/auth/oidc/${row.slug}/callback`,
|
||||
// Domains and their proof state. A provider whose domains are all unverified can be saved and
|
||||
// looks configured, but routes nobody — the UI needs this to say so rather than imply success.
|
||||
domains: domainsFor(row.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -105,28 +133,169 @@ function normaliseDomains(raw) {
|
|||
* that company's logins — the worst failure this feature could have. First claim wins; the loser is
|
||||
* told which domain clashed and nothing about who holds it.
|
||||
*/
|
||||
function assertDomainsFree(domains, orgId, excludeId) {
|
||||
function assertDomainsFree(domains, orgId, excludeProviderId) {
|
||||
if (!domains) return;
|
||||
const wanted = domains.split(',');
|
||||
const rows = db.prepare("SELECT id, organization_id, email_domains FROM org_sso_providers WHERE email_domains != ''").all();
|
||||
for (const row of rows) {
|
||||
if (row.id === excludeId) continue;
|
||||
const held = String(row.email_domains).split(',');
|
||||
for (const d of wanted) {
|
||||
if (held.includes(d)) {
|
||||
// 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;
|
||||
}
|
||||
for (const d of domains.split(',')) {
|
||||
const row = db.prepare('SELECT * FROM org_sso_domains WHERE domain = ?').get(d);
|
||||
if (!row) continue;
|
||||
if (row.provider_id && row.provider_id === excludeProviderId) continue;
|
||||
/*
|
||||
* A lapsed unverified claim reserves nothing. Clearing it here rather than on a timer means the
|
||||
* domain frees itself the moment someone else asks for it, and there is no sweeper to forget to
|
||||
* run — a squatter's unprovable claim simply stops being an obstacle.
|
||||
*/
|
||||
if (domainVerify.isClaimExpired(row)) {
|
||||
db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(row.id);
|
||||
continue;
|
||||
}
|
||||
// Same-org duplicates were allowed and should not have been: two providers claiming one
|
||||
// domain makes routing depend on table-scan order, so half a company's staff get sent to an
|
||||
// identity provider that has never heard of them.
|
||||
const e = new Error(row.organization_id === orgId
|
||||
? `the domain ${d} is already used by another of your providers`
|
||||
: `the domain ${d} is already used for sign-in by another organization`);
|
||||
e.status = 409;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Bring the claimed-domain rows in line with what the admin typed.
|
||||
*
|
||||
* A newly claimed domain arrives UNVERIFIED and stays inert until DNS proves the claim — it routes
|
||||
* nobody and the login callback refuses assertions for it. Re-typing an existing domain must not
|
||||
* reset that proof, which is why this diffs rather than deleting and re-inserting: a save on the
|
||||
* name field would otherwise silently un-verify every domain the customer had already proved, and
|
||||
* log their whole company out.
|
||||
*
|
||||
* Runs inside the caller's transaction so a domain cannot be reserved by two organizations at once.
|
||||
*/
|
||||
function syncDomains(providerId, orgId, domains) {
|
||||
const wanted = domains ? domains.split(',').filter(Boolean) : [];
|
||||
const existing = db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ?').all(providerId);
|
||||
const stale = new Map(existing.map((r) => [r.domain, r]));
|
||||
const claimed = []; // newly claimed, for the operator notification — sent AFTER the transaction
|
||||
|
||||
for (const d of wanted) {
|
||||
const mine = stale.get(d);
|
||||
if (mine) {
|
||||
stale.delete(d);
|
||||
/*
|
||||
* Already ours — keep a proof that happened. But a LAPSED claim must not be renewed by
|
||||
* simply saving the form again, or the 8-hour limit would mean nothing: the token is rotated,
|
||||
* which also means a record left in DNS from the previous attempt no longer matches. An old
|
||||
* record lying around proves nothing about the claim being made now.
|
||||
*/
|
||||
if (domainVerify.isClaimExpired(mine)) {
|
||||
db.prepare("UPDATE org_sso_domains SET token = ?, token_issued_at = strftime('%s','now'), last_error = NULL WHERE id = ?")
|
||||
.run(domainVerify.newToken(), mine.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
assertDomainsFree(d, orgId, providerId);
|
||||
db.prepare(`INSERT INTO org_sso_domains (id, organization_id, provider_id, domain, token)
|
||||
VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(crypto.randomUUID(), orgId, providerId, d, domainVerify.newToken());
|
||||
claimed.push(d);
|
||||
}
|
||||
for (const row of stale.values()) {
|
||||
db.prepare('DELETE FROM org_sso_domains WHERE id = ?').run(row.id);
|
||||
}
|
||||
return claimed;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tell the operator that a tenant has claimed a domain.
|
||||
*
|
||||
* DNS verification makes a claim worthless without control of the domain, so this is not what stops
|
||||
* abuse — it is what makes abuse VISIBLE. A tenant claiming `microsoft.com` will never verify it,
|
||||
* but an operator still wants to know somebody tried, and the notification is the difference
|
||||
* between finding that out now and finding it out from the company involved.
|
||||
*
|
||||
* Deliberately NOT sent to postmaster@ the claimed domain. That would mean this product emails
|
||||
* third parties who never signed up for it, on input any tenant can supply — a spam cannon with a
|
||||
* ScreenTinker return address. The operator can contact a domain owner; the server should not do it
|
||||
* unprompted.
|
||||
*
|
||||
* Failure to send is logged and swallowed: a mail outage must not stop a customer configuring SSO.
|
||||
*/
|
||||
function notifyOperatorOfClaim(req, { domain, orgId, providerName }) {
|
||||
try {
|
||||
if (!emailSvc.isConfigured()) return;
|
||||
const admins = db.prepare("SELECT email FROM users WHERE role = 'platform_admin' AND email_alerts = 1").all();
|
||||
if (!admins.length) return;
|
||||
const org = db.prepare('SELECT name FROM organizations WHERE id = ?').get(orgId);
|
||||
const who = req.user && req.user.email ? req.user.email : 'an administrator';
|
||||
const body = [
|
||||
`${who} claimed the sign-in domain ${domain}.`,
|
||||
'',
|
||||
`Organization: ${org ? org.name : orgId} (${orgId})`,
|
||||
`Provider: ${providerName}`,
|
||||
'',
|
||||
'The domain routes nobody until it is verified by a DNS record published in the domain itself,',
|
||||
'and the claim lapses after 8 hours if it is not. No action is needed unless this looks wrong.',
|
||||
].join('\n');
|
||||
for (const a of admins) {
|
||||
emailSvc.sendEmail({
|
||||
to: a.email,
|
||||
subject: `[ScreenTinker] SSO domain claimed: ${domain}`,
|
||||
text: body,
|
||||
}).catch((e) => console.error('[org-sso] claim notification failed:', e && e.message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[org-sso] claim notification failed:', e && e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider's domains, with the DNS record each unverified one still needs. */
|
||||
function domainsFor(providerId) {
|
||||
return db.prepare('SELECT * FROM org_sso_domains WHERE provider_id = ? ORDER BY domain').all(providerId)
|
||||
.map((r) => ({
|
||||
domain: r.domain,
|
||||
verified: !!r.verified_at,
|
||||
verified_at: r.verified_at,
|
||||
last_checked_at: r.last_checked_at,
|
||||
last_error: r.verified_at ? null : r.last_error,
|
||||
// The token is not a secret — it only means anything published in that domain's own DNS.
|
||||
...domainVerify.instructions(r.domain, r.token),
|
||||
}));
|
||||
}
|
||||
|
||||
/*
|
||||
* What an admin is told when discovery fails.
|
||||
*
|
||||
* The temptation is to hand back the underlying message, because it is genuinely the most useful
|
||||
* thing for a real misconfiguration. But the issuer is caller-supplied and fetched server-side, so
|
||||
* that message is an SSRF read primitive: `https://internal-host:8080 responded 403` and
|
||||
* `discovery issuer mismatch: … document says <X>` both report on services the caller cannot reach
|
||||
* directly. The jwks branch of the /test endpoint was already genericised for exactly this reason;
|
||||
* these paths were not, which left the scanner intact one line above the comment saying not to.
|
||||
*
|
||||
* So: the shape of the failure, never the upstream's answer. The full message goes to the log.
|
||||
*/
|
||||
function discoveryErrorMessage(e, issuer) {
|
||||
const raw = String((e && e.message) || '');
|
||||
console.warn(`[org-sso] discovery failed for ${issuer}: ${raw}`);
|
||||
if (/must use https|not publicly routable|not a URL/i.test(raw)) return raw; // our own guard, no upstream data
|
||||
if (/issuer mismatch/i.test(raw)) return 'that URL is not the OpenID issuer it claims to be';
|
||||
if (/is missing /i.test(raw)) return 'that issuer published an incomplete OpenID configuration';
|
||||
if (/redirected/i.test(raw)) return 'that issuer redirected; the URL must be the final one';
|
||||
if (/abort|timeout/i.test(raw)) return 'that issuer did not respond in time';
|
||||
return 'no OpenID configuration could be read from that URL';
|
||||
}
|
||||
|
||||
/*
|
||||
* Wrap an async handler so a rejection is a 500 rather than a dead server. Express 4 does not await
|
||||
* handlers and server.js turns an unhandled rejection into process.exit — see the longer note on
|
||||
* asyncRoute in routes/auth.js, which is the same guard for the same reason.
|
||||
*/
|
||||
function asyncRoute(handler) {
|
||||
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch((err) => {
|
||||
console.error(`[org-sso] unhandled error in ${req.method} ${req.path}:`, err && err.message);
|
||||
if (!res.headersSent) res.status(500).json({ error: 'Something went wrong' });
|
||||
});
|
||||
}
|
||||
|
||||
router.use(requireAuth, resolveTenancy);
|
||||
|
||||
// List an organization's providers.
|
||||
|
|
@ -135,7 +304,7 @@ router.get('/:orgId/sso', requireOrgAdmin, (req, res) => {
|
|||
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 || {};
|
||||
if (!name || !issuer || !clientId) {
|
||||
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 {
|
||||
await oidc.discover(String(issuer).trim().replace(/\/+$/, ''));
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${e.message}` });
|
||||
return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${discoveryErrorMessage(e, issuer)}` });
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const slug = newSlug();
|
||||
let newlyClaimed = [];
|
||||
/*
|
||||
* Re-check the domains INSIDE the transaction. The first check happened before `await
|
||||
* oidc.discover()`, which yields the event loop for a network round trip the caller's own IdP
|
||||
|
|
@ -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(),
|
||||
clientSecret ? secretbox.encrypt(String(clientSecret)) : null,
|
||||
String(scopes || 'openid email profile').trim(), cleanDomains);
|
||||
newlyClaimed = syncDomains(id, req.orgId, cleanDomains);
|
||||
})();
|
||||
} 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.
|
||||
logActivity(req.user.id, 'org_sso_created', `${name} (${slug}) org=${req.orgId}`, null, getClientIp(req));
|
||||
res.status(201).json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(id)));
|
||||
});
|
||||
}));
|
||||
|
||||
router.put('/:orgId/sso/:id', requireOrgAdmin, 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);
|
||||
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;
|
||||
if (nextIssuer !== existing.issuer) {
|
||||
try { await oidc.discover(nextIssuer); }
|
||||
catch (e) { return res.status(400).json({ error: `Could not read OpenID configuration from that issuer: ${e.message}` }); }
|
||||
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 —
|
||||
* the classic way a settings page silently breaks the thing it is editing.
|
||||
*/
|
||||
let newlyClaimed = [];
|
||||
const secretEnc = clientSecret === undefined ? existing.client_secret_enc
|
||||
: (clientSecret === '' ? null : secretbox.encrypt(String(clientSecret)));
|
||||
|
||||
db.prepare(`
|
||||
UPDATE org_sso_providers
|
||||
SET name = ?, issuer = ?, client_id = ?, client_secret_enc = ?, scopes = ?, email_domains = ?, enabled = ?,
|
||||
updated_at = strftime('%s','now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name !== undefined ? String(name).trim() : existing.name,
|
||||
nextIssuer,
|
||||
clientId !== undefined ? String(clientId).trim() : existing.client_id,
|
||||
secretEnc,
|
||||
scopes !== undefined ? String(scopes).trim() : existing.scopes,
|
||||
cleanDomains,
|
||||
enabled === undefined ? existing.enabled : (enabled ? 1 : 0),
|
||||
existing.id,
|
||||
);
|
||||
/*
|
||||
* Same transaction, same re-check, and for the same reason as the create path above — this one was
|
||||
* missed when that was fixed, which left the race fully open on the route an attacker would
|
||||
* actually pick: `await oidc.discover()` on a CHANGED issuer is a round trip whose length the
|
||||
* caller's own IdP decides, so it can be held open for the full fetch timeout while a victim
|
||||
* organization claims the domain legitimately. Both rows then hold it, and forEmail's
|
||||
* `ORDER BY created_at` hands routing to the OLDER row — the attacker's.
|
||||
*/
|
||||
try {
|
||||
db.transaction(() => {
|
||||
if (domains !== undefined) assertDomainsFree(cleanDomains, req.orgId, existing.id);
|
||||
db.prepare(`
|
||||
UPDATE org_sso_providers
|
||||
SET name = ?, issuer = ?, client_id = ?, client_secret_enc = ?, scopes = ?, email_domains = ?, enabled = ?,
|
||||
updated_at = strftime('%s','now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name !== undefined ? String(name).trim() : existing.name,
|
||||
nextIssuer,
|
||||
clientId !== undefined ? String(clientId).trim() : existing.client_id,
|
||||
secretEnc,
|
||||
scopes !== undefined ? String(scopes).trim() : existing.scopes,
|
||||
cleanDomains,
|
||||
enabled === undefined ? existing.enabled : (enabled ? 1 : 0),
|
||||
existing.id,
|
||||
);
|
||||
if (domains !== undefined) newlyClaimed = syncDomains(existing.id, req.orgId, cleanDomains);
|
||||
})();
|
||||
} catch (e) {
|
||||
// A thrown assertDomainsFree carries its own status; anything else is ours and stays generic
|
||||
// rather than returning a raw SQLite message to the caller.
|
||||
if (e.status) return res.status(e.status).json({ error: e.message });
|
||||
console.error('[org-sso] update failed:', e.message);
|
||||
return res.status(500).json({ error: 'Could not save that provider' });
|
||||
}
|
||||
|
||||
for (const d of newlyClaimed) notifyOperatorOfClaim(req, { domain: d, orgId: req.orgId, providerName: existing.name });
|
||||
|
||||
logActivity(req.user.id, 'org_sso_updated', `${existing.name} (${existing.slug}) org=${req.orgId}`, null, getClientIp(req));
|
||||
res.json(toPublic(db.prepare('SELECT * FROM org_sso_providers WHERE id = ?').get(existing.id)));
|
||||
});
|
||||
}));
|
||||
|
||||
/*
|
||||
* Check a provider without making anyone log in.
|
||||
|
|
@ -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,
|
||||
* 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);
|
||||
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);
|
||||
checks.push({ name: 'discovery', ok: true, detail: doc.issuer });
|
||||
} 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 });
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
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);
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||
db.prepare('DELETE FROM org_sso_providers WHERE id = ?').run(existing.id);
|
||||
|
|
|
|||
|
|
@ -529,7 +529,23 @@ function rateLimit(windowMs, maxRequests) {
|
|||
// 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
|
||||
// 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 windowStart = now - windowMs;
|
||||
let hits = rateLimits.get(key) || [];
|
||||
|
|
|
|||
|
|
@ -293,16 +293,34 @@ function orgDb() {
|
|||
scopes TEXT NOT NULL DEFAULT 'openid email profile', email_domains TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE 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;
|
||||
}
|
||||
|
||||
/*
|
||||
* `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) {
|
||||
const d = orgDb();
|
||||
let n = 0;
|
||||
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)
|
||||
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.
|
||||
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', () => {
|
||||
// Two rows, same domain. forEmail must be deterministic rather than returning whichever the
|
||||
// database happened to hand back first — the API refuses the second claim, and this is the
|
||||
// backstop if a row ever gets in another way.
|
||||
withOrgDb([
|
||||
{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'First', domains: 'shared.com' },
|
||||
{ id: '2', org: 'org-b', slug: 'orgbbb', name: 'Second', domains: 'shared.com' },
|
||||
], (m) => {
|
||||
assert.equal(m.forEmail('x@shared.com').name, 'First');
|
||||
});
|
||||
test('a domain can only be held by one organization AT THE DATABASE', () => {
|
||||
// Uniqueness used to be enforced only by a check in the route, which a race defeated twice in
|
||||
// review. It is now a UNIQUE constraint, so a second claim cannot exist even if the check is
|
||||
// bypassed entirely — the strongest form of "first claim wins" available here.
|
||||
assert.throws(() => {
|
||||
withOrgDb([
|
||||
{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'First', domains: 'shared.com' },
|
||||
{ id: '2', org: 'org-b', slug: 'orgbbb', name: 'Second', domains: 'shared.com' },
|
||||
], () => {});
|
||||
}, /UNIQUE/);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const p = m.getOrgProvider('orgevil');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -406,15 +425,43 @@ test('an INSTANCE provider carries no organization, so it is not domain-confined
|
|||
assert.equal(g.source, 'env');
|
||||
});
|
||||
|
||||
test('domain routing is deterministic, 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.
|
||||
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. 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([
|
||||
{ id: 'b', org: 'org-a', slug: 'orgbbb', name: 'Second', domains: 'shared.test' },
|
||||
{ id: 'a', org: 'org-a', slug: 'orgaaa', name: 'First', 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: 'Earlier', domains: 'earlier.test', verifiedAt: 1000 },
|
||||
], (m) => {
|
||||
const first = m.forEmail('x@shared.test').name;
|
||||
assert.equal(m.forEmail('x@shared.test').name, first, 'same answer every time');
|
||||
assert.equal(m.forEmail('x@earlier.test').name, 'Earlier');
|
||||
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('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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue