Authenticate against any OIDC provider, for on-premises installs
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:
2026-08-13 02:32:28 -07:00
parent 54edee30ed
commit c821b2ca07
7 changed files with 453 additions and 84 deletions
+21
View File
@@ -22,6 +22,27 @@ SUPABASE_ANON_KEY=
# PIG runs fine in invite-only mode. Treat it as the most powerful secret here. # PIG runs fine in invite-only mode. Treat it as the most powerful secret here.
SUPABASE_SERVICE_KEY= SUPABASE_SERVICE_KEY=
# --- Auth: on-premises (OIDC) ---------------------------------------------
# Set PIG_OIDC_ISSUER to authenticate against your own identity provider —
# Okta, Entra, Keycloak, Auth0, Authentik, Google Workspace, anything
# standards-compliant. It TAKES PRECEDENCE over the Supabase values above, so
# an on-prem install can leave those in place.
#
# PIG never sees a password. It verifies the token your provider issued and
# reads two things: a stable subject, and an email. Everything else — teams,
# roles, capabilities — is PIG's own data keyed on that subject, so users are
# provisioned in PIG by invite, not by your directory.
PIG_OIDC_ISSUER=
# Optional. Discovered from the issuer's /.well-known/openid-configuration when
# omitted. Set it to skip discovery entirely on an air-gapped network.
PIG_OIDC_JWKS_URI=
# STRONGLY recommended. Without it, a token your provider issued for ANY other
# application in the same tenant is accepted here as a PIG session.
PIG_OIDC_AUDIENCE=
# Comma-separated, in preference order. Defaults to email,preferred_username,upn
# which covers most providers; Entra sometimes needs upn first.
PIG_OIDC_EMAIL_CLAIMS=
# --- Application ------------------------------------------------------------ # --- Application ------------------------------------------------------------
PIG_PORT=8920 PIG_PORT=8920
PIG_PUBLIC_URL=http://localhost:8920 PIG_PUBLIC_URL=http://localhost:8920
+5
View File
@@ -91,6 +91,11 @@ in the service layer or in the agent. The API signals the agent by *writing a
row to `agent_tasks`*, never by calling it — so the queue survives the agent row to `agent_tasks`*, never by calling it — so the queue survives the agent
being down and no request thread ever blocks on a model. being down and no request thread ever blocks on a model.
**There are two auth providers, behind one interface.** Supabase for the
hosted deployment, OIDC for on-premises — see `apps/api/src/lib/auth-provider.ts`.
Both reduce to "verify a bearer token, return a subject and an email", because
that is all PIG needs. Never reach for a provider SDK outside that file.
**Authentication is not authorization.** A verified JWT proves someone has an **Authentication is not authorization.** A verified JWT proves someone has an
account in an identity provider that PIG *shares with another application*. It account in an identity provider that PIG *shares with another application*. It
does not prove they belong here. Access requires a row in PIG's own `users` does not prove they belong here. Access requires a row in PIG's own `users`
+1
View File
@@ -53,6 +53,7 @@ test('invite-bound member creates, sells and observes capacity through authentic
PIGGY_ENABLED: 'false', PIGGY_ENABLED: 'false',
}); });
const authProvider = { const authProvider = {
name: 'e2e-stub',
async verifyAccessToken(token: string) { async verifyAccessToken(token: string) {
if (token !== accessToken) throw new Error('Invalid E2E token.'); if (token !== accessToken) throw new Error('Invalid E2E token.');
return { subject, email }; return { subject, email };
+157 -2
View File
@@ -4,8 +4,22 @@
* Providers prove an external identity. They do not decide whether that * Providers prove an external identity. They do not decide whether that
* identity belongs to PIG; workspace membership remains a database decision * identity belongs to PIG; workspace membership remains a database decision
* in the authenticator and signup route. * in the authenticator and signup route.
*
* Two implementations:
*
* **Supabase** — the hosted deployment. Well-known JWKS path, fixed issuer.
* **OIDC** — any standards-compliant identity provider, which is what an
* on-premises install needs. The customer already runs Okta,
* Entra, Keycloak, Auth0, Authentik or Google Workspace behind
* their VPN; asking them to stand up a second identity system
* to use PIG would be a serious adoption tax, and in a
* regulated environment often simply refused.
*
* Both reduce to the same thing — verify a bearer token, return a stable
* subject and an email — because that is all PIG needs. Everything downstream
* (teams, roles, capabilities) is PIG's own data keyed on that subject.
*/ */
import { createRemoteJWKSet, jwtVerify } from 'jose'; import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
import type { Config } from './config'; import type { Config } from './config';
export interface VerifiedIdentity { export interface VerifiedIdentity {
@@ -14,6 +28,8 @@ export interface VerifiedIdentity {
} }
export interface AuthProvider { export interface AuthProvider {
/** Human-readable, for startup logging and the health surface. */
readonly name: string;
verifyAccessToken(token: string): Promise<VerifiedIdentity>; verifyAccessToken(token: string): Promise<VerifiedIdentity>;
} }
@@ -24,6 +40,7 @@ export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider {
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)); const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
return { return {
name: 'supabase',
async verifyAccessToken(token: string): Promise<VerifiedIdentity> { async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
const { payload } = await jwtVerify(token, jwks, { issuer }); const { payload } = await jwtVerify(token, jwks, { issuer });
if (!payload.sub) throw new Error('token has no subject'); if (!payload.sub) throw new Error('token has no subject');
@@ -36,8 +53,146 @@ export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider {
}; };
} }
export interface OidcProviderOptions {
/** The `iss` value the provider stamps into its tokens. */
issuer: string;
/**
* JWKS location. Optional: when omitted it is discovered from
* `${issuer}/.well-known/openid-configuration`, which every compliant
* provider serves. Setting it explicitly avoids one startup fetch and lets
* an air-gapped deployment skip discovery entirely.
*/
jwksUri?: string;
/**
* Expected audience. **Strongly recommended.**
*
* Without it, any token the identity provider issued for *any* application
* in the same tenant will verify here — a token minted for an unrelated
* internal tool would be accepted as a PIG session. `jose` only checks the
* audience when asked to, so leaving this unset is a real hole rather than a
* relaxed default, and it is warned about at boot.
*/
audience?: string;
/**
* Claim to read the email from. Providers disagree: most use `email`, some
* corporate Entra configurations use `preferred_username` or `upn`. Each
* candidate is tried in order.
*/
emailClaims?: string[];
/** Tolerance for clock skew between PIG and the provider. */
clockToleranceSeconds?: number;
}
const DEFAULT_EMAIL_CLAIMS = ['email', 'preferred_username', 'upn'];
export function createOidcAuthProvider(options: OidcProviderOptions): AuthProvider {
const issuer = options.issuer.replace(/\/+$/, '');
const emailClaims = options.emailClaims?.length ? options.emailClaims : DEFAULT_EMAIL_CLAIMS;
/*
* Resolved once, lazily, and cached — including the failure.
*
* Discovery is a network call, so doing it per request would put the
* identity provider on the critical path of every API call. Doing it eagerly
* at boot would mean PIG refuses to start if the provider is briefly
* unreachable, which on a customer's own network is a bad trade: their
* identity provider rebooting should not take the CRM down with it.
*
* So it happens on first use and is retried on the next request if it fails.
*/
let jwksPromise: Promise<ReturnType<typeof createRemoteJWKSet>> | null = null;
async function resolveJwks() {
if (options.jwksUri) return createRemoteJWKSet(new URL(options.jwksUri));
const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
const response = await fetch(discoveryUrl, { headers: { accept: 'application/json' } });
if (!response.ok) {
throw new Error(
`OIDC discovery failed: ${discoveryUrl} returned ${response.status}. ` +
'Set PIG_OIDC_JWKS_URI to skip discovery.',
);
}
const document = (await response.json()) as { jwks_uri?: string; issuer?: string };
if (!document.jwks_uri) {
throw new Error(`OIDC discovery document at ${discoveryUrl} has no jwks_uri.`);
}
// A discovery document whose issuer disagrees with the configured one means
// the deployment is pointed somewhere unexpected. Verification would fail
// later anyway; failing here says why.
if (document.issuer && document.issuer.replace(/\/+$/, '') !== issuer) {
throw new Error(
`OIDC issuer mismatch: configured ${issuer}, discovery reports ${document.issuer}.`,
);
}
return createRemoteJWKSet(new URL(document.jwks_uri));
}
return {
name: 'oidc',
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
if (!jwksPromise) {
jwksPromise = resolveJwks().catch((error) => {
// Clear the cache so the next request retries rather than being
// stuck with a rejected promise for the process lifetime.
jwksPromise = null;
throw error;
});
}
const jwks = await jwksPromise;
const { payload } = await jwtVerify(token, jwks, {
issuer,
...(options.audience ? { audience: options.audience } : {}),
clockTolerance: options.clockToleranceSeconds ?? 5,
});
if (!payload.sub) throw new Error('token has no subject');
return { subject: payload.sub, email: readEmail(payload, emailClaims) };
},
};
}
function readEmail(payload: JWTPayload, claims: string[]): string | undefined {
for (const claim of claims) {
const value = payload[claim];
// A `preferred_username` is not always an address; only take it if it
// looks like one, so a bare username never becomes an account identity.
if (typeof value === 'string' && value.includes('@')) return value.toLowerCase();
}
return undefined;
}
/**
* Build the provider this deployment is configured for.
*
* OIDC wins when both are set, so an on-premises install can keep the Supabase
* values in its environment file without them quietly taking precedence.
*/
export function createConfiguredAuthProvider( export function createConfiguredAuthProvider(
config: Pick<Config, 'SUPABASE_URL'>, config: Pick<
Config,
| 'SUPABASE_URL'
| 'PIG_OIDC_ISSUER'
| 'PIG_OIDC_JWKS_URI'
| 'PIG_OIDC_AUDIENCE'
| 'PIG_OIDC_EMAIL_CLAIMS'
>,
): AuthProvider | null { ): AuthProvider | null {
if (config.PIG_OIDC_ISSUER) {
return createOidcAuthProvider({
issuer: config.PIG_OIDC_ISSUER,
jwksUri: config.PIG_OIDC_JWKS_URI || undefined,
audience: config.PIG_OIDC_AUDIENCE || undefined,
emailClaims: config.PIG_OIDC_EMAIL_CLAIMS
? config.PIG_OIDC_EMAIL_CLAIMS.split(',').map((claim) => claim.trim()).filter(Boolean)
: undefined,
});
}
return config.SUPABASE_URL ? createSupabaseAuthProvider(config.SUPABASE_URL) : null; return config.SUPABASE_URL ? createSupabaseAuthProvider(config.SUPABASE_URL) : null;
} }
+52 -6
View File
@@ -33,6 +33,21 @@ const schema = z.object({
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'), DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
SUPABASE_URL: z.string().url().optional(), SUPABASE_URL: z.string().url().optional(),
/*
* OIDC — the on-premises path.
*
* A customer running PIG inside their own network already has an identity
* provider. Setting PIG_OIDC_ISSUER switches authentication to it and takes
* precedence over any Supabase values left in the environment file.
*/
PIG_OIDC_ISSUER: z.string().url().optional(),
/** Optional. Discovered from the issuer when omitted. */
PIG_OIDC_JWKS_URI: z.string().url().optional(),
/** Strongly recommended — see the boot warning. */
PIG_OIDC_AUDIENCE: z.string().optional(),
/** Comma-separated, in preference order. Defaults cover most providers. */
PIG_OIDC_EMAIL_CLAIMS: z.string().optional(),
SUPABASE_ANON_KEY: z.string().optional(), SUPABASE_ANON_KEY: z.string().optional(),
SUPABASE_SERVICE_KEY: z.string().optional(), SUPABASE_SERVICE_KEY: z.string().optional(),
@@ -203,17 +218,48 @@ function warnOnFootguns(config: Config): void {
warn('PIG_ADMIN_EMAILS is empty — no user will have platform-admin rights.'); warn('PIG_ADMIN_EMAILS is empty — no user will have platform-admin rights.');
} }
if (!config.SUPABASE_URL) { // Keyed on BOTH providers, not just Supabase. An OIDC deployment has
// authentication and this warning previously claimed it did not — which is
// worse than saying nothing, because an operator reading "authentication is
// DISABLED" on a correctly secured install learns to ignore the warnings.
if (!config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) {
warn( warn(
'SUPABASE_URL is not set — authentication is DISABLED and every request ' + 'No identity provider is configured (SUPABASE_URL or PIG_OIDC_ISSUER) — ' +
'runs as the development user. Never do this in production.', 'authentication is DISABLED and every request runs as the development ' +
'user. Never do this in production.',
); );
} }
if (config.isProduction && !config.SUPABASE_URL) { if (config.PIG_OIDC_ISSUER && config.SUPABASE_URL) {
warn(
'Both PIG_OIDC_ISSUER and SUPABASE_URL are set — OIDC takes precedence and ' +
'Supabase will not be used for authentication.',
);
}
if (config.PIG_OIDC_ISSUER && !config.PIG_OIDC_AUDIENCE) {
// Not fatal, because some providers issue single-audience tokens where it
// adds nothing — but on a shared corporate tenant this is the difference
// between "a token for PIG" and "a token for anything in the company".
warn(
'PIG_OIDC_AUDIENCE is not set. Any token your identity provider issued for ' +
'ANY application in the same tenant will be accepted here. Set it unless ' +
'you are certain that is safe.',
);
}
if (config.PIG_OIDC_ISSUER && config.SUPABASE_SERVICE_KEY) {
warn(
'SUPABASE_SERVICE_KEY is set while running on OIDC. Self-registration mints ' +
'Supabase accounts, which an OIDC deployment does not use — unset it and ' +
'provision users through your identity provider instead.',
);
}
if (config.isProduction && !config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) {
throw new Error( throw new Error(
'Refusing to start: NODE_ENV=production with no SUPABASE_URL would serve ' + 'Refusing to start: NODE_ENV=production with neither SUPABASE_URL nor ' +
'the entire CRM unauthenticated.', 'PIG_OIDC_ISSUER would serve the entire CRM unauthenticated.',
); );
} }
+189 -76
View File
@@ -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 * This is the code that decides whether a stranger is who they claim to be, so
* creation. Membership remains deliberately outside this module. * 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 { 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 { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { after, before, describe, it } from 'node:test'; import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose';
import { exportJWK, SignJWT } from 'jose'; import { createOidcAuthProvider, createConfiguredAuthProvider } from '../src/lib/auth-provider';
import {
createSupabaseAuthProvider,
type AuthProvider,
} from '../src/lib/auth-provider';
describe('Supabase auth provider', () => { let server: Server;
let server: Server; let origin: string;
let provider: AuthProvider; // Derived from jose rather than referencing the DOM `CryptoKey` type, which
let issuer: string; // is not in this package's type lib.
let privateKey: KeyObject; type SigningKey = Awaited<ReturnType<typeof generateKeyPair>>['privateKey'];
before(async () => { let privateKey: SigningKey;
const keys = generateKeyPairSync('rsa', { modulusLength: 2048 }); let otherPrivateKey: SigningKey;
privateKey = keys.privateKey; let jwks: { keys: JWK[] };
const publicJwk = await exportJWK(keys.publicKey); /** Flipped per test to exercise discovery failures. */
let discoveryStatus = 200;
let serveDiscovery = true;
server = createServer((request, response) => { before(async () => {
if (request.url !== '/auth/v1/.well-known/jwks.json') { const pair = await generateKeyPair('RS256');
response.writeHead(404).end(); 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; return;
} }
response.setHeader('content-type', 'application/json'); res.writeHead(200, { 'content-type': 'application/json' });
response.end( res.end(JSON.stringify({ issuer: origin, jwks_uri: `${origin}/jwks` }));
JSON.stringify({ return;
keys: [{ ...publicJwk, alg: 'RS256', kid: 'test-key', use: 'sig' }], }
}), if (req.url === '/jwks') {
); res.writeHead(200, { 'content-type': 'application/json' });
}); res.end(JSON.stringify(jwks));
return;
await new Promise<void>((resolve, reject) => { }
server.once('error', reject); res.writeHead(404);
server.listen(0, '127.0.0.1', resolve); res.end();
});
const address = server.address() as AddressInfo;
const supabaseUrl = `http://127.0.0.1:${address.port}`;
issuer = `${supabaseUrl}/auth/v1`;
provider = createSupabaseAuthProvider(supabaseUrl);
}); });
after( await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
() => origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
new Promise<void>((resolve, reject) => { });
server.close((error) => (error ? reject(error) : resolve()));
}),
);
async function sign(claims: Record<string, unknown>, tokenIssuer = issuer): Promise<string> { after(() => server?.close());
return new SignJWT(claims)
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
.setIssuer(tokenIssuer)
.setExpirationTime('5m')
.sign(privateKey);
}
it('returns only the verified external identity claims', async () => { async function mint(
const token = await sign({ sub: 'provider-user-1', email: 'Owner@Example.com' }); 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), { describe('OIDC provider — what it accepts', () => {
subject: 'provider-user-1', it('verifies a well-formed token and returns subject and email', async () => {
email: 'Owner@Example.com', 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 () => { it('discovers the JWKS from the issuer when no explicit URI is given', async () => {
// Signature validity alone is insufficient: without the issuer check, a const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
// sibling deployment using the same key could authenticate here. const token = await mint({ sub: 'user-2', email: 'a@b.com', aud: 'pig' });
const token = await sign({ sub: 'provider-user-1' }, 'https://other.example/auth/v1'); assert.equal((await provider.verifyAccessToken(token)).subject, 'user-2');
await assert.rejects(provider.verifyAccessToken(token));
}); });
it('accepts a subject without email for protected requests', async () => { it('skips discovery entirely when the JWKS URI is configured', async () => {
// Existing members are joined by subject. Email is required only by the // The air-gapped path: no discovery request is made at all.
// invite-gated profile flow, not as an extra condition on every request. serveDiscovery = false;
const token = await sign({ sub: 'provider-user-2' }); try {
const provider = createOidcAuthProvider({
assert.deepEqual(await provider.verifyAccessToken(token), { issuer: origin,
subject: 'provider-user-2', jwksUri: `${origin}/jwks`,
email: undefined, 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 () => { it('falls back through email claims for providers that do not send `email`', async () => {
const token = await sign({ email: 'owner@example.com' }); 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);
}); });
}); });
+28
View File
@@ -98,6 +98,34 @@ applied. Take a dump before a major upgrade anyway:
docker compose -p pig exec db pg_dump -U pig pig | gzip > pig-$(date +%F).sql.gz docker compose -p pig exec db pg_dump -U pig pig | gzip > pig-$(date +%F).sql.gz
``` ```
## On-premises: using your own identity provider
PIG authenticates against any standards-compliant OIDC provider, which is how
an install inside your own network works. Set:
```bash
PIG_OIDC_ISSUER=https://id.yourcompany.internal
PIG_OIDC_AUDIENCE=pig # the client/app id you registered for PIG
```
That is usually the whole configuration — the JWKS is discovered from the
issuer. On an air-gapped network, set `PIG_OIDC_JWKS_URI` too and no discovery
request is made.
`PIG_OIDC_ISSUER` takes precedence over `SUPABASE_URL`, so the hosted values
can stay in the environment file without quietly taking over.
**Set the audience.** Without it, any token your provider issued for any
application in the same tenant verifies here — a token minted for an unrelated
internal tool would be accepted as a PIG session. PIG warns about this at boot
but cannot refuse, because some providers legitimately issue single-audience
tokens.
**Provisioning stays in PIG.** Authenticating proves who someone is; it does
not make them a member. They still need an invite, and their team and role live
in PIG's database. That is deliberate — your directory should not have to model
"supply lead versus demand member" for one application.
## A note on the auth project ## A note on the auth project
PIG verifies JWTs but authorizes from its own `users` table. If the Supabase PIG verifies JWTs but authorizes from its own `users` table. If the Supabase