Fix 500 on /login when a Kratos session exists but no app JWT (session_already_available)
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<void>((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<void>((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=<host-relative path>; /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).
|
||||
|
||||
+10
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user