/** * Authentication-provider boundary. * * 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, type JWTPayload } from 'jose'; import type { Config } from './config'; export interface VerifiedIdentity { subject: string; email?: string; } export interface AuthProvider { /** Human-readable, for startup logging and the health surface. */ readonly name: string; verifyAccessToken(token: string): Promise; } export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider { const issuer = `${supabaseUrl}/auth/v1`; // `jose` fetches lazily and caches this set, including safe key rotation. // Sharing one provider instance avoids a remote lookup path per handler. 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'); return { subject: payload.sub, email: typeof payload.email === 'string' ? payload.email : undefined, }; }, }; } 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, | '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; }