§9 whole-project arch+product review pass (todo §9); ran systems-architect + product-owner on the whole project (no Critical/High — a converged scaffold) and addressed the in-scope §9 customer-facing/security findings. (1) return_to deep-link login, open-redirect-safe: a gated request hit while signed out (plugin-route gate, requireSession, requireAdmin) bounces to /login?return_to=<host-relative path> via new loginRedirect(ctx) (GET/HEAD, skips /); /login bakes it into the Kratos flow — a host-relative target is wrapped through <origin>/auth/complete?return_to=<path> so the JWT mints before landing, an absolute target (§6 OAuth2 login challenge) passes to Kratos as-is; /auth/complete redirects to the requested page. (2) safeUrl()+localPath() in new pure src/safe-url.ts: safeUrl sanitises an untrusted href/src to relative-or-http(s) (else "#"), exported via plugin-api.ts (closes the contract's "planned for §9" pointer); localPath is the host-relative redirect-allowlist guard for return_to, re-checked at both /login and /auth/complete. (3) honest 503 on Ory-unreachable sign-in (views/503.ejs) instead of the misattributed catch-all 500; expired-flow 4xx still restarts. Tests-first throughout; stability-reviewer APPROVE (addressed its Medium — scoped the 503 catch so a template bug hits the 500 with a stack, not a 503). typecheck + 339 units + full scripts/ci.sh gate green. Deferred with justification: the app.ts route-table refactor (standalone change + §10 prereq), mock dashboard + public-page blessing (§10 lines 139/140), success-flash (known).

This commit is contained in:
2026-06-20 16:38:57 +02:00
parent 1118d7a9f7
commit 66ea68a91b
16 changed files with 277 additions and 48 deletions
+13 -2
View File
@@ -5,6 +5,17 @@
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
import type { RequestContext, User } from "./context.ts";
import type { KetoClient } from "./keto-client.ts";
import { localPath } from "./safe-url.ts";
// Build the sign-in redirect for a gated request, preserving where the user was headed as
// `return_to` so login can land them back there (§9). Only a safe GET/HEAD navigation to a
// non-home, host-relative path is remembered (a POST or "/" ⇒ a bare /login); the target is
// validated host-relative (localPath) so it can't become an open redirect.
export function loginRedirect(ctx: RequestContext): string {
const method = (ctx.req.method ?? "GET").toUpperCase();
const target = method === "GET" || method === "HEAD" ? localPath(ctx.url.pathname + ctx.url.search) : null;
return target && target !== "/" ? `/login?return_to=${encodeURIComponent(target)}` : "/login";
}
// Thrown by an asserting guard; app.ts maps it to a response. `location` ⇒ a 303 redirect (an
// anonymous browser bounces to /login); otherwise `status` renders an error page (403 Forbidden).
@@ -20,9 +31,9 @@ export class GuardError extends Error {
}
}
// Assert a signed-in session and return the user. Anonymous ⇒ GuardError → /login.
// Assert a signed-in session and return the user. Anonymous ⇒ GuardError → /login (return_to kept).
export function requireSession(ctx: RequestContext): User {
if (!ctx.user) throw new GuardError(401, "authentication required", "/login");
if (!ctx.user) throw new GuardError(401, "authentication required", loginRedirect(ctx));
return ctx.user;
}