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) {