This commit is contained in:
+70
-32
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user