134 lines
4.1 KiB
TypeScript
134 lines
4.1 KiB
TypeScript
import type { Context, Middleware } from "@wrnexus/core";
|
|
import type { AuthEngine } from "./engine.ts";
|
|
import { publicUser } from "./normalize.ts";
|
|
import type { AuthenticatedContext, AuthPublicUser, AuthSession } from "./types.ts";
|
|
|
|
export const AUTH_SESSION_KEY = "wrnexus.auth.session";
|
|
|
|
function wantsJson(ctx: Context): boolean {
|
|
if (ctx.url.pathname.startsWith("/api/") || ctx.url.pathname.startsWith("/__wrnexus/"))
|
|
return true;
|
|
const accept = ctx.req.headers.get("accept") ?? "";
|
|
return accept.includes("application/json") && !accept.includes("text/html");
|
|
}
|
|
|
|
export interface AuthSessionOptions {
|
|
/** Optional shared SSO cookie containing an AuthEngine session id. */
|
|
cookieName?: string;
|
|
}
|
|
|
|
export function authSession(engine: AuthEngine, options: AuthSessionOptions = {}): Middleware {
|
|
return async (ctx, next) => {
|
|
const sessionId =
|
|
(options.cookieName ? ctx.cookies.get(options.cookieName) : undefined) ??
|
|
ctx.session.get<string>(AUTH_SESSION_KEY);
|
|
if (!sessionId) {
|
|
ctx.user = null;
|
|
ctx.locals.authUser = null;
|
|
delete ctx.locals.authSession;
|
|
return next();
|
|
}
|
|
const session = await engine.validateSession(sessionId);
|
|
if (!session) {
|
|
ctx.session.delete(AUTH_SESSION_KEY);
|
|
ctx.user = null;
|
|
ctx.locals.authUser = null;
|
|
delete ctx.locals.authSession;
|
|
return next();
|
|
}
|
|
const user = await engine.getUser(session.userId);
|
|
if (!user || user.status !== "active") {
|
|
ctx.session.delete(AUTH_SESSION_KEY);
|
|
ctx.user = null;
|
|
ctx.locals.authUser = null;
|
|
delete ctx.locals.authSession;
|
|
return next();
|
|
}
|
|
const safe = publicUser(user);
|
|
ctx.user = safe;
|
|
ctx.locals.authUser = safe;
|
|
ctx.locals.authSession = session;
|
|
return next();
|
|
};
|
|
}
|
|
|
|
export function establishAuthSession(
|
|
ctx: Context,
|
|
session: AuthSession,
|
|
user: AuthPublicUser,
|
|
): void {
|
|
ctx.session.regenerate();
|
|
ctx.session.set(AUTH_SESSION_KEY, session.id);
|
|
ctx.user = user;
|
|
ctx.locals.authUser = user;
|
|
ctx.locals.authSession = session;
|
|
}
|
|
|
|
export function clearAuthSession(ctx: Context): void {
|
|
ctx.session.delete(AUTH_SESSION_KEY);
|
|
ctx.user = null;
|
|
delete ctx.locals.authUser;
|
|
delete ctx.locals.authSession;
|
|
}
|
|
|
|
export function getAuthUser(ctx: Context): AuthPublicUser | null {
|
|
return (
|
|
(ctx.locals.authUser as AuthPublicUser | null | undefined) ??
|
|
(ctx.user as AuthPublicUser | null | undefined) ??
|
|
null
|
|
);
|
|
}
|
|
|
|
/** Return a typed authenticated user or fail closed for direct handler use. */
|
|
export function requireAuthUser(ctx: Context): AuthPublicUser {
|
|
const user = getAuthUser(ctx);
|
|
if (!user) throw new AuthRequiredError();
|
|
return user;
|
|
}
|
|
|
|
export class AuthRequiredError extends Error {
|
|
readonly code = "WRN-AUTH-REQUIRED";
|
|
readonly status = 401;
|
|
|
|
constructor() {
|
|
super("Authentication is required");
|
|
this.name = "AuthRequiredError";
|
|
}
|
|
}
|
|
|
|
export function getAuthSession(ctx: Context): AuthSession | null {
|
|
return (ctx.locals.authSession as AuthSession | undefined) ?? null;
|
|
}
|
|
|
|
export interface RequireAuthOptions {
|
|
loginPath?: string;
|
|
returnToParam?: string;
|
|
roles?: string[];
|
|
status?: AuthPublicUser["status"][];
|
|
}
|
|
|
|
export function requireAuth(options: RequireAuthOptions = {}): Middleware {
|
|
const loginPath = options.loginPath ?? "/sign-in";
|
|
const returnToParam = options.returnToParam ?? "returnTo";
|
|
return (ctx, next) => {
|
|
const user = getAuthUser(ctx);
|
|
const allowedStatus = options.status ?? ["active"];
|
|
const allowedRole =
|
|
!options.roles?.length || options.roles.some((role) => user?.roles.includes(role));
|
|
if (user && allowedStatus.includes(user.status) && allowedRole) return next();
|
|
if (wantsJson(ctx)) {
|
|
return Response.json(
|
|
{ ok: false, error: user ? "Forbidden" : "Unauthorized" },
|
|
{ status: user ? 403 : 401 },
|
|
);
|
|
}
|
|
const redirect = new URL(loginPath, ctx.url);
|
|
redirect.searchParams.set(returnToParam, `${ctx.url.pathname}${ctx.url.search}`);
|
|
return Response.redirect(redirect, 302);
|
|
};
|
|
}
|
|
|
|
export function isAuthenticatedContext(ctx: Context): ctx is AuthenticatedContext {
|
|
return Boolean(getAuthUser(ctx));
|
|
}
|