Authenticate against any OIDC provider, for on-premises installs
CI / verify (push) Successful in 2m55s
CI / verify (push) Successful in 2m55s
The seam existed with only a Supabase implementation, so an on-prem deployment had no way to authenticate. A customer running PIG inside their own network already has Okta, Entra, Keycloak, Auth0 or Google Workspace; asking them to stand up a second identity system is a serious adoption tax and in a regulated environment usually refused outright. Setting PIG_OIDC_ISSUER is normally the whole configuration — the JWKS is discovered from the issuer's well-known document. PIG_OIDC_JWKS_URI skips discovery entirely for an air-gapped network. OIDC takes precedence over Supabase so an on-prem install can leave the hosted values in its environment file without them quietly taking over. Three decisions worth stating: Discovery is resolved lazily and the FAILURE is not cached. Doing it per request would put the customer's identity provider on the critical path of every API call; doing it eagerly at boot would mean their IdP rebooting takes the CRM down with it. So it happens on first use and retries on the next request. The audience check is optional but warned about loudly. Without it, a token the provider issued for ANY other application in the same tenant verifies here — a token minted for an unrelated internal tool would be accepted as a PIG session. It cannot be mandatory because some providers legitimately issue single-audience tokens. Email falls back through email, preferred_username and upn, because providers disagree, but a preferred_username without an "@" is ignored — PIG keys membership on the address, and a bare username must never become an account identity. Also fixed a warning that claimed "authentication is DISABLED" on a correctly configured OIDC deployment. That is worse than silence: an operator who reads it on a secure install learns to ignore the warnings. The dev bypass itself was already correct — it keys on the resolved provider rather than on Supabase. 18 new tests, most of them about what the provider must REFUSE: a foreign signing key, a foreign issuer, a token for a different application, an expired token, a token with no subject, and a discovery outage that must not become permanent. Keys are generated per test and the JWKS is served locally, so they run offline. Verified: production refuses to start with neither provider, starts with OIDC alone, enforces 401 on an unauthenticated request, and warns only about the genuinely missing admin list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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