Files
WRNexusJS/packages/authz/src/middleware.ts
T
Clintchiz a3ddd39b7b
Quality / quality (ubuntu-latest) (push) Failing after 14m38s
Quality / quality (windows-latest) (push) Canceled after 0s
feat: centralize application framework primitives
2026-08-22 23:07:46 +05:30

329 lines
12 KiB
TypeScript

import type { Context, Middleware } from "@wrnexus/core";
import type { AuthorizationDecision } from "./advanced.ts";
import { safeRecord, type AuthzAuditSink } from "./audit.ts";
import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts";
import type { AuthzScope } from "./types.ts";
/**
* `can` is deliberately not a Context member: @wrnexus/core must not depend on
* @wrnexus/authz. The per-request resolver lives here instead.
*/
export const AUTHZ_LOCALS_KEY = "_authz";
export interface AuthorizationResponses {
forbidden(decision?: AuthorizationDecision, options?: { exposeReason?: boolean }): Response;
notFoundOrForbidden(options?: {
mayDiscover?: boolean;
decision?: AuthorizationDecision;
exposeReason?: boolean;
}): Response;
}
export interface RequestAuthorization extends AuthorizationResponses {
can(permission: string, resource?: unknown): Promise<boolean>;
decide(permission: string, resource?: unknown): Promise<AuthorizationDecision>;
}
declare module "@wrnexus/core" {
interface Context {
/** Installed by authzMiddleware for handlers that prefer context-local authorization. */
authz?: RequestAuthorization;
}
}
interface RequestAuthz {
resolver: AuthzResolver;
/**
* Same sink `decide()` records through. Stashed here too so a denial that
* never reaches the resolver (e.g. `guardPermission`'s `getResource`
* throwing) can still be audited, instead of vanishing from the trail.
*/
audit: AuthzAuditSink | undefined;
/** Memo for object resources, keyed by identity so two rows never collide. */
byRef: WeakMap<object, Map<string, Promise<AuthorizationDecision>>>;
/** Memo for symbol resources, keyed by identity for the same reason. */
bySymbol: Map<symbol, Map<string, Promise<AuthorizationDecision>>>;
/** Memo for primitive and absent resources. */
byValue: Map<string, Promise<AuthorizationDecision>>;
}
function readAuthz(ctx: Context): RequestAuthz {
const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined;
if (!value) {
throw new Error(
"WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " +
"Add it to app/middleware before calling can()/guardPermission().",
);
}
return value;
}
/** Install the per-request resolver. Register early, after sessionAuth. */
export function authzMiddleware(options: AuthzResolverOptions): Middleware {
const resolver = createAuthzResolver(options);
return (ctx, next) => {
const request: RequestAuthz = {
resolver,
audit: options.audit,
byRef: new WeakMap(),
bySymbol: new Map(),
byValue: new Map(),
};
ctx.locals[AUTHZ_LOCALS_KEY] = request;
ctx.authz = {
can: (permission, resource) => can(ctx, permission, resource),
decide: (permission, resource) => decideFor(ctx, permission, resource),
forbidden: (decision, responseOptions) =>
forbiddenResponse(decision, responseOptions?.exposeReason),
notFoundOrForbidden: (responseOptions = {}) =>
responseOptions.mayDiscover
? Response.json(
{ ok: false, error: "Not Found" },
{ status: 404, headers: NO_STORE_HEADERS },
)
: forbiddenResponse(responseOptions.decision, responseOptions.exposeReason),
};
return next();
};
}
function forbiddenResponse(decision?: AuthorizationDecision, exposeReason = false): Response {
return Response.json(
exposeReason
? { ok: false, error: "Forbidden", reason: decision?.reason, policy: decision?.policy }
: { ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
}
export function getRequestAuthorization(ctx: Context): RequestAuthorization {
if (!ctx.authz) {
throw new Error("WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request.");
}
return ctx.authz;
}
/**
* Read the tenant from the context at decision time, not at middleware time:
* a request that switches tenant mid-flight must not keep the old scope.
*/
function currentScope(ctx: Context): AuthzScope | undefined {
const tenantId = ctx.tenant?.id;
return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined;
}
/**
* Same normalization `decide()` applies before handing a subject id to the
* audit sink: a non-empty string, or undefined (never a raw non-string id
* leaking into an audit record).
*/
function subjectIdOf(ctx: Context): string | undefined {
const rawId = (ctx.user as { id?: unknown } | null | undefined)?.id;
return typeof rawId === "string" && rawId !== "" ? rawId : undefined;
}
/**
* Object resources are memoised by identity (`byRef`), never by serialising
* their contents — serialisation is what let unrelated resources collide
* (same `id` shape, circular references, BigInt fields, throwing getters all
* funnelled into one bucket). Symbols are memoised by identity too (`bySymbol`)
* since `String(symbol)` collapses distinct symbols with the same description.
* Primitive/absent resources are memoised by a
* `[scope, permission, typeof, String(value)]` tuple, with `-0` rendered
* distinctly from `0` since `String(-0) === "0"` would otherwise merge them.
*
* Subject and scope are both part of the key. A request that reassigns
* ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant
* must not be served the previous principal's verdict from the memo.
*/
export function decideFor(
ctx: Context,
permission: string,
resource?: unknown,
): Promise<AuthorizationDecision> {
const request = readAuthz(ctx);
const scope = currentScope(ctx);
const subjectId = (ctx.user as { id?: unknown } | null | undefined)?.id;
const key = JSON.stringify([
scope?.tenantId ?? "",
permission,
typeof subjectId,
String(subjectId),
]);
const run = () =>
request.resolver.decide({
subject: ctx.user as { id?: string } | null | undefined,
permission,
resource,
scope,
});
// Symbols carry identity that String() erases, so they memo by identity too.
// They are held in a plain Map rather than the WeakMap: the memo is discarded
// with the request, so there is nothing to leak.
if (typeof resource === "symbol") {
let perSymbol = request.bySymbol.get(resource);
if (!perSymbol) request.bySymbol.set(resource, (perSymbol = new Map()));
const cached = perSymbol.get(key);
if (cached) return cached;
const pending = run();
perSymbol.set(key, pending);
return pending;
}
const isObjectResource =
resource !== null &&
resource !== undefined &&
(typeof resource === "object" || typeof resource === "function");
if (isObjectResource) {
const resourceObject = resource as object;
let inner = request.byRef.get(resourceObject);
if (!inner) {
inner = new Map();
request.byRef.set(resourceObject, inner);
}
const cached = inner.get(key);
if (cached) return cached;
const pending = run();
inner.set(key, pending);
return pending;
}
const rendered = Object.is(resource, -0) ? "-0" : String(resource);
const valueKey = JSON.stringify([key, typeof resource, rendered]);
const cached = request.byValue.get(valueKey);
if (cached) return cached;
const pending = run();
request.byValue.set(valueKey, pending);
return pending;
}
export async function can(ctx: Context, permission: string, resource?: unknown): Promise<boolean> {
return (await decideFor(ctx, permission, resource)).allowed;
}
/**
* Replicates `packages/core/src/auth.ts`'s `wantsJson` (not imported: authz
* may only pull TYPES from @wrnexus/core, never runtime code).
*/
function wantsJson(ctx: Context): boolean {
if (ctx.url.pathname.startsWith("/api/")) return true;
const accept = ctx.req.headers.get("accept") ?? "";
return accept.includes("application/json") && !accept.includes("text/html");
}
/**
* Refuse anything but a same-origin, same-app path: no scheme/host
* (`https://evil.example.com/...`), no protocol-relative target (`//evil...`
* is host-relative in a browser, not path-relative), no backslashes (some
* user agents treat `\` as `/`, which can smuggle a host past a naive
* `startsWith("/")` check), and no control characters (CR/LF header/response
* splitting, etc). Written as a codepoint loop rather than a control-char
* regex literal, which tooling in this repo mangles.
*/
function isLocalPath(target: string): boolean {
if (!target.startsWith("/")) return false;
if (target.startsWith("//")) return false;
if (target.includes("\\")) return false;
for (const ch of target) {
const code = ch.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) return false;
}
return true;
}
/**
* Header values must be Latin-1, so a localized path would otherwise throw
* inside `new Response` and 500 on a denial path. Encode ONLY the codepoints
* that cannot be sent: encodeURI would also escape "%", corrupting a target
* that already carries a percent-encoded return path.
*/
function headerSafePath(value: string): string {
let out = "";
for (const character of value) {
out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character);
}
return out;
}
const NO_STORE_HEADERS = { "cache-control": "private, no-store" } as const;
export interface GuardOptions {
/** Load the resource a bound policy needs. */
getResource?: (ctx: Context) => unknown;
/** Include reason and policy name in the 403 body. Off by default. */
exposeReason?: boolean;
/** Redirect page requests here instead of returning 403. Ignored for JSON/API requests and for any non-local target. */
redirectTo?: string;
}
/**
* Guard a route on a registered permission. Named `guardPermission` because
* `requirePermission(rbac, permission)` already exists with a different shape.
*/
export function guardPermission(permission: string, options: GuardOptions = {}): Middleware {
return async (ctx, next) => {
let resource: unknown;
if (options.getResource) {
try {
resource = await options.getResource(ctx);
} catch (error) {
console.error(`[wrnexus:authz] getResource threw for '${permission}'; denying`, error);
// This denial never reaches decideFor()/decide()/finish() — the
// resource load failed before there was anything to decide — so
// without recording here it would be invisible to the audit trail:
// an attacker probing ids that make the loader throw gets a clean
// 403 stream no operator can see. Keep the response body opaque
// (no loader message), same as every other guardPermission denial.
const { audit } = readAuthz(ctx);
safeRecord(audit, {
subjectId: subjectIdOf(ctx),
scope: currentScope(ctx),
permission,
allowed: false,
reason: "Resource unavailable",
at: Date.now(),
});
return forbiddenResponse();
}
}
const result = await decideFor(ctx, permission, resource);
if (result.allowed) return next();
if (options.redirectTo && !wantsJson(ctx)) {
if (isLocalPath(options.redirectTo)) {
return new Response(null, {
status: 303,
// headerSafePath, not encodeURI: a non-ASCII local path (e.g. a
// localized login route) is valid config but not a valid raw
// header value, while encodeURI would also mangle a target that
// already carries a percent-encoded return path.
headers: { location: headerSafePath(options.redirectTo), ...NO_STORE_HEADERS },
});
}
// JSON.stringify, not string interpolation: this branch exists precisely
// for targets containing CR/LF, which must not reach the log verbatim.
console.error(
`[wrnexus:authz] guardPermission redirectTo ${JSON.stringify(options.redirectTo)} is not a local path; falling back to 403`,
);
}
return forbiddenResponse(result, options.exposeReason);
};
}
/** Keep only the items the current subject may act on. */
export async function filterCan<T>(
ctx: Context,
permission: string,
items: readonly T[],
): Promise<T[]> {
const verdicts = await Promise.all(
items.map(async (item) => ({ item, allowed: await can(ctx, permission, item) })),
);
return verdicts.filter((entry) => entry.allowed).map((entry) => entry.item);
}