Logout (todo §4); GET /logout clears plainpages_jwt + revokes the Kratos session (createLogoutFlow → redirect to Kratos logout URL → /login); wire shell Sign out link

This commit is contained in:
2026-06-18 10:35:07 +02:00
parent 4f6b60463b
commit dec55f85a6
9 changed files with 67 additions and 6 deletions

View File

@@ -314,6 +314,7 @@ const loginFlow = (id: string): Flow => ({
function mockKratos(getFlow: KratosPublic["getFlow"]): KratosPublic {
return {
createLogoutFlow: async () => null,
getFlow,
initBrowserFlow: async (_t: FlowType) => ({ flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: ["csrf_token=abc; Path=/; HttpOnly"] }),
submitFlow: async () => { throw new Error("unused"); },
@@ -408,6 +409,27 @@ test("login completion with no Kratos session redirects to /login and sets no co
assert.equal(res.headers.get("set-cookie"), null);
});
test("logout: bounces to Kratos to revoke the session and clears our JWT cookie; no session → /login", async (t) => {
const logoutUrl = "http://127.0.0.1:4433/self-service/logout?token=lt";
const kratos: KratosPublic = { ...mockKratos(async () => { throw new Error("unused"); }), createLogoutFlow: async (o) => (o?.cookie ? { logoutToken: "lt", logoutUrl } : null) };
const app = createApp({ kratos });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
// Active session → redirect to Kratos' logout URL (it revokes + clears plainpages_session, then → /login).
const out = await fetch(url + "/logout", { headers: { cookie: `${SESSION_COOKIE}=x; plainpages_session=s` }, redirect: "manual" });
assert.equal(out.status, 303);
assert.equal(out.headers.get("location"), logoutUrl);
assert.match(out.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
// No active Kratos session → clear our cookie and land on /login ourselves.
const none = await fetch(url + "/logout", { redirect: "manual" });
assert.equal(none.status, 303);
assert.equal(none.headers.get("location"), "/login");
assert.match(none.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
});
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);

View File

@@ -13,7 +13,7 @@ import { resolveSession, type VerifyOptions } from "./jwt-middleware.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { KratosError, type KratosPublic } from "./kratos-public.ts";
import { completeLogin, remintSession, sessionCookie } from "./login.ts";
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "./login.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
@@ -157,6 +157,16 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Logout: clear our local JWT and revoke the Kratos session. Kratos' own cookie lives on
// its origin, so we can't clear it here — redirect the browser to Kratos' logout URL (it
// revokes the session, clears plainpages_session, then lands on /login per kratos.yml).
// No active session ⇒ just clear our cookie and go to /login.
if (pathname === "/logout" && (method === "GET" || method === "HEAD") && kratos) {
const flow = await kratos.createLogoutFlow(req.headers.cookie ? { cookie: req.headers.cookie } : {});
res.writeHead(303, { location: flow?.logoutUrl ?? "/login", "set-cookie": clearSessionCookie() }).end();
return;
}
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// Roles from the verified JWT (anonymous ⇒ []); branding/override come from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu) }));

View File

@@ -109,3 +109,13 @@ test("whoami throws on an unexpected upstream error", async () => {
const { fetchImpl } = recorder(() => res(500, { error: "boom" }));
await assert.rejects(createKratosPublic({ baseUrl: BASE, fetchImpl }).whoami(), KratosError);
});
test("createLogoutFlow returns the logout URL/token on 200 (cookie forwarded) and null on 401 (no session)", async () => {
const flow = { logout_token: "lt", logout_url: `${BASE}/self-service/logout?token=lt` };
const { calls, fetchImpl } = recorder((url) => (url.endsWith("/self-service/logout/browser") ? res(200, flow) : res(401)));
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).createLogoutFlow({ cookie: "plainpages_session=s" });
assert.deepEqual(out, { logoutToken: "lt", logoutUrl: flow.logout_url });
assert.match(calls[0]!.url, /\/self-service\/logout\/browser$/);
assert.equal(calls[0]!.headers.get("cookie"), "plainpages_session=s");
assert.equal(await createKratosPublic({ baseUrl: BASE, fetchImpl: (async () => res(401)) as typeof fetch }).createLogoutFlow(), null);
});

View File

@@ -1,6 +1,6 @@
// Kratos public-API client (todo §4): typed `fetch` wrappers over Ory Kratos' public
// endpoints — self-service flow init/get/submit, session `whoami`, and the session→JWT
// tokenizer (`whoami?tokenize_as`). Built-in `fetch` only, no SDK dep (AGENTS.md). The
// endpoints — self-service flow init/get/submit, browser logout, session `whoami`, and the
// session→JWT tokenizer (`whoami?tokenize_as`). Built-in `fetch` only, no SDK dep (AGENTS.md). The
// themed flow pages and login completion (§4) build on this; rendering flow `ui.nodes`
// and mapping field errors is the renderer's job (§4), so we keep those types loose.
@@ -46,6 +46,11 @@ export interface FlowInit {
setCookie: string[]; // Kratos' CSRF cookie(s) to relay to the browser
}
export interface LogoutFlow {
logoutToken: string; // CSRF token Kratos embeds in logoutUrl
logoutUrl: string; // send the browser here to revoke the session + clear Kratos' cookie
}
export interface FlowSubmission {
body: unknown; // parsed JSON: the re-rendered flow on 400, the success payload on 200
location: string | null; // redirect target (Location header, or a 422 redirect_browser_to)
@@ -67,6 +72,7 @@ export class KratosError extends Error {
}
export interface KratosPublic {
createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401)
getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>;
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise<FlowSubmission>;
@@ -99,6 +105,15 @@ export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof
}
return {
async createLogoutFlow(opts = {}) {
// Browser logout: get the logout URL (carrying a CSRF token) to send the browser to.
const res = await http(new URL(`${base}/self-service/logout/browser`), { headers: headers(opts.cookie), redirect: "manual" });
if (res.status === 401) return null; // no active session to revoke
if (res.status !== 200) throw new KratosError(`Kratos logout flow failed (${res.status})`, res.status, await res.text());
const body = (await res.json()) as { logout_token: string; logout_url: string };
return { logoutToken: body.logout_token, logoutUrl: body.logout_url };
},
async initBrowserFlow(type, opts = {}) {
const url = new URL(`${base}/self-service/${type}/browser`);
if (opts.returnTo) url.searchParams.set("return_to", opts.returnTo);

View File

@@ -31,6 +31,7 @@ const adminStub = (over: Partial<KratosAdmin> = {}): KratosAdmin => ({
});
const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
createLogoutFlow: async () => null,
getFlow: async () => { throw new Error("unused"); },
initBrowserFlow: async () => { throw new Error("unused"); },
submitFlow: async () => { throw new Error("unused"); },

View File

@@ -30,6 +30,9 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
assert.match(html, /<section id="body-marker">page<\/section>/); // content slot
assert.match(html, /<button id="action-marker"/); // topbar actions slot
// Sign out is wired to the logout route (the side-footer profile menu).
assert.match(html, /<a class="menu-item danger" href="\/logout">/);
// Branding, document title, and the inlined icon sprite (so <use> resolves).
assert.match(html, /Acme Console/);
assert.match(html, /<title>People<\/title>/);