appurl-canonical-host #1

Merged
lilleman merged 2 commits from appurl-canonical-host into main 2026-06-21 22:18:46 +02:00
3 changed files with 37 additions and 0 deletions
Showing only changes of commit c8b4c3c23b - Show all commits
+1
View File
@@ -12,6 +12,7 @@ services:
APP_URL: ${APP_URL:-http://localhost:3000} APP_URL: ${APP_URL:-http://localhost:3000}
CACHE_TEMPLATES: "false" CACHE_TEMPLATES: "false"
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines) 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" REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # reference plugin → the dev mock backend SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # reference plugin → the dev mock backend
+26
View File
@@ -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"); 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 // 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 // /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). // returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
+10
View File
@@ -305,6 +305,16 @@ export function createApp(options: AppOptions = {}): Server {
res.writeHead(303, { location: pathname }).end(); res.writeHead(303, { location: pathname }).end();
return; 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 // 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. // documented, so render an honest 503 rather than the catch-all "error on our end" 500.
if (!(err instanceof KratosError) || err.status >= 500) { if (!(err instanceof KratosError) || err.status >= 500) {