screentinker/server/test/oidc-sso.test.js
ScreenTinker 983bee31b7 SSO-only: close the backdoor, the unilateral disable, and the fresh-install fail-open
Three HIGH findings from the QA round. Each was demonstrated end to end against a
running server, and each is now refused there.

ENFORCEMENT PROTECTED A DOMAIN, NOT AN ORGANIZATION

ssoOnlyForEmail answers about an address's domain, so any account in the tenant at an
outside address kept password login — a contractor, an MSP, the one address nobody
remembered. And it could be manufactured: POST /api/admin/users accepts workspace_admin
and creates a LOCAL password account at any address bound to that workspace. A review
created backdoor@notacme.test, logged in with the password, landed in the SSO-only org,
and used it to create another. Enforcement is now keyed on MEMBERSHIP as well as domain
(ssoOnlyForUser), and that route refuses to mint password accounts into an SSO-only
organization at all. platform_admin keeps both, as the operator break-glass.

THE APPROVAL WORKFLOW WAS DECORATIVE

`sso_only` is honoured only while a provider is enabled and a domain is verified, so
`PUT {enabled:false}`, `PUT {email_domains:""}` and `DELETE` each switched enforcement
off — with sso_only still reading true, no request filed and the operator never told.
The delete variant additionally rewrites every federated account to `local`, after
which a password reset takes over accounts the identity provider was supposed to own.
Anyone who could file a request could simply turn the provider off instead. All three
now refuse with sso_only_locked when nothing else would still enforce, and say to ask
for approval.

FRESH INSTALLS FAILED THE MIGRATION AND FAILED OPEN

The ALTER adding organizations.sso_only sat in the column-migration array, which runs
BEFORE the multi-tenancy migration that creates the table: `[migrate] FAILED … no such
table: organizations`, one line among ~85. The instance then ran its whole first boot
with the SSO settings screen 500ing and ssoOnlyForEmail catching `no such column` and
answering "not required" — password login proceeding for an organization that had
switched it off. It self-healed on the second boot, which is what made it easy to miss.
The column is now added after the table exists, and the catch distinguishes "this
instance has no per-org SSO" (null, so single-tenant installs keep working) from drift
on a table that DOES exist (throw). Login treats an undeterminable answer as "required"
rather than letting a 500 escape or letting the login through.

Verified live, all four refused with enforcement intact and the operator still able to
sign in. 1609 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-11 07:23:28 -05:00

775 lines
38 KiB
JavaScript

