Files
0pt/src/auth/context.ts
T
admin bd1c20edb2
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 7s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 21s
CI (SIAX Cloud) / security (pull_request) Successful in 25s
CI (SIAX Cloud) / quality (push) Successful in 1m17s
CI (SIAX Cloud) / integration (pull_request) Successful in 54s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m7s
feat: add guarded document workbench foundations
2026-11-02 01:26:36 +01:00

267 lines
10 KiB
TypeScript

/**
* `0pt/auth/context.ts` -- request-context extraction AND auth, the single
* seam the framework layer (Next.js middleware.ts) calls.
*
* ==========================================================================
* AUTH DEFAULT: FAIL-CLOSED
*
* 0pt performs REAL, cryptographic Zitadel (OIDC) Bearer-token verification
* (src/auth/zitadel.ts). Enforcement is controlled by AUTH_ENFORCE, and the
* DEFAULT IS ENFORCED -- an absent, empty, or misspelled value authenticates:
*
* AUTH_ENFORCE unset, or any value except "false" (DEFAULT, enforced):
* REQUIRES a valid, verified token on every non-exempt route. The tenant
* is derived from the TOKEN (not the X-Tenant-Id header). Missing/invalid
* token -> 401 (OPT_UNAUTHENTICATED). If an X-Tenant-Id header is present
* and disagrees with the token's tenant -> 403 (OPT_ACCESS_DENIED).
*
* AUTH_ENFORCE == "false" (explicit local-development escape hatch):
* No token is required; the tenant comes from the X-Tenant-Id header /
* request body. A present, JWT-shaped Bearer token is still best-effort
* verified and attached, but never rejected. This mode is REFUSED in
* production: {@link assertAuthConfigSafe} throws when NODE_ENV=production,
* so an instance that loses (or mis-sets) the variable cannot serve
* openly -- it fails to start, and fails the request, instead.
*
* WHY THE INVERSION: the predicate previously read `AUTH_ENFORCE === 'true'`,
* so every fresh container, local run, DR restore, or redeploy that dropped
* the variable ran with NO authentication at all, and `.env.example` shipped
* `AUTH_ENFORCE=false`. Absence now means enforced.
*
* This module is framework-neutral: it takes a minimal header-lookup (the Web
* `Headers` interface satisfies it, as does Next's `NextRequest.headers`) and
* returns a decision object. It does NOT import next/*, so it is fully
* unit-testable in isolation.
* ==========================================================================
*/
import { verifyBearer, type VerifiedPrincipal } from './zitadel';
/** Minimal header accessor -- Web `Headers` and `NextRequest.headers` satisfy this. */
export interface HeaderLookup {
get(name: string): string | null | undefined;
}
export interface OptRequestContext {
/** Tenant id: from the token when enforced, else from the X-Tenant-Id header. */
tenantId: string;
/** Actor id: the verified token subject when enforced; header only in explicit dev mode. */
actorId: string;
/** Correlation/request id (X-Request-Id header, else a fresh uuid). */
requestId: string;
/** The raw bearer token, if an Authorization: Bearer header was present. */
bearerToken?: string;
/** Present when a Bearer token was successfully verified (either mode). */
principal?: VerifiedPrincipal;
}
export type AuthDecision =
| { ok: true; ctx: OptRequestContext }
| {
ok: false;
status: 401 | 403;
code: 'OPT_UNAUTHENTICATED' | 'OPT_ACCESS_DENIED';
message: string;
details?: Record<string, unknown>;
ctx: OptRequestContext;
};
/** Name of the environment variable that can switch enforcement off. */
export const AUTH_ENFORCE_VAR = 'AUTH_ENFORCE';
/** The only two environment values this module reads; injectable for tests. */
export interface AuthEnvView {
AUTH_ENFORCE?: string;
NODE_ENV?: string;
}
/**
* Snapshot the auth-relevant environment. `process.env.NODE_ENV` is written
* literally so bundlers (Next's edge compiler) inline it.
*/
function currentEnv(): AuthEnvView {
return { AUTH_ENFORCE: process.env.AUTH_ENFORCE, NODE_ENV: process.env.NODE_ENV };
}
/**
* True unless enforcement was EXPLICITLY disabled with AUTH_ENFORCE="false".
*
* FAIL-CLOSED: unset, empty, "flase", "0", or any other value => ENFORCED.
*/
export function authEnforced(env: AuthEnvView = currentEnv()): boolean {
return env.AUTH_ENFORCE?.trim().toLowerCase() !== 'false';
}
/** Thrown when the process is configured to run without auth in production. */
export class AuthMisconfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthMisconfigurationError';
}
}
/**
* Refuse to run open in production. Throws {@link AuthMisconfigurationError}
* when NODE_ENV=production and enforcement resolved to OFF.
*
* Called at module scope by middleware.ts (startup) AND on the env-driven
* request path below, so a misconfigured production instance can neither boot
* nor serve a single unauthenticated request.
*/
export function assertAuthConfigSafe(env: AuthEnvView = currentEnv()): void {
if (env.NODE_ENV === 'production' && !authEnforced(env)) {
throw new AuthMisconfigurationError(
`0pt refuses to run with authentication disabled in production: ` +
`${AUTH_ENFORCE_VAR} is explicitly set to "${env.AUTH_ENFORCE}" while NODE_ENV=production. ` +
`Unset ${AUTH_ENFORCE_VAR} (absent = enforced) or set ${AUTH_ENFORCE_VAR}=true. ` +
`${AUTH_ENFORCE_VAR}=false is a local-development escape hatch only.`,
);
}
}
function firstHeader(headers: HeaderLookup, name: string): string | undefined {
const v = headers.get(name);
if (v === null || v === undefined) return undefined;
return v;
}
/** A raw token that structurally looks like a compact JWS (three segments). */
function looksLikeJwt(token: string): boolean {
return token.split('.').length === 3;
}
function newRequestId(): string {
try {
return globalThis.crypto.randomUUID();
} catch {
return `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
}
/** Read whatever context headers are present, without enforcing required-ness. */
export function readRequestContext(headers: HeaderLookup): OptRequestContext {
const auth = firstHeader(headers, 'authorization');
const bearerToken = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : undefined;
return {
tenantId: firstHeader(headers, 'x-tenant-id') ?? '',
actorId: firstHeader(headers, 'x-actor-id') ?? '',
requestId: firstHeader(headers, 'x-request-id') ?? newRequestId(),
bearerToken: bearerToken && bearerToken.length > 0 ? bearerToken : undefined,
};
}
/** Minimal logger seam so middleware can pass the platform logger; defaults to console. */
export interface AuthLogger {
warn(obj: unknown, msg?: string): void;
}
export interface ResolveAuthOptions {
/** Injectable verifier (test seam); defaults to the real {@link verifyBearer}. */
verify?: typeof verifyBearer;
/** Force enforcement on/off (test seam); defaults to {@link authEnforced}. */
enforce?: boolean;
logger?: AuthLogger;
}
/**
* Resolve and (when enforced -- the default) enforce the request auth context.
* Returns an {@link AuthDecision}; the caller (middleware) turns a `!ok`
* decision into an HTTP response and an `ok` decision into forwarded principal
* headers.
*
* Throws {@link AuthMisconfigurationError} when the enforcement decision comes
* from the environment and that environment is "production with auth off".
*/
export async function resolveAuthContext(
headers: HeaderLookup,
opts: ResolveAuthOptions = {},
): Promise<AuthDecision> {
const verify = opts.verify ?? verifyBearer;
let enforce: boolean;
if (opts.enforce !== undefined) {
enforce = opts.enforce;
} else {
// Never serve openly in production, even if the startup check was bypassed.
assertAuthConfigSafe();
enforce = authEnforced();
}
const logger: AuthLogger = opts.logger ?? {
warn: (o, m) => console.warn(m ?? 'auth warning', o),
};
const ctx = readRequestContext(headers);
if (enforce) {
// --- ENFORCED (DEFAULT): a valid verified token is mandatory ------------
if (!ctx.bearerToken) {
return {
ok: false,
status: 401,
code: 'OPT_UNAUTHENTICATED',
message: 'Authentication required: missing Authorization: Bearer <token>',
ctx,
};
}
let principal: VerifiedPrincipal;
try {
principal = await verify(ctx.bearerToken);
} catch (err) {
return {
ok: false,
status: 401,
code: 'OPT_UNAUTHENTICATED',
message: 'Authentication required: invalid or unverifiable Bearer token',
details: { reason: err instanceof Error ? err.message : 'verification_failed' },
ctx,
};
}
// Tenant is derived from the TOKEN. A disagreeing X-Tenant-Id is a 403.
const headerTenant = firstHeader(headers, 'x-tenant-id');
if (headerTenant && headerTenant !== principal.tenantId) {
return {
ok: false,
status: 403,
code: 'OPT_ACCESS_DENIED',
message: 'X-Tenant-Id header disagrees with authenticated token tenant',
details: { header_tenant: headerTenant, token_tenant: principal.tenantId },
ctx,
};
}
// A token proves its own subject, not any actor named in a header.
// Delegation is denied until an explicit, verified grant contract exists.
const headerActor = firstHeader(headers, 'x-actor-id');
if (headerActor && headerActor !== principal.sub) {
return {
ok: false,
status: 403,
code: 'OPT_ACCESS_DENIED',
message: 'X-Actor-Id header disagrees with authenticated token subject',
ctx,
};
}
ctx.tenantId = principal.tenantId;
ctx.actorId = principal.sub;
ctx.principal = principal;
return { ok: true, ctx };
}
// --- EXPLICIT OPT-OUT (AUTH_ENFORCE=false, non-production only) -----------
// Never rejects; a present token is best-effort verified so tokens can start
// flowing in local development.
if (ctx.bearerToken && looksLikeJwt(ctx.bearerToken)) {
try {
const principal = await verify(ctx.bearerToken);
ctx.principal = principal;
// Do NOT overwrite the header/body tenant in this mode; only fill it in
// when the caller supplied none.
if (!ctx.tenantId) ctx.tenantId = principal.tenantId;
if (!ctx.actorId) ctx.actorId = principal.sub;
} catch (err) {
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
`0pt: bearer verification failed (${AUTH_ENFORCE_VAR}=false, ignoring)`,
);
}
}
return { ok: true, ctx };
}