Split handleRequest into pipeline + internal route table; auth/OAuth2 endpoints become named handlers in src/auth/routes.ts
This commit is contained in:
@@ -1319,7 +1319,8 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
||||
*.test.ts (compose/kratos/keto/hydra/postgres) Topology guards with no source counterpart — assert the compose dev/prod split + ordering and each Ory service's config (they validate ory/ + the compose files)
|
||||
|
||||
http/ Request pipeline + HTTP primitives
|
||||
app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes)
|
||||
app.ts createApp(): the request pipeline — security headers, static, canonical host, session verify/re-mint, CSRF, hooks, plugin routes, then the internal route table → RouteResult rendering
|
||||
builtin-routes.ts The internal route table's contract: BuiltinRoute + the request's CSRF mint + matchBuiltinRoute() (exact path, GET answers HEAD)
|
||||
context.ts RequestContext handed to handlers + buildContext()
|
||||
body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + forms)
|
||||
cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies)
|
||||
@@ -1339,6 +1340,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
||||
flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
|
||||
oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
|
||||
oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
|
||||
routes.ts buildAuthRoutes(): the built-in auth/OAuth2 endpoints as named handlers on the internal route table — themed flow pages, /oauth2/* challenges, /auth/complete, POST /logout, /error; only what the wired clients support is registered
|
||||
bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
|
||||
kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
|
||||
kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -8,4 +8,17 @@ For each todo item, interview the user extensively to deeply understand the scop
|
||||
- [ ] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser.
|
||||
- [ ] Build and publish docker image as CI/CD.
|
||||
- [ ] Add i18n support.
|
||||
- [ ] Set up CI/CD. Tests on push to any branch. Require PR to main and don't allow merge if tests does not pass. Publish docker image on valid semver tag. Sync up to github when merging to main.
|
||||
- [ ] Set up CI/CD. Tests on push to any branch. Require PR to main and don't allow merge if tests does not pass. Publish docker image on valid semver tag. Sync up to github when merging to main.
|
||||
|
||||
## Architectural review findings (2026-07-02)
|
||||
|
||||
Prioritized. Overall verdict: architecture is sound (contract-first plugin API, functional core/imperative shell, strong test seams); these are refinements.
|
||||
|
||||
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts` (~380 lines).** It mixes the request pipeline with inline implementations of ~10 built-in endpoints (Kratos flows, /oauth2/*, /auth/complete, /logout, /, /dashboard, 404/405). Extract each endpoint into a named handler (auth/OAuth2 group → `src/auth/` route module) with the same `(req, res, ctx)` shape plugin routes use; reduce `handleRequest` to pipeline → internal route table → `sendResult`.
|
||||
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-free `visual` suite; heavy e2e suites required-but-separate) and make the pipeline the only path to a published image (build once at tag, promote).
|
||||
- [ ] **MEDIUM — De-duplicate `examples/plugins/admin/admin-groups.ts` and `admin-roles.ts` (~80% identical).** Same "Keto membership object admin" concept twice; extract a parameterized helper keyed on `{ namespace, base, labels, columns }`, leave roles' effective-access view as the only delta. Matters extra because this is the reference plugin people copy.
|
||||
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen (users, groups, roles, shifts) hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
|
||||
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field, incl. identical brand-assembly in `chrome.ts` and `shell-context.ts`. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
|
||||
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
|
||||
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. Record the decision.
|
||||
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population (first-party vs external) to justify the versioning machinery.
|
||||
Reference in New Issue
Block a user