diff --git a/.env.example b/.env.example index 496e602..7cdf4bb 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,27 @@ SUPABASE_ANON_KEY= # PIG runs fine in invite-only mode. Treat it as the most powerful secret here. 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 ------------------------------------------------------------ PIG_PORT=8920 PIG_PUBLIC_URL=http://localhost:8920 diff --git a/AGENTS.md b/AGENTS.md index 6d69968..314f909 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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 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` diff --git a/apps/api/e2e/critical-path.test.ts b/apps/api/e2e/critical-path.test.ts index 5a6b50d..22edd07 100644 --- a/apps/api/e2e/critical-path.test.ts +++ b/apps/api/e2e/critical-path.test.ts @@ -53,6 +53,7 @@ test('invite-bound member creates, sells and observes capacity through authentic PIGGY_ENABLED: 'false', }); const authProvider = { + name: 'e2e-stub', async verifyAccessToken(token: string) { if (token !== accessToken) throw new Error('Invalid E2E token.'); return { subject, email }; diff --git a/apps/api/src/lib/auth-provider.ts b/apps/api/src/lib/auth-provider.ts index 7f1a22e..61cc9d2 100644 --- a/apps/api/src/lib/auth-provider.ts +++ b/apps/api/src/lib/auth-provider.ts @@ -4,8 +4,22 @@ * Providers prove an external identity. They do not decide whether that * identity belongs to PIG; workspace membership remains a database decision * 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'; export interface VerifiedIdentity { @@ -14,6 +28,8 @@ export interface VerifiedIdentity { } export interface AuthProvider { + /** Human-readable, for startup logging and the health surface. */ + readonly name: string; verifyAccessToken(token: string): Promise; } @@ -24,6 +40,7 @@ export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider { const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)); return { + name: 'supabase', async verifyAccessToken(token: string): Promise { const { payload } = await jwtVerify(token, jwks, { issuer }); 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> | 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 { + 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( - config: Pick, + config: Pick< + Config, + | 'SUPABASE_URL' + | 'PIG_OIDC_ISSUER' + | 'PIG_OIDC_JWKS_URI' + | 'PIG_OIDC_AUDIENCE' + | 'PIG_OIDC_EMAIL_CLAIMS' + >, ): 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; } diff --git a/apps/api/src/lib/config.ts b/apps/api/src/lib/config.ts index 8155f7f..b3168d4 100644 --- a/apps/api/src/lib/config.ts +++ b/apps/api/src/lib/config.ts @@ -33,6 +33,21 @@ const schema = z.object({ DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'), 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_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.'); } - 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( - 'SUPABASE_URL is not set — authentication is DISABLED and every request ' + - 'runs as the development user. Never do this in production.', + 'No identity provider is configured (SUPABASE_URL or PIG_OIDC_ISSUER) — ' + + '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( - 'Refusing to start: NODE_ENV=production with no SUPABASE_URL would serve ' + - 'the entire CRM unauthenticated.', + 'Refusing to start: NODE_ENV=production with neither SUPABASE_URL nor ' + + 'PIG_OIDC_ISSUER would serve the entire CRM unauthenticated.', ); } diff --git a/apps/api/test/auth-provider.test.ts b/apps/api/test/auth-provider.test.ts index d7c4d98..bdc9d21 100644 --- a/apps/api/test/auth-provider.test.ts +++ b/apps/api/test/auth-provider.test.ts @@ -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>['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((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((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }), - ); + await new Promise((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, tokenIssuer = issuer): Promise { - 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, + 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); }); }); diff --git a/deploy/README.md b/deploy/README.md index e5cf3a5..aead6e7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 ``` +## 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 PIG verifies JWTs but authorizes from its own `users` table. If the Supabase