/** * Tests for the OIDC authentication provider. * * This is the code that decides whether a stranger is who they claim to be, so * the cases below are mostly about what it must **refuse**. A provider that * accepts a token it should not is not a bug you find in staging. * * Keys are generated per test and the JWKS is served from a local HTTP server, * so these run offline and deterministically — no network, no fixtures that * expire. */ import { strict as assert } from 'node:assert'; import { after, before, describe, it } from 'node:test'; import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose'; import { createOidcAuthProvider, createConfiguredAuthProvider } from '../src/lib/auth-provider'; let server: Server; let origin: string; // Derived from jose rather than referencing the DOM `CryptoKey` type, which // is not in this package's type lib. type SigningKey = Awaited>['privateKey']; let privateKey: SigningKey; let otherPrivateKey: SigningKey; let jwks: { keys: JWK[] }; /** Flipped per test to exercise discovery failures. */ let discoveryStatus = 200; let serveDiscovery = true; before(async () => { const pair = await generateKeyPair('RS256'); const other = await generateKeyPair('RS256'); privateKey = pair.privateKey; otherPrivateKey = other.privateKey; const publicJwk = await exportJWK(pair.publicKey); publicJwk.kid = 'test-key'; publicJwk.alg = 'RS256'; jwks = { keys: [publicJwk] }; server = createServer((req, res) => { if (req.url === '/.well-known/openid-configuration') { if (!serveDiscovery || discoveryStatus !== 200) { res.writeHead(discoveryStatus === 200 ? 404 : discoveryStatus); res.end('{}'); return; } res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ issuer: origin, jwks_uri: `${origin}/jwks` })); return; } if (req.url === '/jwks') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(jwks)); return; } res.writeHead(404); res.end(); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); after(() => server?.close()); async function mint( claims: Record, opts: { key?: SigningKey; issuer?: string; expiresIn?: string } = {}, ) { return new SignJWT(claims) .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) .setIssuedAt() .setIssuer(opts.issuer ?? origin) .setExpirationTime(opts.expiresIn ?? '5m') .sign(opts.key ?? privateKey); } describe('OIDC provider — what it accepts', () => { it('verifies a well-formed token and returns subject and email', async () => { const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' }); const token = await mint({ sub: 'user-1', email: 'Person@Example.com', aud: 'pig' }); const identity = await provider.verifyAccessToken(token); assert.equal(identity.subject, 'user-1'); // Lower-cased, because PIG keys membership on the address and // Person@ and person@ are the same person. assert.equal(identity.email, 'person@example.com'); }); it('discovers the JWKS from the issuer when no explicit URI is given', async () => { const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' }); const token = await mint({ sub: 'user-2', email: 'a@b.com', aud: 'pig' }); assert.equal((await provider.verifyAccessToken(token)).subject, 'user-2'); }); it('skips discovery entirely when the JWKS URI is configured', async () => { // The air-gapped path: no discovery request is made at all. serveDiscovery = false; try { const provider = createOidcAuthProvider({ issuer: origin, jwksUri: `${origin}/jwks`, audience: 'pig', }); const token = await mint({ sub: 'user-3', email: 'a@b.com', aud: 'pig' }); assert.equal((await provider.verifyAccessToken(token)).subject, 'user-3'); } finally { serveDiscovery = true; } }); it('falls back through email claims for providers that do not send `email`', async () => { const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' }); const token = await mint({ sub: 'user-4', preferred_username: 'someone@corp.com', aud: 'pig' }); assert.equal((await provider.verifyAccessToken(token)).email, 'someone@corp.com'); }); it('ignores a preferred_username that is not an address', async () => { // A bare username must never become an account identity — PIG keys // membership on the email, and "jsmith" is not one. const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' }); const token = await mint({ sub: 'user-5', preferred_username: 'jsmith', aud: 'pig' }); assert.equal((await provider.verifyAccessToken(token)).email, undefined); }); }); describe('OIDC provider — what it must refuse', () => { const provider = () => createOidcAuthProvider({ issuer: origin, audience: 'pig' }); it('rejects a token signed by a different key', async () => { const token = await mint({ sub: 'x', aud: 'pig' }, { key: otherPrivateKey }); await assert.rejects(() => provider().verifyAccessToken(token)); }); it('rejects a token from a different issuer', async () => { const token = await mint({ sub: 'x', aud: 'pig' }, { issuer: 'https://evil.example' }); await assert.rejects(() => provider().verifyAccessToken(token)); }); it('rejects a token minted for a DIFFERENT application in the same tenant', async () => { // The case the audience check exists for. Without it, a token issued for // any other internal tool would be accepted as a PIG session. const token = await mint({ sub: 'x', aud: 'some-other-app' }); await assert.rejects(() => provider().verifyAccessToken(token)); }); it('rejects an expired token', async () => { const token = await mint({ sub: 'x', aud: 'pig' }, { expiresIn: '-1m' }); await assert.rejects(() => provider().verifyAccessToken(token)); }); it('rejects a token with no subject', async () => { const token = await mint({ aud: 'pig' }); await assert.rejects(() => provider().verifyAccessToken(token)); }); it('rejects garbage', async () => { await assert.rejects(() => provider().verifyAccessToken('not-a-token')); }); it('surfaces a discovery failure and retries on the next call', async () => { const p = createOidcAuthProvider({ issuer: origin, audience: 'pig' }); const token = await mint({ sub: 'user-6', aud: 'pig' }); discoveryStatus = 503; await assert.rejects(() => p.verifyAccessToken(token)); // The failure must not be cached for the process lifetime: an identity // provider that reboots should not permanently break PIG. discoveryStatus = 200; assert.equal((await p.verifyAccessToken(token)).subject, 'user-6'); }); }); describe('createConfiguredAuthProvider', () => { it('prefers OIDC when both are configured', () => { const provider = createConfiguredAuthProvider({ SUPABASE_URL: 'https://project.supabase.co', PIG_OIDC_ISSUER: 'https://id.customer.internal', PIG_OIDC_JWKS_URI: undefined, PIG_OIDC_AUDIENCE: 'pig', PIG_OIDC_EMAIL_CLAIMS: undefined, }); // So an on-prem install can leave the hosted values in place without them // silently taking over. assert.equal(provider?.name, 'oidc'); }); it('uses Supabase when only it is configured', () => { const provider = createConfiguredAuthProvider({ SUPABASE_URL: 'https://project.supabase.co', PIG_OIDC_ISSUER: undefined, PIG_OIDC_JWKS_URI: undefined, PIG_OIDC_AUDIENCE: undefined, PIG_OIDC_EMAIL_CLAIMS: undefined, }); assert.equal(provider?.name, 'supabase'); }); it('returns null when neither is configured', () => { const provider = createConfiguredAuthProvider({ SUPABASE_URL: undefined, PIG_OIDC_ISSUER: undefined, PIG_OIDC_JWKS_URI: undefined, PIG_OIDC_AUDIENCE: undefined, PIG_OIDC_EMAIL_CLAIMS: undefined, }); // The production guard in config.ts turns this into a refusal to start. assert.equal(provider, null); }); });