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
267 lines
10 KiB
TypeScript
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 };
|
|
}
|