screentinker/server/test/oidc-email-verified-policy.test.js
ScreenTinker e5e5b75b85 Customer Entra tenants: verify the domain, then be believed
The previous fix let the instance-wide Microsoft button work and left the
customer-facing path broken, which is the worst way round. An organization that
brings its own Entra tenant would publish the TXT record, watch its domain go
green, and still be refused at login with `email_unverified` -- because Entra
sends no such claim and rowToProvider pinned the assumption off for every org
provider.

Requiring a claim Microsoft does not emit is not a security control, it is an
outage. What makes it safe to stop requiring it is the proof that already gates
these providers: the callback confines an org provider to its DNS-verified
domains, and an address only reaches the check after passing that. Whoever
controls a domain's DNS controls its mail, which is the same trust that makes a
verification link meaningful.

So the assumption is DERIVED from proof -- `verified.length > 0` -- rather than
pinned off. A provider that has verified nothing still assumes nothing, which is
belt and braces: emailAllowedForProvider already refuses it, since an empty
allow-list matches no domain, but deriving it here means a future reordering of
those checks cannot silently widen it.

It is never a column, and there is no column for it to be read from. An
organization must not be able to switch this on for itself; it is a consequence
of DNS proof, not a setting. A test asserts both -- that the value is derived
next to `source: 'org'`, and that no `assume_email_verified` exists in the
schema.

Domain confinement is untouched. An explicit `email_verified: false` is still
refused from anyone.

Mutations all fail the tests: assuming unconditionally, never assuming, and
reading it from the row.
2026-08-11 23:05:25 -05:00

102 lines
5.5 KiB
JavaScript

'use strict';
/*
* Who may be believed about an email address.
*
* Regression: requiring `claims.email_verified === true` made Microsoft sign-in impossible, because
* Entra ID v2 does not send the claim. Every Entra login authenticated and was then refused with
* `email_unverified`. The previous test suite asserted how the Microsoft ISSUER string is built but
* never pushed a Microsoft-shaped token through the policy, so nothing failed.
*
* The rule these tests pin down:
* - `email_verified: true` -> believed, always
* - claim ABSENT + operator-chosen -> believed (Microsoft, or an opted-in provider)
* - claim ABSENT + org with a VERIFIED domain -> believed (it proved DNS control)
* - claim ABSENT + org with NO verified domain -> refused (it has proven nothing)
* - `email_verified: false` -> refused, whoever asked
*
* The org case is a consequence of DNS proof, never a setting: an organization must not be able to
* turn it on for itself, so it is derived and never read from a column.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { emailIsVerified, list } = require('../lib/oidc-providers');
const MS_ENV = { MICROSOFT_CLIENT_ID: 'client-abc', MICROSOFT_TENANT_ID: 'ffffffff-1111-2222-3333-444444444444' };
const microsoft = () => list(MS_ENV).find((p) => p.slug === 'microsoft');
const google = () => list({ GOOGLE_CLIENT_ID: 'g-abc' }).find((p) => p.slug === 'google');
// An org provider as rowToProvider builds it: `assumeEmailVerified` follows from whether any domain
// has actually been DNS-verified.
const orgProvider = (verifiedDomains = []) => ({
slug: 'acme7f3', source: 'org', organizationId: 'org-1',
emailDomains: verifiedDomains.join(','),
assumeEmailVerified: verifiedDomains.length > 0,
});
const orgUnproven = orgProvider(); // configured, nothing verified yet
const orgProven = orgProvider(['bytetinker.net']); // TXT published, domain green
test('an explicit true is believed from any provider', () => {
for (const p of [microsoft(), google(), orgUnproven, orgProven]) {
assert.equal(emailIsVerified({ email_verified: true }, p), true, `${p.slug} should accept an explicit true`);
}
});
test('Microsoft omits the claim and is still believed (the regression)', () => {
const ms = microsoft();
assert.equal(ms.assumeEmailVerified, true, 'the tenant-pinned Microsoft entry must assume verification');
assert.equal(emailIsVerified({ email: 'someone@example.com' }, ms), true);
});
test('Google stays strict — it does send the claim, so there is nothing to assume', () => {
const g = google();
assert.equal(g.assumeEmailVerified, false);
assert.equal(emailIsVerified({ email: 'someone@example.com' }, g), false);
});
test('an org provider that has proven a domain may assume — the customer-Entra case', () => {
// Entra sends no email_verified. Before this, the domain went green and the login still failed.
assert.equal(emailIsVerified({ email: 'dan@bytetinker.net' }, orgProven), true);
});
test('an org provider that has proven NOTHING assumes nothing', () => {
assert.equal(emailIsVerified({ email: 'dan@bytetinker.net' }, orgUnproven), false);
});
test('the org assumption is DERIVED from proof, never readable from the row', () => {
const src = require('fs').readFileSync(require.resolve('../lib/oidc-providers'), 'utf8');
assert.match(src, /assumeEmailVerified: verified\.length > 0,\s*\n\s*source: 'org'/,
'rowToProvider must derive it from the verified-domain list, next to source:org');
// The whole point: an organization must not be able to switch this on for itself.
assert.doesNotMatch(src, /assumeEmailVerified: *row\./, 'must never be read from the org row');
// ...and there must be no column for it to be read FROM. Checked against the schema rather than
// this file, where `OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED` is a legitimate operator-set env var.
const schema = require('fs').readFileSync(require.resolve('../db/database'), 'utf8');
assert.doesNotMatch(schema, /assume_email_verified/i,
'org_sso_providers must have no assume_email_verified column');
});
test('an EXPLICIT false is refused even where absence would be assumed', () => {
assert.equal(emailIsVerified({ email_verified: false }, microsoft()), false);
assert.equal(emailIsVerified({ email_verified: 'false' }, microsoft()), false, 'a string is not a true');
assert.equal(emailIsVerified({ email_verified: 0 }, microsoft()), false);
});
test('a generic provider can opt in by env, and is strict without it', () => {
const base = { OIDC_PROVIDERS: 'keycloak', OIDC_KEYCLOAK_ISSUER: 'https://kc.example.com', OIDC_KEYCLOAK_CLIENT_ID: 'kc' };
const strict = list(base).find((p) => p.slug === 'keycloak');
assert.equal(strict.assumeEmailVerified, false);
assert.equal(emailIsVerified({}, strict), false);
const opted = list({ ...base, OIDC_KEYCLOAK_ASSUME_EMAIL_VERIFIED: 'true' }).find((p) => p.slug === 'keycloak');
assert.equal(opted.assumeEmailVerified, true);
assert.equal(emailIsVerified({}, opted), true);
assert.equal(emailIsVerified({ email_verified: false }, opted), false, 'opting in never overrides an explicit false');
});
test('missing claims object or provider does not throw and does not pass', () => {
assert.equal(emailIsVerified(null, microsoft()), true, 'no claims at all still consults the provider policy');
assert.equal(emailIsVerified({}, null), false);
assert.equal(emailIsVerified({}, undefined), false);
});