Merge remote-tracking branch 'gitea/main' into feat/revenue-intelligence
CI / verify (push) Successful in 2m54s
CI / verify (push) Successful in 2m54s
This commit is contained in:
@@ -1,101 +1,214 @@
|
||||
/**
|
||||
* Tests for the identity-provider boundary.
|
||||
* Tests for the OIDC authentication provider.
|
||||
*
|
||||
* These cases pin the trust decisions shared by protected requests and profile
|
||||
* creation. Membership remains deliberately outside this module.
|
||||
* 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 { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { exportJWK, SignJWT } from 'jose';
|
||||
import {
|
||||
createSupabaseAuthProvider,
|
||||
type AuthProvider,
|
||||
} from '../src/lib/auth-provider';
|
||||
import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose';
|
||||
import { createOidcAuthProvider, createConfiguredAuthProvider } from '../src/lib/auth-provider';
|
||||
|
||||
describe('Supabase auth provider', () => {
|
||||
let server: Server;
|
||||
let provider: AuthProvider;
|
||||
let issuer: string;
|
||||
let privateKey: KeyObject;
|
||||
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<ReturnType<typeof generateKeyPair>>['privateKey'];
|
||||
|
||||
before(async () => {
|
||||
const keys = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
privateKey = keys.privateKey;
|
||||
const publicJwk = await exportJWK(keys.publicKey);
|
||||
let privateKey: SigningKey;
|
||||
let otherPrivateKey: SigningKey;
|
||||
let jwks: { keys: JWK[] };
|
||||
/** Flipped per test to exercise discovery failures. */
|
||||
let discoveryStatus = 200;
|
||||
let serveDiscovery = true;
|
||||
|
||||
server = createServer((request, response) => {
|
||||
if (request.url !== '/auth/v1/.well-known/jwks.json') {
|
||||
response.writeHead(404).end();
|
||||
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;
|
||||
}
|
||||
response.setHeader('content-type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
keys: [{ ...publicJwk, alg: 'RS256', kid: 'test-key', use: 'sig' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const address = server.address() as AddressInfo;
|
||||
const supabaseUrl = `http://127.0.0.1:${address.port}`;
|
||||
issuer = `${supabaseUrl}/auth/v1`;
|
||||
provider = createSupabaseAuthProvider(supabaseUrl);
|
||||
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();
|
||||
});
|
||||
|
||||
after(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
async function sign(claims: Record<string, unknown>, tokenIssuer = issuer): Promise<string> {
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||
.setIssuer(tokenIssuer)
|
||||
.setExpirationTime('5m')
|
||||
.sign(privateKey);
|
||||
}
|
||||
after(() => server?.close());
|
||||
|
||||
it('returns only the verified external identity claims', async () => {
|
||||
const token = await sign({ sub: 'provider-user-1', email: 'Owner@Example.com' });
|
||||
async function mint(
|
||||
claims: Record<string, unknown>,
|
||||
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);
|
||||
}
|
||||
|
||||
assert.deepEqual(await provider.verifyAccessToken(token), {
|
||||
subject: 'provider-user-1',
|
||||
email: 'Owner@Example.com',
|
||||
});
|
||||
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('rejects a correctly signed token issued for a different identity provider', async () => {
|
||||
// Signature validity alone is insufficient: without the issuer check, a
|
||||
// sibling deployment using the same key could authenticate here.
|
||||
const token = await sign({ sub: 'provider-user-1' }, 'https://other.example/auth/v1');
|
||||
|
||||
await assert.rejects(provider.verifyAccessToken(token));
|
||||
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('accepts a subject without email for protected requests', async () => {
|
||||
// Existing members are joined by subject. Email is required only by the
|
||||
// invite-gated profile flow, not as an extra condition on every request.
|
||||
const token = await sign({ sub: 'provider-user-2' });
|
||||
|
||||
assert.deepEqual(await provider.verifyAccessToken(token), {
|
||||
subject: 'provider-user-2',
|
||||
email: undefined,
|
||||
});
|
||||
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('rejects a token with no stable subject', async () => {
|
||||
const token = await sign({ email: 'owner@example.com' });
|
||||
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');
|
||||
});
|
||||
|
||||
await assert.rejects(provider.verifyAccessToken(token));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user