diff --git a/README.md b/README.md index 2d12a4f..6ab6e89 100644 --- a/README.md +++ b/README.md @@ -504,7 +504,7 @@ src/static.ts Static file serving (path-traversal protection) + routePubl src/jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK) src/jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint (§4) src/jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload) -src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, whoami, session→JWT tokenize (§4) +src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4) src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4) src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4) src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4) diff --git a/src/app.test.ts b/src/app.test.ts index 08d3204..824a591 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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((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); diff --git a/src/app.ts b/src/app.ts index e9a73d9..1951752 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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) })); diff --git a/src/kratos-public.test.ts b/src/kratos-public.test.ts index 70a1bc1..90a8192 100644 --- a/src/kratos-public.test.ts +++ b/src/kratos-public.test.ts @@ -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); +}); diff --git a/src/kratos-public.ts b/src/kratos-public.ts index 7b0b18b..c3f9d1d 100644 --- a/src/kratos-public.ts +++ b/src/kratos-public.ts @@ -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; // null ⇒ no active session (401) getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise; initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise; submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise; @@ -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); diff --git a/src/login.test.ts b/src/login.test.ts index 26fde05..27ed7e5 100644 --- a/src/login.test.ts +++ b/src/login.test.ts @@ -31,6 +31,7 @@ const adminStub = (over: Partial = {}): KratosAdmin => ({ }); const publicStub = (over: Partial = {}): KratosPublic => ({ + createLogoutFlow: async () => null, getFlow: async () => { throw new Error("unused"); }, initBrowserFlow: async () => { throw new Error("unused"); }, submitFlow: async () => { throw new Error("unused"); }, diff --git a/src/shell.test.ts b/src/shell.test.ts index cf6f1e7..57083ce 100644 --- a/src/shell.test.ts +++ b/src/shell.test.ts @@ -30,6 +30,9 @@ test("app shell renders sidebar, topbar and the content slot", async () => { assert.match(html, /
page<\/section>/); // content slot assert.match(html, /