@beignet/core
Version:
Core framework primitives for Beignet
223 lines (206 loc) • 6.44 kB
text/typescript
import type { InferOutput, StandardSchema } from "../../contracts/index.js";
import { AuthUnauthorizedError } from "../../ports/index.js";
import type { HttpRequestLike, RouteHook } from "../types.js";
type MaybePromise<T> = T | Promise<T>;
/**
* Arguments passed to auth route-hook callbacks.
*/
export type AuthHookArgs<
Ctx,
HeadersSchema extends StandardSchema | undefined = undefined,
> = {
/**
* Framework-neutral request.
*/
req: HttpRequestLike;
/**
* Current route handler context.
*/
ctx: Ctx;
/**
* Matched contract metadata and schemas.
*/
contract: {
metadata?: Record<string, unknown>;
};
/**
* Parsed path parameters.
*/
path: unknown;
/**
* Parsed query parameters.
*/
query: unknown;
/**
* Hook-owned request headers.
*
* When the auth hooks declare a `headers` schema, this is that schema's
* output parsed from the raw lowercase request header record. Without a
* schema it is the raw lowercase header record itself, so auth resolution
* never depends on each route's contract header schema.
*/
headers: HeadersSchema extends StandardSchema
? InferOutput<HeadersSchema>
: Record<string, string>;
/**
* Parsed request body.
*/
body: unknown;
};
/**
* Options for route-scoped auth hooks.
*
* Auth additions must not include `gate`: the server re-attaches the gate
* declared by the context blueprint after every hook, so elevated identities
* are authorized against the updated context automatically.
*/
export type AuthHooksOptions<
Ctx,
AddedCtx extends object & { gate?: never },
HeadersSchema extends StandardSchema | undefined = undefined,
> = {
/**
* Hook name prefix used in diagnostics.
*/
name?: string;
/**
* Optional Standard Schema for the credential headers this hook reads.
*
* The schema is validated against the raw lowercase request header record
* before `resolve` runs. On `required()` hooks a schema failure rejects the
* request with a framework-owned 401; on `optional()` hooks a schema failure
* skips auth resolution; `public()` hooks never parse headers.
*/
headers?: HeadersSchema;
/**
* Resolve authenticated context additions for the current request.
*
* Return `null` when the request is unauthenticated. Required hooks will
* reject that request; optional hooks will add no auth context.
*/
resolve: (
args: AuthHookArgs<Ctx, HeadersSchema>,
) => MaybePromise<AddedCtx | null>;
};
/**
* Route-scoped auth hook set.
*/
export type AuthRouteHooks<Ctx, AddedCtx extends object & { gate?: never }> = {
/**
* Mark a route as intentionally public.
*/
public: () => RouteHook<Ctx, Record<string, never>>;
/**
* Resolve auth when present and add optional auth fields to the handler ctx.
*/
optional: () => RouteHook<Ctx, Partial<AddedCtx>>;
/**
* Require auth and add authenticated fields to the handler ctx.
*/
required: () => RouteHook<Ctx, AddedCtx>;
};
function rawRequestHeaders(req: HttpRequestLike): Record<string, string> {
const record: Record<string, string> = {};
req.headers.forEach((value, key) => {
record[key.toLowerCase()] = value;
});
return record;
}
type ParsedAuthHeaders = { ok: true; headers: unknown } | { ok: false };
async function parseAuthHeaders(
schema: StandardSchema | undefined,
req: HttpRequestLike,
): Promise<ParsedAuthHeaders> {
const raw = rawRequestHeaders(req);
if (!schema) {
return { ok: true, headers: raw };
}
const result = await schema["~standard"].validate(raw);
if (result.issues) {
return { ok: false };
}
return { ok: true, headers: result.value };
}
/**
* Create route-scoped authentication hooks.
*
* The outer call binds the app context; the inner call takes auth options and
* infers the added context from `resolve`:
*
* ```ts
* const auth = createAuthHooks<AppContext>()({
* resolve: ({ ctx }) => (ctx.auth ? { user: ctx.auth.user } : null),
* });
* ```
*
* Use `auth.required()` on routes that require an authenticated actor and
* `auth.optional()` where handlers can use auth when present. The returned
* route hooks enrich handler `ctx`; business authorization still belongs in
* feature policies or use cases.
*
* Declare a `headers` schema when credentials live in request headers. The
* hook validates the raw lowercase header record itself, so `resolve` receives
* typed headers without contract casts and a `required()` hook rejects
* missing or malformed credentials with a framework-owned 401.
*
* @returns A function that takes auth options and returns public, optional,
* and required route-hook factories.
*/
export function createAuthHooks<Ctx>() {
return <
AddedCtx extends object & { gate?: never },
HeadersSchema extends StandardSchema | undefined = undefined,
>(
options: AuthHooksOptions<Ctx, AddedCtx, HeadersSchema>,
): AuthRouteHooks<Ctx, AddedCtx> => {
const name = options.name ?? "auth";
const toAuthArgs = (
args: Parameters<RouteHook<Ctx, object>["resolve"]>[0],
headers: unknown,
): AuthHookArgs<Ctx, HeadersSchema> =>
({
req: args.req,
ctx: args.ctx,
contract: args.contract,
path: args.path,
query: args.query,
headers,
body: args.body,
}) as AuthHookArgs<Ctx, HeadersSchema>;
return {
public: () => ({
name: `${name}.public`,
resolve: () => undefined,
}),
optional: () => ({
name: `${name}.optional`,
resolve: async (args) => {
const parsed = await parseAuthHeaders(options.headers, args.req);
if (!parsed.ok) {
return undefined;
}
const additions = await options.resolve(
toAuthArgs(args, parsed.headers),
);
return additions ?? undefined;
},
}),
required: () => ({
name: `${name}.required`,
resolve: async (args) => {
const parsed = await parseAuthHeaders(options.headers, args.req);
if (!parsed.ok) {
throw new AuthUnauthorizedError();
}
const additions = await options.resolve(
toAuthArgs(args, parsed.headers),
);
if (!additions) {
throw new AuthUnauthorizedError();
}
return additions;
},
}),
};
};
}