import assert from "node:assert/strict"; import type { AddressInfo } from "node:net"; import { test } from "node:test"; import { createApp } from "./app.ts"; import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts"; import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts"; import { staticJwks } from "../auth/jwks.ts"; import type { Identity } from "../auth/kratos-admin.ts"; import { KratosError, type Flow, type FlowSubmission, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts"; import { SESSION_COOKIE } from "../auth/login.ts"; import { createI18n } from "../i18n/runtime.ts"; import { loadI18n } from "../i18n/load.ts"; import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "./app-harness.ts"; // A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message. const node = (attrs: Record, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" }); const loginFlow = (id: string): Flow => ({ id, ui: { action: `http://127.0.0.1:4433/self-service/login?flow=${id}`, messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }], method: "post", nodes: [ node({ name: "csrf_token", type: "hidden", value: "tok" }), node({ name: "identifier", required: true, type: "email" }, "E-Mail"), node({ name: "password", required: true, type: "password" }, "Password"), node({ name: "method", type: "submit", value: "password" }, "Sign in"), { attributes: { name: "provider", type: "submit", value: "google" }, group: "oidc", messages: [], meta: { label: { id: 1, text: "Sign in with Google", type: "info" } }, type: "input" }, ], }, }); // GET dispatch for the themed auth pages: the same handler branches on session presence. test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a signed-in user is sent home, except /settings", async (t) => { const app = createApp({ jwks: staticJwks([ecJwk]), kratos: mockKratos(async (_t, id) => { if (id === "stale") throw new KratosError("gone", 410, ""); return loginFlow(id); }) }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const url = `http://localhost:${(app.address() as AddressInfo).port}`; // Anonymous, no ?flow= → init one + relay Kratos' CSRF cookie. const init = await fetch(url + "/login", { redirect: "manual" }); assert.equal(init.status, 303); assert.equal(init.headers.get("location"), "/login?flow=new1"); assert.match(init.headers.get("set-cookie") ?? "", /csrf_token=abc/); // A stale flow id (Kratos 410) bounces back to a fresh init. const stale = await fetch(url + "/login?flow=stale", { redirect: "manual" }); assert.equal(stale.status, 303); assert.equal(stale.headers.get("location"), "/login"); // Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable. const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const }; for (const path of ["/login", "/registration"]) { const res = await fetch(url + path, signedIn); assert.equal(res.status, 303, `${path} while signed in → 303`); assert.equal(res.headers.get("location"), "/dashboard"); } assert.equal((await fetch(url + "/settings", signedIn)).headers.get("location"), "/settings?flow=new1"); }); test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => { // After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.user // is null and the "already signed in" short-circuit 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), preserving return_to — never fall through to the catch-all 500. const sessionRace = new KratosError("Kratos init login flow failed (400)", 400, JSON.stringify({ error: { id: "session_already_available" } })); const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw sessionRace; } } }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const url = `http://localhost:${(app.address() as AddressInfo).port}`; const recover = await fetch(url + "/login", { redirect: "manual" }); assert.equal(recover.status, 303); assert.equal(recover.headers.get("location"), "/auth/complete"); // return_to is carried through so the deep link still lands after the JWT is minted. const deep = await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" }); assert.equal(deep.headers.get("location"), "/auth/complete?return_to=%2Fadmin%2Fusers"); // A genuinely unexpected Kratos 400 is still surfaced as a 500 (not masked as a session race). const app2 = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw new KratosError("bad", 400, JSON.stringify({ error: { id: "security_csrf_violation" } })); } } }); await new Promise((r) => app2.listen(0, r)); t.after(() => app2.close()); const url2 = `http://localhost:${(app2.address() as AddressInfo).port}`; assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500); }); test("themed auth GET: an anonymous hit on /settings (Kratos 401) bounces to sign-in with the page as return_to, never 500", async (t) => { let failure = new KratosError("Kratos init settings flow failed (401)", 401, ""); const throwing = async (): Promise => { throw failure; }; const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(throwing), initBrowserFlow: throwing } }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const url = `http://localhost:${(app.address() as AddressInfo).port}`; const init = await fetch(url + "/settings", { redirect: "manual" }); assert.equal(init.status, 303); assert.equal(init.headers.get("location"), "/login?return_to=%2Fsettings"); const existing = await fetch(url + "/settings?flow=f1", { redirect: "manual" }); assert.equal(existing.headers.get("location"), "/login?return_to=%2Fsettings"); // /login redirecting to itself would loop, so a 401 there stays the catch-all 500. assert.equal((await fetch(url + "/login", { redirect: "manual" })).status, 500); // Only a 401 means "sign in": an Ory outage on /settings is still the honest 503. failure = new KratosError("Kratos init settings flow failed (500)", 500, ""); assert.equal((await fetch(url + "/settings", { redirect: "manual" })).status, 503); }); // return_to: a deep-link login lands back on the requested page. The gate redirects to // /login?return_to=; /login bakes that into the Kratos flow so completion // returns there — but a first-party path must route via /auth/complete first (to mint the JWT). test("login return_to: a first-party deep link is wrapped through /auth/complete; an absolute target passes through as-is", async (t) => { let lastReturnTo: string | undefined; const kratos: KratosPublic = { ...mockKratos(async (_t, id) => loginFlow(id)), initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; }, }; const app = createApp({ kratos }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const url = `http://localhost:${(app.address() as AddressInfo).port}`; // A host-relative deep link → wrapped: Kratos returns to /auth/complete?return_to=, // so the JWT is minted before the user lands on the page (query preserved, re-encoded). await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users?q=1"), { redirect: "manual" }); assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Fq%3D1$/); // An absolute target (the OAuth2 login challenge) is passed to Kratos unchanged — Kratos // allow-lists it. A protocol-relative "//evil.com" is likewise not wrapped (Kratos rejects it). const abs = "http://localhost/oauth2/login?login_challenge=abc"; await fetch(url + "/login?return_to=" + encodeURIComponent(abs), { redirect: "manual" }); assert.equal(lastReturnTo, abs); await fetch(url + "/login?return_to=" + encodeURIComponent("//evil.com"), { redirect: "manual" }); assert.equal(lastReturnTo, "//evil.com"); }); test("login return_to: a chosen language alone still routes through /auth/complete, so it survives the sign-in", async (t) => { let lastReturnTo: string | undefined; const kratos: KratosPublic = { ...mockKratos(async (_t, id) => loginFlow(id)), initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; }, }; const app = createApp({ i18n: createI18n(await loadI18n()), kratos }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const url = `http://localhost:${(app.address() as AddressInfo).port}`; lastReturnTo = "unset"; await fetch(url + "/login", { redirect: "manual" }); assert.equal(lastReturnTo, undefined, "no target and no language: Kratos keeps its default"); await fetch(url + "/login?locale=sv-SE", { redirect: "manual" }); assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?locale=sv-SE$/); await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" }); assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Flocale%3Dsv-SE&locale=sv-SE$/); // An absolute target still reaches Kratos beside a chosen language; the language is lost instead. const abs = "http://localhost/oauth2/login?login_challenge=abc"; await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent(abs), { redirect: "manual" }); assert.equal(lastReturnTo, abs); }); // "Ory down ⇒ no logins" is documented; the auth path should say so honestly (503), not the // generic "error on our end" 500 the catch-all renders. test("auth flow when Ory is unreachable → an honest 503, not the catch-all 500", async (t) => { const boom = () => { throw new KratosError("kratos down", 503, ""); }; const down: KratosPublic = { ...mockKratos(async () => boom()), initBrowserFlow: async () => boom() }; const app = createApp({ kratos: down }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const url = `http://localhost:${(app.address() as AddressInfo).port}`; const init = await fetch(url + "/login", { redirect: "manual" }); // init (no ?flow=) with Kratos down assert.equal(init.status, 503); assert.match(await init.text(), /unavailable/i); assert.equal((await fetch(url + "/login?flow=f1")).status, 503); // fetching a flow, Kratos down // A network-level throw (refused/timeout — not a KratosError) is treated the same way. const refused: KratosPublic = { ...mockKratos(async () => { throw new Error("ECONNREFUSED"); }), initBrowserFlow: async () => { throw new Error("ECONNREFUSED"); } }; const app2 = createApp({ kratos: refused }); await new Promise((r) => app2.listen(0, r)); t.after(() => app2.close()); assert.equal((await fetch(`http://localhost:${(app2.address() as AddressInfo).port}/login`, { redirect: "manual" })).status, 503); }); test("renders a fetched flow as the themed auth page: fields post straight to Kratos, errors surface", async (t) => { const app = createApp({ kratos: mockKratos(async (_t, id) => loginFlow(id)) }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/login?flow=f1`)).text(); // The form posts to flow.ui.action (Kratos owns CSRF); csrf rides as a hidden input. assert.match(html, /
/); assert.match(html, /name="identifier"/); assert.match(html, /name="password"[^>]*type="password"/); assert.match(html, /