From c8b4c3c23bd699e0a0381de1649feaf8076b361c Mon Sep 17 00:00:00 2001 From: lilleman Date: Sun, 21 Jun 2026 22:04:55 +0200 Subject: [PATCH] Fix 500 on /login when a Kratos session exists but no app JWT (session_already_available) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repro: register a new account, then click back from the password/verification step to /login → 500. Registration's `session` hook signs the user in at Kratos but routes to the verification UI, not /auth/complete, so they hold a Kratos session with NO app JWT — ctx.user is null, the "already signed in -> /dashboard" short-circuit can't fire, and initialising a login flow makes Kratos return 400 `session_already_available`. The flow-init catch only handled 403/404/410 and 5xx, so the 400 fell through to `throw` -> catch-all 500. Recover instead: on `session_already_available` (already authenticated at Kratos), 303 to /auth/complete to mint the JWT from the live session, preserving return_to. A genuinely unexpected Kratos 400 still surfaces as 500. Verified live: /login (Kratos session, no JWT) now 303s to /auth/complete, which mints plainpages_jwt and lands on /dashboard. Also default LOG_LEVEL to debug in the dev override (compose.override.yml) for verbose local logs. Tests-first: app.test asserts the session-race recovers to /auth/complete (return_to carried) while an unrelated 400 stays a 500. typecheck + 361 units green. --- compose.override.yml | 1 + src/app.test.ts | 26 ++++++++++++++++++++++++++ src/app.ts | 10 ++++++++++ 3 files changed, 37 insertions(+) diff --git a/compose.override.yml b/compose.override.yml index f6072e6..033727e 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -12,6 +12,7 @@ services: APP_URL: ${APP_URL:-http://localhost:3000} CACHE_TEMPLATES: "false" LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines) + LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info) REQUIRE_SECURE_SECRETS: "false" SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # reference plugin → the dev mock backend diff --git a/src/app.test.ts b/src/app.test.ts index b021628..477c89d 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -721,6 +721,32 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s 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); +}); + // return_to (§9): 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). diff --git a/src/app.ts b/src/app.ts index 5ca4a24..e99abfc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -305,6 +305,16 @@ export function createApp(options: AppOptions = {}): Server { 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) {