Build the agent-native compute CRM platform
CI / verify (push) Successful in 3m6s

This commit is contained in:
2026-08-13 01:39:01 -07:00
parent bfd2f8d95a
commit 853bde2265
160 changed files with 61812 additions and 483 deletions
+70 -32
View File
@@ -4,9 +4,8 @@
* 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 Supabase.
* PIG verifies the JWT against the project's JWKS. It stores no passwords
* and issues no sessions of its own.
* **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.
@@ -20,13 +19,21 @@
* A token with no matching PIG user gets 403 with `needs_profile`, which the
* front end turns into the invite-redemption screen.
*/
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { eq } from 'drizzle-orm';
import type { Database } from '@pig/db';
import { apiKeys, teamMemberships, users } from '@pig/db';
import type { Team, TeamRole } from '@pig/core';
import {
permissionGranted,
resolvePermissionGrants,
type Capability,
type PermissionGrant,
type Team,
type TeamCapability,
type TeamRole,
} 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;
@@ -51,13 +58,11 @@ export class AuthError extends Error {
}
}
export function createAuthenticator(config: Config, db: Database) {
// The JWKS is fetched lazily and cached by `jose`, which also handles key
// rotation. Building it once avoids a fetch per request.
const jwks = config.SUPABASE_URL
? createRemoteJWKSet(new URL(`${config.SUPABASE_URL}/auth/v1/.well-known/jwks.json`))
: null;
export function createAuthenticator(
config: Config,
db: Database,
authProvider: AuthProvider | null,
) {
async function loadPrincipal(
userId: string,
via: Principal['via'],
@@ -95,13 +100,21 @@ export function createAuthenticator(config: Config, db: Database) {
* 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.
* own audit trail and its own scopes.
*/
async authenticate(header: string | undefined): Promise<Principal> {
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 Supabase, so this cannot leak into a
// real deployment.
if (!config.SUPABASE_URL && !config.isProduction) {
// 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(
@@ -116,22 +129,14 @@ export function createAuthenticator(config: Config, db: Database) {
if (!header?.startsWith('Bearer ')) {
throw new AuthError('Missing bearer token.', 401, 'no_token');
}
const token = header.slice(7).trim();
// PIG-issued API keys carry a recognisable prefix, so we can route
// without attempting an expensive and pointless JWT verification.
if (token.startsWith('pig_')) return authenticateApiKey(token);
if (!jwks) throw new AuthError('Authentication is not configured.', 401, 'no_jwks');
if (!authProvider) {
throw new AuthError('Authentication is not configured.', 401, 'no_jwks');
}
let subject: string;
try {
const { payload } = await jwtVerify(token, jwks, {
// Supabase signs with the project URL as issuer.
issuer: `${config.SUPABASE_URL}/auth/v1`,
});
if (!payload.sub) throw new Error('token has no subject');
subject = payload.sub;
subject = (await authProvider.verifyAccessToken(token!)).subject;
} catch {
// Deliberately opaque: distinguishing "expired" from "malformed" from
// "wrong issuer" tells an attacker which knob to turn.
@@ -167,10 +172,7 @@ export function createAuthenticator(config: Config, db: Database) {
.limit(1);
if (!record) throw new AuthError('Unknown API key.', 401, 'invalid_key');
if (record.revokedAt) throw new AuthError('This API key was revoked.', 401, 'revoked_key');
if (record.expiresAt && record.expiresAt < new Date()) {
throw new AuthError('This API key has expired.', 401, 'expired_key');
}
assertApiKeyActive(record);
// Best-effort last-used stamp. Never block the request on it: a failed
// bookkeeping write must not deny access.
@@ -187,6 +189,16 @@ export function createAuthenticator(config: Config, db: Database) {
}
}
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.
*
@@ -227,3 +239,29 @@ 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. */
export function effectivePermissions(principal: Principal): PermissionGrant[] {
if (!principal.scopes.includes('write')) return [];
return resolvePermissionGrants(principal);
}
export function requireCapability(principal: Principal, capability: Capability): void;
export function requireCapability(
principal: Principal,
capability: TeamCapability,
team: Team,
): void;
export function requireCapability(
principal: Principal,
capability: Capability,
team?: Team,
): void {
requireScope(principal, 'write');
if (permissionGranted(resolvePermissionGrants(principal), capability, team)) return;
throw new AuthError(
`This principal lacks the '${capability}' capability${team ? ` for ${team}` : ''}.`,
403,
'insufficient_permission',
);
}