Split handleRequest into pipeline + internal route table; auth/OAuth2 endpoints become named handlers in src/auth/routes.ts

This commit is contained in:
2026-07-02 13:05:54 +02:00
parent 8621cf24b6
commit 535902e69b
7 changed files with 434 additions and 264 deletions
+46
View File
@@ -0,0 +1,46 @@
// buildAuthRoutes registers only the endpoints the wired clients support — a missing client
// means the path isn't in the table at all (the host then 404s). The endpoints' behaviour is
// covered end-to-end through the app in src/http/app.test.ts.
import { test } from "node:test";
import assert from "node:assert/strict";
import { AUTH_FLOWS } from "./flow-view.ts";
import type { HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
import { DEFAULT_MENU } from "../ui/menu-config.ts";
import { type AuthRouteDeps, buildAuthRoutes } from "./routes.ts";
// Assembly never calls the clients, so identity-only stubs suffice.
const hydra = {} as unknown as HydraAdmin;
const keto = {} as unknown as KetoClient;
const kratos = {} as unknown as KratosPublic;
const kratosAdmin = {} as unknown as KratosAdmin;
const deps = (wired: Partial<AuthRouteDeps>): AuthRouteDeps =>
({ hydra: undefined, keto: undefined, kratos: undefined, kratosAdmin: undefined, menu: DEFAULT_MENU, secureCookies: false, ...wired });
const keys = (routes: { method: string; path: string }[]): string[] => routes.map((r) => `${r.method} ${r.path}`).sort();
test("nothing wired ⇒ only the flow-error sink exists", () => {
assert.deepEqual(keys(buildAuthRoutes(deps({}))), ["GET /error"]);
});
test("kratos wired ⇒ the themed flow pages + POST /logout; no OAuth2, no /auth/complete", () => {
const got = keys(buildAuthRoutes(deps({ kratos })));
assert.deepEqual(got, keys([
...Object.keys(AUTH_FLOWS).map((path) => ({ method: "GET", path })),
{ method: "GET", path: "/error" },
{ method: "POST", path: "/logout" },
]));
});
test("hydra alone ⇒ only RP-initiated logout of the OAuth2 group (login/consent need kratos)", () => {
assert.deepEqual(keys(buildAuthRoutes(deps({ hydra }))), ["GET /error", "GET /oauth2/logout"]);
});
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
const got = keys(buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin })));
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
assert.ok(got.includes(key), key);
}
});
+254
View File
@@ -0,0 +1,254 @@
// The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table
// (src/http/builtin-routes.ts): the themed Kratos self-service pages, the Hydra OAuth2
// login/consent/logout challenges, login completion, the first-party logout, and Kratos'
// flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing
// client means the path simply doesn't exist (404). `view` results render the core views.
import { readFormBody } from "../http/body.ts";
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
import type { RequestContext } from "../http/context.ts";
import { CSRF_FIELD } from "./csrf.ts";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { type Flow, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts";
import { clearSessionCookie, completeLogin, sessionCookie } from "./login.ts";
import type { MenuConfig } from "../ui/menu-config.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
import { resolveLoginChallenge } from "./oauth-login.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
import { localPath } from "../http/safe-url.ts";
export interface AuthRouteDeps {
hydra: HydraAdmin | undefined; // OAuth2 provider challenges; absent ⇒ /oauth2/* don't exist
keto: KetoClient | undefined; // with kratos+kratosAdmin: login completion
kratos: KratosPublic | undefined; // themed self-service pages + logout
kratosAdmin: KratosAdmin | undefined;
menu: MenuConfig; // consent-screen branding
secureCookies: boolean; // cookie Secure flag + the scheme of self-referencing absolute URLs
}
const TEXT_PLAIN = { "content-type": "text/plain; charset=utf-8" };
const FORBIDDEN: RouteResult = { data: { title: "Forbidden" }, status: 403, view: "403" };
// Scheme + host for a self-referencing absolute URL (Kratos/Hydra return targets). Host reflects
// what the browser used (so it matches the allow-lists); scheme follows SECURE_COOKIES. A spoofed
// Host can't escape — Kratos/Hydra validate return URLs against their allow-lists.
const selfOrigin = (ctx: RequestContext, secure: boolean): string =>
`${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`;
// Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/
// verification/settings).
function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => {
const pathname = ctx.url.pathname;
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
const cookie = ctx.req.headers.cookie;
const flowId = ctx.url.searchParams.get("flow");
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
let flow: Flow;
try {
if (!flowId) {
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
// A `return_to` is baked into the flow so Kratos lands there after login instead of the
// default completion route. A first-party deep link (host-relative, from the gate's
// return_to) is wrapped through /auth/complete so the session JWT is minted before the
// user reaches the page; an absolute target (the OAuth2 login challenge) is passed
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw);
let returnTo: string | undefined;
if (local) {
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
complete.searchParams.set("return_to", local);
returnTo = complete.toString();
} else if (raw) returnTo = raw;
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie);
return { redirect: `${pathname}?flow=${initiated.id}` };
}
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
} catch (err) {
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
// login (mint the JWT from the live session), honouring return_to — never a 500.
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
const local = localPath(ctx.url.searchParams.get("return_to"));
return { redirect: local ? `/auth/complete?return_to=${encodeURIComponent(local)}` : "/auth/complete" };
}
// Ory unreachable (Kratos 5xx / connection refused / timeout): "Ory down ⇒ no logins" is
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
if (!(err instanceof KratosError) || err.status >= 500) {
ctx.log.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname });
return { data: { title: "Sign-in unavailable" }, status: 503, view: "503" };
}
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
}
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
csrf.setCookie();
return { data: { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }, view: "auth" };
};
}
// OAuth2 login challenge: Hydra hands the browser here when another app logs in *through* us.
// Resolve it via the Kratos session and accept; an unauthenticated user bounces to our themed
// login and returns here once signed in. Provider-only.
function oauthLogin(deps: { hydra: HydraAdmin; kratos: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => {
const challenge = ctx.url.searchParams.get("login_challenge");
if (!challenge) return { headers: TEXT_PLAIN, html: "Missing login_challenge", status: 400 };
// Absolute return target so Kratos lands back here post-login.
const selfUrl = `${selfOrigin(ctx, secureCookies)}/oauth2/login?login_challenge=${encodeURIComponent(challenge)}`;
try {
const { redirect } = await resolveLoginChallenge(deps, challenge, ctx.req.headers.cookie, selfUrl);
return { redirect };
} catch (err) {
// A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL) is
// user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500.
if (err instanceof HydraError && err.status < 500) {
return { headers: TEXT_PLAIN, html: "This sign-in request has expired. Please start again from the application you were signing in to.", status: 400 };
}
throw err;
}
};
}
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500 (as /oauth2/login).
function consentError(err: unknown): RouteResult {
if (err instanceof HydraError && err.status < 500) {
return { headers: TEXT_PLAIN, html: "This authorization request has expired. Please start again from the application you were signing in to.", status: 400 };
}
throw err;
}
// OAuth2 consent challenge: after login Hydra hands the browser here. A first-party (or
// Hydra-skipped) client is auto-granted its scopes; a third-party client gets the themed consent
// screen, whose CSRF-guarded POST (consentDecision) accepts or rejects. Provider-only.
function consentScreen(deps: { hydra: HydraAdmin; kratos: KratosPublic }, brand: string): BuiltinRoute["handler"] {
return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => {
const challenge = ctx.url.searchParams.get("consent_challenge");
if (!challenge) return { headers: TEXT_PLAIN, html: "Missing consent_challenge", status: 400 };
try {
const { redirect, view } = await resolveConsentChallenge(deps, challenge, ctx.req.headers.cookie);
if (redirect) return { redirect };
// Third-party: show the consent screen, carrying a CSRF token its form echoes back.
csrf.setCookie();
return { data: { brand, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }, view: "oauth-consent" };
} catch (err) {
return consentError(err);
}
};
}
// The consent screen's POST: allow → accept the requested scopes, anything else → deny.
function consentDecision(deps: { hydra: HydraAdmin; kratos: KratosPublic }): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => {
const form = await readFormBody(ctx.req);
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) {
ctx.log.warn("csrf rejected", { path: ctx.url.pathname });
return FORBIDDEN;
}
const challenge = form.get("consent_challenge");
if (!challenge) return { headers: TEXT_PLAIN, html: "Missing consent_challenge", status: 400 };
try {
const redirect = form.get("decision") === "allow"
? await acceptConsent(deps, challenge, ctx.req.headers.cookie)
: await rejectConsent(deps, challenge);
return { redirect };
} catch (err) {
return consentError(err);
}
};
}
// OAuth2 RP-initiated logout: Hydra hands the browser here to end the OAuth2 session
// (hydra.yml urls.logout). Accept the challenge and resume to Hydra's post-logout redirect; the
// first-party POST /logout owns the Kratos session + our JWT cookie. Provider-only. GET-accept
// is safe (like the login/consent handlers): the challenge is Hydra-minted + single-use, so a
// forged GET can't fabricate one — we skip only the optional "confirm logout?".
function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => {
const challenge = ctx.url.searchParams.get("logout_challenge");
if (!challenge) return { headers: TEXT_PLAIN, html: "Missing logout_challenge", status: 400 };
try {
const { redirect } = await hydra.acceptLogoutRequest(challenge);
return { redirect };
} catch (err) {
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500.
if (err instanceof HydraError && err.status < 500) {
return { headers: TEXT_PLAIN, html: "This logout request has expired. Please start again from the application you were signing out of.", status: 400 };
}
throw err;
}
};
}
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
// session JWT — read roles from Keto, project onto the identity, tokenize — and store it as the
// cookie; no active session bounces back to sign in.
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => {
const completed = await completeLogin(deps, ctx.req.headers.cookie);
if (!completed) return { redirect: "/login" };
ctx.res.appendHeader("set-cookie", sessionCookie(completed.jwt, { secure: secureCookies }));
// Land on the deep link the user was headed to (return_to, validated host-relative so a
// crafted ?return_to= can't make this an open redirect), else the gated dashboard.
return { redirect: localPath(ctx.url.searchParams.get("return_to")) ?? "/dashboard" };
};
}
// Logout: a state change, so a CSRF-guarded POST (the shell submits a form, not a GET link).
// Clear our local JWT and revoke the Kratos session — Kratos' own cookie lives on its origin, so
// redirect to its logout URL (it revokes the session, clears plainpages_session, then lands on
// /login per kratos.yml). No active session ⇒ just clear our cookie and go to /login.
function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => {
const form = await readFormBody(ctx.req);
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) {
ctx.log.warn("csrf rejected", { path: ctx.url.pathname });
return FORBIDDEN;
}
const flow = await kratos.createLogoutFlow(ctx.req.headers.cookie ? { cookie: ctx.req.headers.cookie } : {});
ctx.res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
ctx.log.info("logout", { sub: ctx.user?.id ?? "" });
return { redirect: flow?.logoutUrl ?? "/login" };
};
}
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
// path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The
// canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the
// honest fallback for any genuine flow error. The id is shown only for support reference.
const errorSink = (ctx: RequestContext): RouteResult =>
({ data: { id: ctx.url.searchParams.get("id"), title: "Sign-in problem" }, view: "error" });
export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }: AuthRouteDeps): BuiltinRoute[] {
const routes: BuiltinRoute[] = [];
if (kratos) {
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path });
}
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout" });
}
if (hydra && kratos) {
const provider = { hydra, kratos };
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login" });
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent" });
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent" });
}
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout" });
if (kratos && kratosAdmin && keto) {
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete" });
}
routes.push({ handler: errorSink, method: "GET", path: "/error" });
return routes;
}
+61 -262
View File
@@ -3,32 +3,29 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
import { readFormBody } from "./body.ts";
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type User } from "./context.ts";
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
import { GuardError, loginRedirect } from "../auth/guards.ts";
import { AUTH_FLOWS, buildFlowView } from "../auth/flow-view.ts";
import { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
import type { HydraAdmin } from "../auth/hydra-admin.ts";
import type { JwksProvider } from "../auth/jwks.ts";
import { resolveSession, type VerifyOptions } from "../auth/jwt-middleware.ts";
import type { KetoClient } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
import { type Flow, KratosError, type KratosPublic } from "../auth/kratos-public.ts";
import type { KratosPublic } from "../auth/kratos-public.ts";
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "../auth/login.ts";
import { resolveLoginChallenge } from "../auth/oauth-login.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "../auth/oauth-consent.ts";
import { remintSession } from "../auth/login.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { buildAuthRoutes } from "../auth/routes.ts";
import { securityHeaders } from "./security-headers.ts";
import { localPath } from "./safe-url.ts";
import { routePublic, serveStatic } from "./static.ts";
import { renderPluginView } from "../plugin-host/view-resolver.ts";
@@ -115,6 +112,46 @@ export function createApp(options: AppOptions = {}): Server {
res.end(html);
};
// The public landing "/": ungated — anyone may see it. A plugin may fully own it via `home`
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
// any form it ships). Else the built-in intro page with prominent sign-in / register links
// (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie).
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
csrf.setCookie();
if (homePlugin) {
const result = (await homePlugin.home(ctx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data));
return null;
}
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
};
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign
// in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its
// handler renders against its own views, same path as a plugin route. Else the built-in
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
csrf.setCookie();
if (dashboardPlugin) {
const result = (await dashboardPlugin.dashboard(ctx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
return null;
}
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user: ctx.user }) }, view: "index" };
};
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
// routes.ts, capability-gated on the wired clients) plus the two landing slots above.
const builtinRoutes: BuiltinRoute[] = [
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
{ handler: serveHome, method: "GET", path: "/" },
{ handler: serveDashboard, method: "GET", path: "/dashboard" },
];
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
// outbound fetch (the Ory clients via tracedFetch) and any deep module joins this request's trace
// and correlation with no logger threaded through their signatures.
@@ -171,8 +208,13 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
// one (the form page below Set-Cookies it). Verified on our own state-changing routes.
// one (a page-emitting handler Set-Cookies it via csrfMint). Verified on our own
// state-changing routes.
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
const csrfMint: RequestCsrf = {
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
token: csrf.token,
};
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
@@ -192,7 +234,7 @@ export function createApp(options: AppOptions = {}): Server {
if (short) {
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
csrfMint.setCookie();
await sendResult(res, short.result, (view, data) => renderView(short.plugin.id, view, data));
return;
}
@@ -212,262 +254,19 @@ export function createApp(options: AppOptions = {}): Server {
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
csrfMint.setCookie();
const result = (await match.route.handler(routeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
await sendResult(res, result, (view, data) => renderView(match.plugin.id, view, data));
return;
}
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
const flowType = AUTH_FLOWS[pathname];
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
if (ctx.user && (pathname === "/login" || pathname === "/registration")) {
res.writeHead(303, { location: "/dashboard" }).end();
return;
}
const cookie = req.headers.cookie;
const flowId = ctx.url.searchParams.get("flow");
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
let flow: Flow;
try {
if (!flowId) {
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
// A `return_to` is baked into the flow so Kratos lands there after login instead of the
// default completion route. A first-party deep link (host-relative, from the gate's
// return_to) is wrapped through /auth/complete so the session JWT is minted before the
// user reaches the page; an absolute target (the OAuth2 login challenge) is passed
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw);
let returnTo: string | undefined;
if (local) {
const origin = `${secureCookies ? "https" : "http"}://${req.headers.host ?? "127.0.0.1:3000"}`;
const complete = new URL(`${origin}/auth/complete`);
complete.searchParams.set("return_to", local);
returnTo = complete.toString();
} else if (raw) returnTo = raw;
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
if (setCookie.length) res.appendHeader("set-cookie", setCookie);
res.writeHead(303, { location: `${pathname}?flow=${initiated.id}` }).end();
return;
}
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
} catch (err) {
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) {
res.writeHead(303, { location: pathname }).end();
return;
}
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
// login (mint the JWT from the live session), honouring return_to — never a 500.
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
const local = localPath(ctx.url.searchParams.get("return_to"));
res.writeHead(303, { location: local ? `/auth/complete?return_to=${encodeURIComponent(local)}` : "/auth/complete" }).end();
return;
}
// Ory unreachable (Kratos 5xx / connection refused / timeout): "Ory down ⇒ no logins" is
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
if (!(err instanceof KratosError) || err.status >= 500) {
reqLog.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname });
sendHtml(res, 503, await render("503", { title: "Sign-in unavailable" }));
return;
}
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
}
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("auth", { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }));
return;
}
// OAuth2 login challenge: Hydra hands the browser here when another app logs in
// *through* us. Resolve it via the Kratos session and accept; an unauthenticated user
// bounces to our themed login and returns here once signed in. Provider-only.
if (hydra && kratos && pathname === "/oauth2/login" && (method === "GET" || method === "HEAD")) {
const challenge = ctx.url.searchParams.get("login_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing login_challenge");
return;
}
// Absolute return target so Kratos lands back here post-login. Host reflects what the
// browser used (so it matches Kratos' allowed_return_urls); scheme follows SECURE_COOKIES.
// A spoofed Host can't escape — Kratos validates return_to against its allow-list.
const origin = `${secureCookies ? "https" : "http"}://${req.headers.host ?? "127.0.0.1:3000"}`;
const selfUrl = `${origin}/oauth2/login?login_challenge=${encodeURIComponent(challenge)}`;
try {
const { redirect } = await resolveLoginChallenge({ hydra, kratos }, challenge, req.headers.cookie, selfUrl);
res.writeHead(303, { location: redirect }).end();
} catch (err) {
// A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL) is
// user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500.
if (err instanceof HydraError && err.status < 500) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This sign-in request has expired. Please start again from the application you were signing in to.");
} else throw err;
}
return;
}
// OAuth2 consent challenge: after login Hydra hands the browser here. A first-party
// (or Hydra-skipped) client is auto-granted its scopes; a third-party client gets the themed
// consent screen, whose CSRF-guarded POST accepts (Allow) or rejects (Deny). Provider-only.
if (hydra && kratos && pathname === "/oauth2/consent") {
const consentDeps = { hydra, kratos };
try {
if (method === "GET" || method === "HEAD") {
const challenge = ctx.url.searchParams.get("consent_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing consent_challenge");
return;
}
const { redirect, view } = await resolveConsentChallenge(consentDeps, challenge, req.headers.cookie);
if (redirect) {
res.writeHead(303, { location: redirect }).end();
return;
}
// Third-party: show the consent screen, carrying a CSRF token its form echoes back.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("oauth-consent", { brand: menu.branding.name, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }));
return;
}
if (method === "POST") {
const form = await readFormBody(req);
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
reqLog.warn("csrf rejected", { path: pathname });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
const challenge = form.get("consent_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing consent_challenge");
return;
}
const redirect = form.get("decision") === "allow"
? await acceptConsent(consentDeps, challenge, req.headers.cookie)
: await rejectConsent(consentDeps, challenge);
res.writeHead(303, { location: redirect }).end();
return;
}
} catch (err) {
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500 (as /oauth2/login).
if (err instanceof HydraError && err.status < 500) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This authorization request has expired. Please start again from the application you were signing in to.");
return;
}
throw err;
}
}
// OAuth2 RP-initiated logout: Hydra hands the browser here to end the OAuth2 session
// (hydra.yml urls.logout). Accept the challenge and resume to Hydra's post-logout redirect;
// the first-party POST /logout (below) owns the Kratos session + our JWT cookie. Provider-only.
// GET-accept is safe (like the login/consent handlers): the challenge is Hydra-minted +
// single-use, so a forged GET can't fabricate one — we skip only the optional "confirm logout?".
if (hydra && pathname === "/oauth2/logout" && (method === "GET" || method === "HEAD")) {
const challenge = ctx.url.searchParams.get("logout_challenge");
if (!challenge) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Missing logout_challenge");
return;
}
try {
const { redirect } = await hydra.acceptLogoutRequest(challenge);
res.writeHead(303, { location: redirect }).end();
} catch (err) {
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500.
if (err instanceof HydraError && err.status < 500) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("This logout request has expired. Please start again from the application you were signing out of.");
} else throw err;
}
return;
}
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
// and store it as the cookie; no active session bounces back to sign in.
if (pathname === "/auth/complete" && method === "GET" && kratos && kratosAdmin && keto) {
const completed = await completeLogin({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
if (!completed) {
res.writeHead(303, { location: "/login" }).end();
return;
}
res.appendHeader("set-cookie", sessionCookie(completed.jwt, { secure: secureCookies }));
// Land on the deep link the user was headed to (return_to, validated host-relative so a
// crafted ?return_to= can't make this an open redirect), else the gated dashboard.
res.writeHead(303, { location: localPath(ctx.url.searchParams.get("return_to")) ?? "/dashboard" }).end();
return;
}
// Logout: a state change, so a CSRF-guarded POST (the shell submits a form, not a GET link).
// Clear our local JWT and revoke the Kratos session — Kratos' own cookie lives on its origin,
// so redirect to its logout URL (it revokes the session, clears plainpages_session, then lands
// on /login per kratos.yml). No active session ⇒ just clear our cookie and go to /login.
if (pathname === "/logout" && method === "POST" && kratos) {
const form = await readFormBody(req);
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
reqLog.warn("csrf rejected", { path: pathname });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
const flow = await kratos.createLogoutFlow(req.headers.cookie ? { cookie: req.headers.cookie } : {});
res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
reqLog.info("logout", { sub: user?.id ?? "" });
res.writeHead(303, { location: flow?.logoutUrl ?? "/login" }).end();
return;
}
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
// path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The
// canonical-host redirect above prevents the common cause (a lost cross-host CSRF cookie); this
// is the honest fallback for any genuine flow error. The id is shown only for support reference.
if (pathname === "/error" && (method === "GET" || method === "HEAD")) {
sendHtml(res, 200, await render("error", { id: ctx.url.searchParams.get("id"), title: "Sign-in problem" }));
return;
}
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// The public landing: ungated — anyone may see it. A plugin may fully own it via `home`
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
// any form it ships). Else the built-in intro page with prominent sign-in / register links.
if (homePlugin) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
const result = (await homePlugin.home(homeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result);
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
return;
}
// Default landing in the unified app shell: `user` picks "go to dashboard" vs sign-in,
// and the shell's Sign-out form (when signed in) needs a fresh CSRF cookie.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("home", { chrome: ctx.chrome, user }));
return;
}
if (pathname === "/dashboard" && (method === "GET" || method === "HEAD")) {
// The post-login app home, gated to a signed-in user: anonymous bounces to sign in,
// remembering /dashboard as return_to.
if (!user) { res.writeHead(303, { location: loginRedirect(ctx) }).end(); return; }
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
// A plugin may fully own the dashboard: render its handler against its own views, native
// shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
if (dashboardPlugin) {
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
const result = (await dashboardPlugin.dashboard(dashCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, dashCtx, result);
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
return;
}
// The one global menu (ctx.chrome.nav) + branding/override from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user }) }));
// Built-in endpoints (the auth/OAuth2 group, the landing slots, /error) from the internal
// route table — same handler shape as plugin routes; a `view` result renders the core views,
// null means the handler wrote to ctx.res itself.
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) {
await sendResult(res, await builtin.handler(ctx, csrfMint), render);
return;
}
+29
View File
@@ -0,0 +1,29 @@
// Internal route-table matching: exact path, a GET route also answering HEAD (like the plugin
// router), method-distinct entries on the same path.
import { test } from "node:test";
import assert from "node:assert/strict";
import { type BuiltinRoute, matchBuiltinRoute } from "./builtin-routes.ts";
const entry = (method: "GET" | "POST", path: string): BuiltinRoute => ({ handler: () => null, method, path });
test("matches exact path + method; GET also answers HEAD", () => {
const get = entry("GET", "/error");
const post = entry("POST", "/logout");
const routes = [get, post];
assert.equal(matchBuiltinRoute(routes, "GET", "/error"), get);
assert.equal(matchBuiltinRoute(routes, "HEAD", "/error"), get);
assert.equal(matchBuiltinRoute(routes, "POST", "/logout"), post);
assert.equal(matchBuiltinRoute(routes, "GET", "/logout"), undefined, "a POST route does not answer GET");
assert.equal(matchBuiltinRoute(routes, "HEAD", "/logout"), undefined, "a POST route does not answer HEAD");
assert.equal(matchBuiltinRoute(routes, "POST", "/error"), undefined, "a GET route does not answer POST");
assert.equal(matchBuiltinRoute(routes, "GET", "/nope"), undefined);
assert.equal(matchBuiltinRoute(routes, "GET", "/error/sub"), undefined, "exact path, no prefix matching");
});
test("the same path may carry method-distinct entries (consent screen GET + decision POST)", () => {
const screen = entry("GET", "/oauth2/consent");
const decision = entry("POST", "/oauth2/consent");
const routes = [screen, decision];
assert.equal(matchBuiltinRoute(routes, "GET", "/oauth2/consent"), screen);
assert.equal(matchBuiltinRoute(routes, "POST", "/oauth2/consent"), decision);
});
+27
View File
@@ -0,0 +1,27 @@
// The host's internal route table: each built-in endpoint (the auth/OAuth2 group, the landing
// slots, /error) is a named handler with the plugin RouteHandler shape, plus the request's CSRF
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
// pipes the result through sendResult against the core views.
import type { RequestContext } from "./context.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
// The request's CSRF token for first-party forms, bound by the host: `token` renders into a
// hidden field; `setCookie()` Set-Cookies it iff it was freshly minted this request — call it on
// every page-emitting response so the form's double-submit cookie exists.
export interface RequestCsrf {
setCookie(): void;
token: string;
}
export interface BuiltinRoute {
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
// (the landing slots dispatch a plugin's own result against that plugin's views).
handler: (ctx: RequestContext, csrf: RequestCsrf) => Promise<RouteResult | null> | RouteResult | null;
method: "GET" | "POST"; // a GET route also answers HEAD, like plugin routes
path: string; // exact pathname
}
export function matchBuiltinRoute(routes: BuiltinRoute[], method: string, pathname: string): BuiltinRoute | undefined {
return routes.find((r) => r.path === pathname && (r.method === method || (r.method === "GET" && method === "HEAD")));
}