/** * Authentication and authorization. * * The distinction is the whole point of this file, and it is the thing most * likely to be got wrong by someone extending PIG later: * * **Authentication** answers "who is this?" and is delegated to an identity * provider. PIG stores no passwords and issues no sessions of its own. * * **Authorization** answers "may they use PIG?" and is answered ONLY by a row * in PIG's `users` table. * * These must stay separate because the Supabase project may be shared with * other applications. A valid token proves someone has an account *somewhere in * that project* — not that they belong here. Treating a verified token as * sufficient would silently grant every user of every sibling application full * access to the CRM. * * A token with no matching PIG user gets 403 with `needs_profile`, which the * front end turns into the invite-redemption screen. */ import { eq } from 'drizzle-orm'; import type { Database } from '@pig/db'; import { apiKeys, teamMemberships, users } from '@pig/db'; import { permissionGranted, resolveReadPermissionGrants, resolveWritePermissionGrants, roleMeets, TEAM_CAPABILITY_RULES, type GlobalCapability, type PermissionGrant, type ReadCapability, type Team, type TeamCapability, type TeamRole, type WriteCapability, } from '@pig/core'; import { createHash, timingSafeEqual } from 'node:crypto'; import type { Config } from './config'; import type { AuthProvider } from './auth-provider'; export interface Principal { userId: string; email: string; name: string; isPlatformAdmin: boolean; teams: { team: Team; role: TeamRole }[]; /** How this request authenticated. Agents get their own audit trail. */ via: 'jwt' | 'api_key' | 'development'; apiKeyId?: string; scopes: string[]; } export class AuthError extends Error { constructor( message: string, readonly status: 401 | 403, readonly code: string, ) { super(message); this.name = 'AuthError'; } } export function createAuthenticator( config: Config, db: Database, authProvider: AuthProvider | null, ) { async function loadPrincipal( userId: string, via: Principal['via'], extras: { apiKeyId?: string; scopes?: string[] } = {}, ): Promise { const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); if (!user) throw new AuthError('No PIG profile for this account.', 403, 'needs_profile'); if (user.deactivatedAt) throw new AuthError('This account is deactivated.', 403, 'deactivated'); const memberships = await db .select({ team: teamMemberships.team, role: teamMemberships.role }) .from(teamMemberships) .where(eq(teamMemberships.userId, user.id)); return { userId: user.id, email: user.email, name: user.name, // Admin rights come from the database, but the environment allowlist can // grant them too — that is how the first admin exists before anyone has // been able to log in and promote anybody. isPlatformAdmin: user.isPlatformAdmin || config.adminEmails.includes(user.email.toLowerCase()), teams: memberships as { team: Team; role: TeamRole }[], via, apiKeyId: extras.apiKeyId, scopes: extras.scopes ?? ['read', 'write'], }; } return { /** * Resolve the principal for a request, or throw. * * Accepts either a Supabase JWT or a PIG API key, both in the * Authorization header. API keys exist so that an agent acting for a person * is a distinct principal from that person — separately revocable, with its * own audit trail and its own scopes. */ async authenticate(header: string | undefined): Promise { const token = header?.startsWith('Bearer ') ? header.slice(7).trim() : null; // An explicit PIG key must be honoured even in development. Otherwise a // revoked or malformed key silently becomes the development user, which // makes local integration tests pass without testing the credential at // all and hides the exact failures developers need to see. if (token?.startsWith('pig_')) return authenticateApiKey(token); // Development escape hatch. Guarded three ways, and `loadConfig` refuses // to start in production without identity configuration, so this cannot // leak into a real deployment. if (!authProvider && !config.isProduction) { const [devUser] = await db.select().from(users).limit(1); if (!devUser) { throw new AuthError( 'Auth is disabled and the database has no users. Run `npm run db:seed`.', 403, 'no_dev_user', ); } return loadPrincipal(devUser.id, 'development'); } if (!header?.startsWith('Bearer ')) { throw new AuthError('Missing bearer token.', 401, 'no_token'); } if (!authProvider) { throw new AuthError('Authentication is not configured.', 401, 'no_jwks'); } let subject: string; try { subject = (await authProvider.verifyAccessToken(token!)).subject; } catch { // Deliberately opaque: distinguishing "expired" from "malformed" from // "wrong issuer" tells an attacker which knob to turn. throw new AuthError('Invalid or expired token.', 401, 'invalid_token'); } const [user] = await db .select({ id: users.id }) .from(users) .where(eq(users.authSubject, subject)) .limit(1); if (!user) { // Authenticated but not authorized — the case that matters when the // identity provider is shared with another application. throw new AuthError( 'This account is not a member of this PIG workspace.', 403, 'needs_profile', ); } return loadPrincipal(user.id, 'jwt'); }, }; async function authenticateApiKey(token: string): Promise { const hash = hashApiKey(token); const [record] = await db .select() .from(apiKeys) .where(eq(apiKeys.keyHash, hash)) .limit(1); if (!record) throw new AuthError('Unknown API key.', 401, 'invalid_key'); assertApiKeyActive(record); // Best-effort last-used stamp. Never block the request on it: a failed // bookkeeping write must not deny access. void db .update(apiKeys) .set({ lastUsedAt: new Date() }) .where(eq(apiKeys.id, record.id)) .catch(() => {}); return loadPrincipal(record.userId, 'api_key', { apiKeyId: record.id, scopes: record.scopes, }); } } export function assertApiKeyActive( record: { revokedAt: Date | null; expiresAt: Date | null }, now = new Date(), ): void { if (record.revokedAt) throw new AuthError('This API key was revoked.', 401, 'revoked_key'); if (record.expiresAt && record.expiresAt < now) { throw new AuthError('This API key has expired.', 401, 'expired_key'); } } /** * Hash an API key for storage and lookup. * * SHA-256 without a salt is correct here, unlike for passwords: the key is 256 * bits of machine-generated randomness, so there is no dictionary to attack and * lookup must be deterministic. What matters is that the plaintext is never * stored, so a database leak yields no working credentials. */ export function hashApiKey(key: string): string { return createHash('sha256').update(key).digest('hex'); } /** Constant-time compare, for anywhere a secret is checked directly. */ export function safeEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); // Length alone can leak, so compare hashes of equal length rather than // returning early on a length mismatch. const ah = createHash('sha256').update(ab).digest(); const bh = createHash('sha256').update(bb).digest(); return timingSafeEqual(ah, bh); } /** Does this principal belong to the team, at or above the given role? */ export function hasTeamAccess( principal: Principal, team: Team, minimumRole: TeamRole = 'member', ): boolean { if (principal.isPlatformAdmin) return true; const membership = principal.teams.find((t) => t.team === team); if (!membership) return false; return roleMeets(membership.role, minimumRole); } export function requireScope(principal: Principal, scope: string): void { if (principal.scopes.includes(scope)) return; throw new AuthError(`This credential lacks the '${scope}' scope.`, 403, 'insufficient_scope'); } /** * Effective grants include credential scope, not merely the owner's roles. * * Read and write scopes are filtered separately. Before read capabilities * existed a read-only key resolved to no grants at all, which was right then * and would now be wrong: it would tell `/api/me` that a read-only agent may * not read, and the browser would grey out a page the server happily serves. */ export function effectivePermissions(principal: Principal): PermissionGrant[] { const grants: PermissionGrant[] = []; if (principal.scopes.includes('read')) grants.push(...resolveReadPermissionGrants(principal)); if (principal.scopes.includes('write')) grants.push(...resolveWritePermissionGrants(principal)); return grants; } export function requireCapability(principal: Principal, capability: GlobalCapability): void; export function requireCapability( principal: Principal, capability: TeamCapability, team: Team, ): void; export function requireCapability( principal: Principal, capability: WriteCapability, team?: Team, ): void { requireScope(principal, 'write'); if (permissionGranted(resolveWritePermissionGrants(principal), capability, team)) return; throw new AuthError( `This principal lacks the '${capability}' capability${team ? ` for ${team}` : ''}.`, 403, 'insufficient_permission', ); } /** * "May they do this on *some* team?" * * Separate from `requireCapability` and deliberately harder to type by * accident. Passing no team to the old `requireCapability` silently meant this * — which is how a research-team admin could bulk-import demand deals — so the * overloads above now refuse it and every remaining any-team check has to say * so in its own name. Use it only where no team is knowable yet: listing the * spreadsheets in someone's Drive, before an entity has been chosen. The * moment the target is known, go back to `requireCapability` with its team. */ export function requireAnyTeamCapability( principal: Principal, capability: TeamCapability, ): void { requireScope(principal, 'write'); const grants = resolveWritePermissionGrants(principal); for (const team of TEAM_CAPABILITY_RULES[capability].teams) { if (permissionGranted(grants, capability, team)) return; } throw new AuthError( `This principal lacks the '${capability}' capability on any team.`, 403, 'insufficient_permission', ); } /** * Reads are governed too. The 'read' scope is checked rather than 'write' * because a read-only API key is exactly the credential this must admit. */ export function requireReadCapability( principal: Principal, capability: ReadCapability, ): void { requireScope(principal, 'read'); if (permissionGranted(resolveReadPermissionGrants(principal), capability)) return; throw new AuthError( `This principal lacks the '${capability}' capability.`, 403, 'insufficient_permission', ); }