screentinker/server/test/oidc-sso.test.js
ScreenTinker 252854d31e SSO: one OIDC flow for every provider, and verify the token properly
The OAuth support that was here could not work and would not have been safe if
it had.

It could not work: the login page called google.accounts.oauth2 and
new msal.PublicClientApplication, and NEITHER SDK WAS EVER LOADED by any page
in this app — no script tag, no dynamic import, nothing. Both buttons threw
ReferenceError on click. Even had they loaded, the CSP allows scripts only from
'self' and cloudflareinsights, and frames only from self and YouTube, so the
libraries and their popups were blocked too.

It would not have been safe: both endpoints authenticated with an ACCESS token
and neither checked who it was issued for. POST /auth/google fell back to
tokeninfo?access_token= and read the email out of the reply; POST
/auth/microsoft handed the bearer token to Graph /me and trusted that. Graph
and tokeninfo will both describe the user behind a token minted for SOMEBODY
ELSE'S application, so any site a user signed into that requested `email` or
`User.Read` could have replayed their token here and been issued a session as
them. Both endpoints are deleted; nothing is lost, because nothing could reach
them.

Replaced by ONE generic flow — Authorization Code + PKCE (S256), run
server-side, with the provider list resolved through a single function so
per-organization SSO can extend it later without a second login path. Google
and Microsoft become ordinary configured providers; Okta, Keycloak, Authentik,
Auth0 and anything else that speaks OIDC now work with three env vars.

Because the exchange happens server-side the browser never talks to the
provider, so there is no SDK to load, no client id in the page, and no
third-party origin needed in the CSP.

Identity comes from an ID token that must survive: signature against the
provider's JWKS (asymmetric algorithms only — alg:none and HMAC are refused
outright, the latter because the only key we hold is public), `iss` exactly as
discovered, `aud` and `azp` matching our client, `exp`, and a `nonce` this
server minted for that login. State is compared in constant time against a
value in an httpOnly SameSite=Lax cookie, so the callback is CSRF-protected and
survives a restart mid-login.

Account rules are the ones already in place: a verified email is required, an
SSO login never takes over an account that has a password, and a changed `sub`
for a known address is refused rather than handing the account to a recycled
mailbox.

18 new tests, every one describing something the old code would have accepted:
cross-audience tokens, azp mismatch, replayed nonces, alg:none, HMAC forgery,
wrong signing key, expired tokens, a discovery document lying about its issuer,
and a registry that never leaks a client id or secret to the browser.

Verified end to end against Google's real discovery document: the redirect
carries response_type=code, PKCE S256, state and nonce, and every callback
guard rejects as intended (no cookie, wrong state, no code, provider refusal,
unknown provider).

⚠️ TOTP is still not prompted on an SSO login, matching the documented
behaviour of the previous SSO and API-token paths. That is a product decision
and is left unchanged here rather than altered silently.

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

263 lines
11 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 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 and Microsoft register from the variables the README always documented', () => {
const list = providers.list({ GOOGLE_CLIENT_ID: 'g', MICROSOFT_CLIENT_ID: 'm', MICROSOFT_TENANT_ID: 'common' });
const byslug = Object.fromEntries(list.map((p) => [p.slug, p]));
assert.equal(byslug.google.issuer, 'https://accounts.google.com');
assert.equal(byslug.microsoft.issuer, 'https://login.microsoftonline.com/common/v2.0');
});
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('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']);
});