'use strict';
/*
* The SSO that shipped before this verified nothing that mattered, and had no tests at all.
*
* Google's path asked `tokeninfo?access_token=` whether a token was valid and trusted the email in
* the answer; Microsoft's handed a bearer token to Graph /me and trusted that. Neither asked WHO
* THE TOKEN WAS ISSUED FOR. An access token is a bearer credential for a resource, minted for some
* application — so any site a user signed into that requested `email` or `User.Read` could replay
* their token and be handed a session as them.
*
* These tests exist so that cannot come back. Every one of them describes an attack that the old
* code would have waved through, and they run against a REAL RSA keypair and a REAL JWKS document
* so the verifier is exercised the way a provider would exercise it — not against a stub that
* agrees with us.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const jwt = require('jsonwebtoken');
const oidc = require('../lib/oidc');
const providers = require('../lib/oidc-providers');
// ---------------------------------------------------------------------------------------------
// A pretend identity provider: one keypair, one JWKS, one discovery document.
const ISSUER = 'https://idp.example.com';
const CLIENT_ID = 'screentinker-test-client';
const KID = 'test-key-1';
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const JWKS = { keys: [{ ...publicKey.export({ format: 'jwk' }), kid: KID, use: 'sig', alg: 'RS256' }] };
// A second keypair nobody should trust — the "signed by someone else" case.
const rogue = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
function discoveryDoc(issuer = ISSUER) {
return {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
};
}
/** Point global fetch at the pretend provider. Returns a restore function. */
function mockProvider({ doc = discoveryDoc(), jwks = JWKS } = {}) {
const real = global.fetch;
global.fetch = async (url) => {
const u = String(url);
if (u.endsWith('/.well-known/openid-configuration')) {
return { ok: true, status: 200, json: async () => doc };
}
if (u.endsWith('/jwks')) {
return { ok: true, status: 200, json: async () => jwks };
}
return { ok: false, status: 404, json: async () => ({}) };
};
oidc._resetCaches();
return () => { global.fetch = real; oidc._resetCaches(); };
}
const idToken = (claims = {}, { key = privateKey, alg = 'RS256', kid = KID } = {}) => jwt.sign(
{ iss: ISSUER, aud: CLIENT_ID, sub: 'user-123', email: 'a@example.com', nonce: 'NONCE', ...claims },
key, { algorithm: alg, keyid: kid, expiresIn: '5m' },
);
const verify = (token, over = {}) =>
oidc.verifyIdToken(token, { issuer: ISSUER, clientId: CLIENT_ID, nonce: 'NONCE', ...over });
// ---------------------------------------------------------------------------------------------
test('a well-formed token from the right provider verifies', async () => {
const restore = mockProvider();
try {
const claims = await verify(idToken());
assert.equal(claims.sub, 'user-123');
assert.equal(claims.email, 'a@example.com');
} finally { restore(); }
});
test('THE OLD BUG: a token minted for a DIFFERENT application is refused', async () => {
// This is the whole reason the previous implementation was unsafe. Same provider, same user,
// real signature — but issued to somebody else's client. It must not buy a session here.
const restore = mockProvider();
try {
await assert.rejects(() => verify(idToken({ aud: 'someone-elses-client' })), /audience/i);
} finally { restore(); }
});
test('...and neither is one that merely LISTS us alongside its real audience', async () => {
// aud can be an array. azp names who it was actually issued to, and if that is not us then we
// are a bystander in someone else's token — the confused-deputy case.
const restore = mockProvider();
try {
await assert.rejects(
() => verify(idToken({ aud: [CLIENT_ID, 'other'], azp: 'other' })),
/issued to a different application/i,
);
} finally { restore(); }
});
test('a token captured from an earlier login cannot be replayed', async () => {
// The nonce is minted per login and kept in a signed cookie. Without this check a correctly
// audienced token, obtained any way at all, would be reusable forever.
const restore = mockProvider();
try {
await assert.rejects(() => verify(idToken({ nonce: 'A-DIFFERENT-LOGIN' })), /nonce/i);
} finally { restore(); }
});
test('alg:none is refused', async () => {
const restore = mockProvider();
try {
// Hand-built, because jsonwebtoken will not sign 'none' for you.
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT', kid: KID })).toString('base64url');
const body = Buffer.from(JSON.stringify({
iss: ISSUER, aud: CLIENT_ID, sub: 'x', email: 'a@example.com', nonce: 'NONCE',
exp: Math.floor(Date.now() / 1000) + 300,
})).toString('base64url');
await assert.rejects(() => verify(`${header}.${body}.`), /algorithm/i);
} finally { restore(); }
});
test('an HMAC-signed token is refused even though the "key" is public', async () => {
// HS256 verifies with a shared secret. The only key we hold for a provider is its PUBLIC one,
// which the attacker also has — so accepting HMAC would let anyone sign their own identity.
const restore = mockProvider();
try {
const forged = jwt.sign(
{ iss: ISSUER, aud: CLIENT_ID, sub: 'x', email: 'admin@example.com', nonce: 'NONCE' },
publicKey.export({ type: 'spki', format: 'pem' }),
{ algorithm: 'HS256', keyid: KID, expiresIn: '5m' },
);
await assert.rejects(() => verify(forged), /algorithm/i);
} finally { restore(); }
});
test('a token signed by the wrong key is refused', async () => {
const restore = mockProvider();
try {
await assert.rejects(() => verify(idToken({}, { key: rogue.privateKey })), /signature/i);
} finally { restore(); }
});
test('an expired token is refused', async () => {
const restore = mockProvider();
try {
const stale = jwt.sign(
{ iss: ISSUER, aud: CLIENT_ID, sub: 'x', email: 'a@example.com', nonce: 'NONCE',
exp: Math.floor(Date.now() / 1000) - 3600 },
privateKey, { algorithm: 'RS256', keyid: KID },
);
await assert.rejects(() => verify(stale), /expired/i);
} finally { restore(); }
});
test('a provider whose discovery claims a different issuer is refused', async () => {
// Discovery is fetched from a URL derived from the configured issuer, so a document naming a
// DIFFERENT one is either broken or hostile. Either way its tokens must not be accepted under a
// name it does not own.
const restore = mockProvider({ doc: discoveryDoc('https://evil.example.com') });
try {
await assert.rejects(() => verify(idToken()), /issuer mismatch/i);
} finally { restore(); }
});
test('verification cannot be skipped by omitting the nonce', async () => {
// Belt and braces: the caller must always have a nonce to compare, so a coding mistake that
// forgets to pass one fails closed rather than accepting anything.
const restore = mockProvider();
try {
await assert.rejects(() => verify(idToken(), { nonce: undefined }), /nonce/i);
} finally { restore(); }
});
test('an unknown kid triggers exactly one JWKS refresh, then gives up', async () => {
// Key rotation is normal and must not fail every login until a cache expires; a token quoting
// nonsense must not become a way to hammer the provider either.
let jwksFetches = 0;
const real = global.fetch;
global.fetch = async (url) => {
const u = String(url);
if (u.endsWith('/.well-known/openid-configuration')) return { ok: true, status: 200, json: async () => discoveryDoc() };
if (u.endsWith('/jwks')) { jwksFetches++; return { ok: true, status: 200, json: async () => JWKS }; }
return { ok: false, status: 404, json: async () => ({}) };
};
oidc._resetCaches();
try {
await assert.rejects(() => verify(idToken({}, { kid: 'no-such-kid' })), /no signing key/i);
assert.equal(jwksFetches, 1, 'one refresh, not a loop');
} finally { global.fetch = real; oidc._resetCaches(); }
});
// ---------------------------------------------------------------------------------------------
// PKCE
test('PKCE uses S256 and never sends the verifier', () => {
const { verifier, challenge, method } = oidc.createPkce();
assert.equal(method, 'S256');
assert.notEqual(verifier, challenge, 'a plain challenge would make PKCE pointless');
const expected = crypto.createHash('sha256').update(verifier).digest('base64url');
assert.equal(challenge, expected);
assert.ok(verifier.length >= 43, 'RFC 7636 wants at least 43 characters of entropy');
});
test('every login gets fresh values', () => {
const a = oidc.createPkce(); const b = oidc.createPkce();
assert.notEqual(a.verifier, b.verifier);
assert.notEqual(oidc.randomToken(), oidc.randomToken());
});
// ---------------------------------------------------------------------------------------------
// The provider registry
test('Google registers from the variable the README always documented', () => {
const [g] = providers.list({ GOOGLE_CLIENT_ID: 'g' });
assert.equal(g.issuer, 'https://accounts.google.com');
});
test('a single-tenant Microsoft app narrows the issuer, so another tenant fails iss', () => {
const [ms] = providers.list({ MICROSOFT_CLIENT_ID: 'm', MICROSOFT_TENANT_ID: 'abc-123' });
assert.equal(ms.issuer, 'https://login.microsoftonline.com/abc-123/v2.0');
});
test('MULTI-TENANT MICROSOFT IS REFUSED, not silently broken', () => {
/*
* Two reasons pointing the same way. It cannot work: Microsoft's `common` metadata advertises the
* literal template `https://login.microsoftonline.com/{tenantid}/v2.0`, so the issuer can never
* equal the configured URL and every login fails at /start anyway.
*
* And the obvious patch is dangerous: loosening the iss comparison accepts tokens from EVERY
* Azure tenant, which is nOAuth — any tenant admin can set an arbitrary unverified `email` on
* their own user and be issued a session as that address here.
*/
for (const tenant of ['common', 'organizations', 'consumers', '']) {
assert.deepEqual(providers.list({ MICROSOFT_CLIENT_ID: 'm', MICROSOFT_TENANT_ID: tenant }), [],
`MICROSOFT_TENANT_ID=${tenant || '(unset)'} must not register a provider`);
}
});
test('any OIDC provider can be added by env', () => {
const list = providers.list({
OIDC_PROVIDERS: 'authentik',
OIDC_AUTHENTIK_ISSUER: 'https://id.example.com/application/o/st/',
OIDC_AUTHENTIK_CLIENT_ID: 'abc',
OIDC_AUTHENTIK_NAME: 'Company SSO',
});
assert.equal(list.length, 1);
assert.equal(list[0].slug, 'authentik');
assert.equal(list[0].name, 'Company SSO');
assert.equal(list[0].issuer, 'https://id.example.com/application/o/st', 'trailing slash normalised');
assert.equal(list[0].clientSecret, null, 'PKCE means a public client is fine');
});
test('an incomplete or malformed provider is ignored rather than crashing boot', () => {
assert.equal(providers.list({ OIDC_PROVIDERS: 'broken' }).length, 0, 'no issuer/client id');
assert.equal(providers.list({
OIDC_PROVIDERS: '../etc/passwd',
OIDC_ISSUER: 'https://x', OIDC_CLIENT_ID: 'y',
}).length, 0, 'a slug that is not URL-safe never becomes a route');
});
test('the browser is told slugs and names only — never a client id or secret', () => {
const pub = providers.publicList({
GOOGLE_CLIENT_ID: 'super-secret-id',
OIDC_PROVIDERS: 'okta', OIDC_OKTA_ISSUER: 'https://x.okta.com',
OIDC_OKTA_CLIENT_ID: 'id', OIDC_OKTA_CLIENT_SECRET: 'shh',
});
const serialised = JSON.stringify(pub);
assert.ok(!serialised.includes('super-secret-id'));
assert.ok(!serialised.includes('shh'));
assert.deepEqual(Object.keys(pub[0]).sort(), ['name', 'slug']);
});
// ---------------------------------------------------------------------------------------------
// Per-organization SSO.
//
// Instance providers belong to whoever runs the server; these belong to a CUSTOMER. Two properties
// matter more than the feature itself: one organization must not be able to capture another's
// logins, and the login page must not become a way to enumerate who the customers are.
const Database = require('better-sqlite3');
function orgDb() {
const d = new Database(':memory:');
d.exec(`
CREATE TABLE org_sso_providers (
id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL, issuer TEXT NOT NULL, client_id TEXT NOT NULL, client_secret_enc TEXT,
scopes TEXT NOT NULL DEFAULT 'openid email profile', email_domains TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0);
CREATE TABLE organizations (id TEXT PRIMARY KEY, name TEXT, sso_only INTEGER NOT NULL DEFAULT 0);
CREATE TABLE org_sso_domains (
id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, provider_id TEXT, domain TEXT NOT NULL UNIQUE,
token TEXT NOT NULL, token_issued_at INTEGER NOT NULL DEFAULT 0, verified_at INTEGER,
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) {
d.prepare('INSERT OR IGNORE INTO organizations (id, name, sso_only) VALUES (?, ?, ?)')
.run(r.org, r.org, r.ssoOnly ? 1 : 0);
const typed = [...(r.domains || '').split(','), ...(r.pending || '').split(',')].filter(Boolean).join(',');
d.prepare(`INSERT INTO org_sso_providers (id, organization_id, slug, name, issuer, client_id, email_domains, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.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');
const saved = real.db;
real.db = d;
delete require.cache[require.resolve('../lib/oidc-providers')];
const mod = require('../lib/oidc-providers');
try { return fn(mod); } finally {
real.db = saved;
delete require.cache[require.resolve('../lib/oidc-providers')];
}
}
test('an org provider is found by the email DOMAIN', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com,acme.co.uk' }], (m) => {
assert.equal(m.forEmail('someone@acme.com').name, 'Acme SSO');
assert.equal(m.forEmail('someone@ACME.CO.UK').name, 'Acme SSO', 'case-insensitive');
assert.equal(m.forEmail('someone@other.com'), null);
assert.equal(m.forEmail('not-an-email'), null);
});
});
test('a disabled provider stops answering for its domain', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.com', enabled: 0 }], (m) => {
assert.equal(m.forEmail('x@acme.com'), null);
assert.equal(m.getOrgProvider('orgaaa'), null, 'and cannot be started directly either');
});
});
test('ORG PROVIDERS ARE NEVER PUBLISHED to the whole internet', () => {
// The login page lists instance-wide providers only. Listing a customer's IdP would both offer it
// to people it does not belong to and leak the customer list.
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com' }], (m) => {
const pub = m.publicList({ GOOGLE_CLIENT_ID: 'g' });
assert.deepEqual(pub.map((p) => p.slug), ['google']);
assert.ok(!JSON.stringify(pub).includes('Acme'), 'no customer name anywhere in the public list');
});
});
test('an org provider is still resolvable by slug, so the shared login flow can run it', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme SSO', domains: 'acme.com' }], (m) => {
const p = m.get('orgaaa', {});
assert.equal(p.name, 'Acme SSO');
assert.equal(p.organizationId, 'org-a', 'carries its org so the callback can grant membership');
assert.equal(p.source, 'org');
});
});
test('an instance provider wins a slug clash with an org one', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'google', name: 'Impostor', domains: 'evil.com' }], (m) => {
// Org slugs are randomly generated so this cannot happen by accident — but if it ever did, a
// tenant must not be able to shadow the platform's own Google button.
assert.equal(m.get('google', { GOOGLE_CLIENT_ID: 'real' }).name, 'Google');
});
});
test('THE SHIPPED SCHEMA makes one domain, one organization a constraint', () => {
/*
* Uniqueness was 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.
*
* ⚠️ Read from server/db/database.js, NOT from the test harness. The earlier version of this
* test asserted against the harness's own CREATE TABLE and therefore stayed green when UNIQUE was
* removed from the shipped schema — it tested a copy of the thing it was named after.
*/
const schema = fs.readFileSync(require.resolve('../db/database.js'), 'utf8');
const table = schema.slice(schema.indexOf('CREATE TABLE IF NOT EXISTS org_sso_domains'));
const body = table.slice(0, table.indexOf('`,'));
assert.match(body, /domain\s+TEXT\s+NOT NULL\s+UNIQUE/, 'org_sso_domains.domain must be UNIQUE');
// And the row must not outlive its provider: a verified row never expires, so an orphan would
// block its domain for every organization, forever, while being invisible in the API.
assert.match(body, /FOREIGN KEY \(provider_id\) REFERENCES org_sso_providers\(id\) ON DELETE CASCADE/,
'a domain row must be removed with its provider');
});
test('no database means no org providers, and no crash', () => {
// The env-only paths must keep working on an instance where the table has not been migrated yet.
const m = require('../lib/oidc-providers');
assert.doesNotThrow(() => m.publicList({ GOOGLE_CLIENT_ID: 'g' }));
});
// ---------------------------------------------------------------------------------------------
// Regressions for defects found in security review. Each one was demonstrated end to end against a
// running server before it was fixed; none of them was hypothetical.
test('TAKEOVER: an org provider may not assert an email outside its own domains', () => {
/*
* The worst defect in this feature. An org admin supplies the issuer and client id, so they
* control the IdP completely and can mint a token asserting ANY email with email_verified:true —
* including a platform_admin's. Every cryptographic check passes honestly, because the attacker
* IS the issuer. Three reviewers demonstrated a full session as the victim independently.
*
* The confinement lives in the callback; this pins the data it depends on, so a provider loaded
* from the database always carries the domains its assertions are checked against.
*/
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');
});
});
test('an INSTANCE provider carries no organization, so it is not domain-confined', () => {
// Operator-chosen providers keep the trust they have always had; confinement targets tenants.
const [g] = providers.list({ GOOGLE_CLIENT_ID: 'g' });
assert.equal(g.organizationId, undefined);
assert.equal(g.source, 'env');
});
test('a domain routes to exactly the provider that verified it', () => {
/*
* forEmail used an unordered SELECT over a comma column, so deleting and re-adding a provider
* silently flipped which IdP a whole domain routed to.
*
* Two earlier versions of this test were hollow: one asserted only that two calls agreed with
* each other (an unordered scan satisfies that within a process), the next used two DIFFERENT
* domains so no ordering was exercised at all. The property that actually matters is that a
* domain reaches its OWN provider and never a sibling's, so assert that.
*/
withOrgDb([
{ id: 'a', org: 'org-a', slug: 'orgaaa', name: 'Alpha', domains: 'alpha.test' },
{ id: 'b', org: 'org-b', slug: 'orgbbb', name: 'Beta', domains: 'beta.test', pending: 'gamma.test' },
], (m) => {
assert.equal(m.forEmail('x@alpha.test').name, 'Alpha');
assert.equal(m.forEmail('x@beta.test').name, 'Beta');
assert.equal(m.forEmail('x@gamma.test'), null, 'unverified, so it belongs to nobody');
// Beta must not inherit Alpha's domain through a join or an ordering accident.
assert.equal(m.getOrgProvider('orgbbb').emailDomains, 'beta.test');
assert.equal(m.getOrgProvider('orgaaa').emailDomains, 'alpha.test');
});
});
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');
});
});
test('a secret that cannot be decrypted fails CLOSED', () => {
// decrypt() returns null after a JWT_SECRET rotation, which silently downgraded a confidential
// client to a public one — the login then failed at the provider with an error nobody could act
// on, while the admin screen still said "a secret is set".
withOrgDb([{ id: '1', org: 'o', slug: 'orgsec', name: 'X', domains: 'x.test' }], (m) => {
const real = require('../db/database');
real.db.prepare('UPDATE org_sso_providers SET client_secret_enc = ? WHERE id = ?').run('not-decryptable', '1');
assert.throws(() => m.getOrgProvider('orgsec'), /could not be decrypted/);
});
});
test('a tenant cannot claim a public email provider as its sign-in domain', () => {
/*
* Demonstrated in review: a tenant claimed gmail.com, after which /sso/discover answered
* {"sso":true} for every Gmail address and the login page offered "sign in with your
* organization" — a phishing hop launched from the vendor's own login screen, pointed at
* infrastructure the tenant controls. First-claim-wins also meant one cheap account could deny a
* public domain to everyone else.
*/
const { isPublicEmailDomain } = require('../lib/public-email-domains');
for (const d of ['gmail.com', 'outlook.com', 'hotmail.co.uk', 'yahoo.com', 'icloud.com',
'proton.me', 'qq.com', 'mail.ru', 'comcast.net', 'gmx.de']) {
assert.ok(isPublicEmailDomain(d), `${d} must be refused as an org sign-in domain`);
}
// ...and a real company domain is still fine, or the feature would be pointless.
for (const d of ['acme.com', 'bigcorp.io', 'my-company.co.uk', 'mail.acme.com']) {
assert.equal(isPublicEmailDomain(d), false, `${d} must remain claimable`);
}
});
test('the blocklist is case- and whitespace-insensitive', () => {
// Domains arrive from a form. ` GMAIL.COM ` must not slip through a lowercase-only comparison.
const { isPublicEmailDomain } = require('../lib/public-email-domains');
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');
// TXT is the only accepted form: a CNAME alternative would need a wildcard zone this project
// does not operate, so offering one would document a check that could never pass.
assert.equal(one.cname_value, undefined, 'no CNAME form is advertised');
// 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');
});
/*
* The proof name must not be delegated.
*
* A TXT lookup follows CNAMEs transparently, and RFC 4592 means a wildcard `*.victim.com`
* synthesizes `_screentinker-verify.victim.com` as well. So a wildcard CNAME pointing at anything
* the attacker controls lets them publish the token in THEIR zone and prove a domain they do not
* own — turning an ordinary subdomain takeover into the whole company's sign-in. A review did
* exactly this against a real authoritative zone.
*
* The resolver is stubbed rather than mocked at the network layer: `dns.promises` is a singleton,
* so replacing the two methods is enough and the real check() runs unmodified.
*/
const dnsPromises = require('node:dns').promises;
function withStubbedDns({ cname, txt }, fn) {
const realCname = dnsPromises.resolveCname;
const realTxt = dnsPromises.resolveTxt;
const nx = () => { const e = new Error('queryTxt ENOTFOUND'); e.code = 'ENOTFOUND'; throw e; };
dnsPromises.resolveCname = async () => (cname ? cname : nx());
dnsPromises.resolveTxt = async () => (txt ? txt : nx());
return Promise.resolve(fn()).finally(() => {
dnsPromises.resolveCname = realCname;
dnsPromises.resolveTxt = realTxt;
});
}
test('DELEGATION: a CNAME at the proof name is refused, even when the TXT matches', async () => {
const token = 'deadbeefdeadbeefdeadbeefdeadbeef';
// The attacker owns takeover.attacker.test and publishes a perfect token there; victim.test has
// a wildcard CNAME pointing at it. Without the refusal this returns ok:true.
const r = await withStubbedDns(
{ cname: ['takeover.attacker.test'], txt: [[`st-verify=${token}`]] },
() => domainVerify.check('victim.test', token),
);
assert.equal(r.ok, false, 'a delegated proof name must never verify');
assert.match(r.error, /CNAME/, 'and the admin is told exactly why');
});
test('an ordinary TXT proof in the domain\'s own zone still verifies', async () => {
const token = 'cafebabecafebabecafebabecafebabe';
const r = await withStubbedDns({ cname: null, txt: [[`st-verify=${token}`]] },
() => domainVerify.check('acme.test', token));
assert.equal(r.ok, true);
assert.equal(r.via, 'TXT');
});
test('a wildcard TXT answers with its own value, which is not a proof', async () => {
const r = await withStubbedDns({ cname: null, txt: [['v=spf1 -all']] },
() => domainVerify.check('victim.test', 'sometoken'));
assert.equal(r.ok, false);
assert.match(r.error, /does not match/);
});
test('a 255-byte-split TXT record is joined before comparing', async () => {
// resolveTxt returns one array of chunks per record; a long value arrives split.
const token = 'a'.repeat(32);
const full = `st-verify=${token}`;
const r = await withStubbedDns({ cname: null, txt: [[full.slice(0, 5), full.slice(5)]] },
() => domainVerify.check('acme.test', token));
assert.equal(r.ok, true, 'chunks of ONE record are concatenated');
});
test('chunks are never joined ACROSS records', async () => {
const token = 'b'.repeat(32);
const full = `st-verify=${token}`;
const r = await withStubbedDns({ cname: null, txt: [[full.slice(0, 5)], [full.slice(5)]] },
() => domainVerify.check('acme.test', token));
assert.equal(r.ok, false, 'two unrelated records must not add up to a proof');
});
test('SSRF: a trailing root dot is the same host, and does not slip the guard', () => {
// `https://localhost./` is a legal fully-qualified spelling that WHATWG URL preserves, so it
// matched neither `localhost` nor `*.localhost` and was allowed. The literal-IP forms were never
// affected — the parser normalises those itself.
for (const u of ['https://localhost./', 'https://LOCALHOST./', 'https://foo.localhost./']) {
assert.throws(() => oidc.assertFetchable(u), /not publicly routable/, u);
}
// and a real host that merely ends in a dot is still fine
for (const u of ['https://accounts.google.com./', 'https://fcm.googleapis.com./']) {
assert.doesNotThrow(() => oidc.assertFetchable(u), u);
}
});
test('a user object never leaves the server carrying a reset or verify hash', () => {
/*
* Two call sites each stripped three columns and stopped, so every login response also carried
* `password_reset_hash` and `email_verify_hash` — live credentials for taking the account over.
* The sanitiser is one function now; this pins the list so the next column added to `users` has
* to be considered rather than shipped.
*/
const src = fs.readFileSync(require.resolve('../routes/auth.js'), 'utf8');
const block = src.slice(src.indexOf('const PRIVATE_USER_FIELDS'), src.indexOf('function publicUser'));
for (const field of ['password_hash', 'totp_secret_enc', 'totp_last_step',
'password_reset_hash', 'password_reset_expires', 'email_verify_hash', 'email_verify_expires']) {
assert.ok(block.includes(`'${field}'`), `${field} must never be serialised to a client`);
}
// And nothing may hand-roll the old partial strip again.
assert.ok(!/totp_last_step,\s*\.\.\.safeUser/.test(src), 'use publicUser(), not an inline destructure');
});
// ---------------------------------------------------------------------------------------------
// SSO-only: an organization requiring its own identity provider.
test('SSO-ONLY applies to a VERIFIED domain', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.test', ssoOnly: true }], (m) => {
const hit = m.ssoOnlyForEmail('staff@acme.test');
assert.ok(hit, 'password login must be refused for this address');
assert.equal(hit.organization_id, 'org-a');
assert.equal(m.ssoOnlyForEmail('staff@ACME.TEST').organization_id, 'org-a', 'case-insensitive');
assert.equal(m.ssoOnlyForEmail('someone@elsewhere.test'), null, 'and nobody else is affected');
});
});
test('SSO-ONLY CANNOT be imposed through a domain that was only claimed', () => {
/*
* The dangerous shape: switching off password login for a domain the tenant never proved would
* be a denial-of-service against a company they have nothing to do with — every account at that
* address locked out of a product the squatter does not own.
*/
withOrgDb([{ id: '1', org: 'org-x', slug: 'orgxxx', name: 'Squatter', pending: 'victim-corp.test', ssoOnly: true }], (m) => {
assert.equal(m.ssoOnlyForEmail('ceo@victim-corp.test'), null, 'an unproved domain compels nobody');
});
});
test('SSO-ONLY stops applying when the provider is disabled', () => {
// Otherwise disabling a broken provider would leave its users with no way in at all: no SSO
// (disabled) and no password (still enforced).
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.test', enabled: 0, ssoOnly: true }], (m) => {
assert.equal(m.ssoOnlyForEmail('staff@acme.test'), null);
});
});
test('SSO-ONLY is off unless the organization turned it on', () => {
withOrgDb([{ id: '1', org: 'org-a', slug: 'orgaaa', name: 'Acme', domains: 'acme.test' }], (m) => {
assert.equal(m.ssoOnlyForEmail('staff@acme.test'), null, 'having SSO is not the same as requiring it');
});
});
test('the login gate exempts platform_admin, and that exemption is deliberate', () => {
/*
* The operator approves turning SSO-only OFF. If the operator's own address sat at an SSO-only
* domain and that identity provider broke, nobody could sign in to approve anything and the
* instance would be bricked. Pinned as source because it is a security-relevant exemption that
* must not be "tidied away" by someone who reads it as a convenience.
*/
const src = fs.readFileSync(require.resolve('../routes/auth.js'), 'utf8');
assert.match(src, /user\.role !== 'platform_admin'[\s\S]{0,600}ssoOnlyForUser/,
'the break-glass exemption must guard the SSO-only check');
assert.match(src, /code: 'sso_required'/, 'and the refusal must be distinguishable from a bad password');
});