Session re-mint on TTL expiry (todo §4); resolveSession flags a lapsed token, app.ts hot path re-mints via remintSession (roles re-read from Keto → fresh cookie) only when a live Kratos session backs it; a dead session clears the stale cookie

This commit is contained in:
2026-06-18 10:25:05 +02:00
parent 228a206469
commit 4f6b60463b
8 changed files with 132 additions and 20 deletions

View File

@@ -210,6 +210,32 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 403);
});
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
const nowSec = Math.floor(Date.now() / 1000);
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
const keto = stubKeto({ listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) });
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const ok = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired } });
assert.equal(ok.status, 200);
assert.equal(await ok.text(), "secret");
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=/);
// Kratos session gone: no re-mint, the stale cookie is cleared, the gate denies.
const dead = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
await new Promise<void>((r) => dead.listen(0, r));
t.after(() => dead.close());
const denied = await fetch(`http://localhost:${(dead.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired } });
assert.equal(denied.status, 403);
assert.match(denied.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
});
test("guards map to responses: requireSession → /login, a failed can/check → 403, success runs the handler", async (t) => {
const keto = { check: async (tuple: { object: string }) => tuple.object === "open" } as unknown as Parameters<typeof check>[0];
const guarded: Plugin = {

View File

@@ -2,18 +2,18 @@ import { createServer, type Server, type ServerResponse } from "node:http";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
import { buildContext } from "./context.ts";
import { buildContext, type User } from "./context.ts";
import { buildDashboardModel } from "./dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts";
import { GuardError } from "./guards.ts";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
import type { JwksProvider } from "./jwks.ts";
import { authenticate, type VerifyOptions } from "./jwt-middleware.ts";
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, sessionCookie } from "./login.ts";
import { 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";
@@ -81,7 +81,20 @@ export function createApp(options: AppOptions = {}): Server {
}
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
const user = jwks ? await authenticate(req.headers.cookie, jwks, authOptions) : null;
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in" (§4): re-read roles from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
let user: User | null = null;
if (jwks) {
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
user = auth.user;
if (!user && auth.expired && keto && kratos && kratosAdmin) {
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
user = reminted.user;
res.setHeader("set-cookie", reminted.setCookie);
}
}
const ctx = buildContext(req, res, { user }); // base context (no route params yet); reused for onRequest
// Plugin onRequest hooks run before routing and may short-circuit the request.

View File

@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto";
import { test } from "node:test";
import { staticJwks } from "./jwks.ts";
import { authenticate, claimsToUser, verifyToken } from "./jwt-middleware.ts";
import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
import { SESSION_COOKIE } from "./login.ts";
const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url");
@@ -72,3 +72,17 @@ test("authenticate: a valid cookie → User; no cookie / invalid / expired → n
assert.equal(await authenticate(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), null);
assert.equal(await authenticate(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, exp: NOW - 999 })}`, jwks, { now: NOW }), null);
});
test("resolveSession flags a lapsed token for re-mint, but not no-cookie / tampered tokens", async () => {
const ok = await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", valid)}`, jwks, { now: NOW });
assert.deepEqual(ok, { expired: false, user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
// Present but past exp → the §4 re-mint trigger.
const lapsed = await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, exp: NOW - 999 })}`, jwks, { now: NOW });
assert.deepEqual(lapsed, { expired: true, user: null });
// No cookie / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip).
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "nope", valid)}`, jwks, { now: NOW }), { expired: false, user: null });
});

View File

@@ -20,8 +20,15 @@ export interface VerifyOptions {
}
// A rejected token (bad signature, expired, wrong iss/aud, malformed claims). `authenticate`
// swallows it to anonymous; a caller wanting the reason can catch it.
export class TokenError extends Error {}
// swallows it to anonymous; a caller wanting the reason can catch it. `expired` is set only for
// a lapsed-but-otherwise-intact token — the §4 re-mint trigger (see resolveSession).
export class TokenError extends Error {
expired: boolean;
constructor(message: string, expired = false) {
super(message);
this.expired = expired;
}
}
function num(payload: Record<string, unknown>, claim: string): number | undefined {
const v = payload[claim];
@@ -35,7 +42,7 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
const exp = num(payload, "exp");
if (exp === undefined) throw new TokenError("token missing exp");
if (now > exp + skew) throw new TokenError("token expired");
if (now > exp + skew) throw new TokenError("token expired", true);
const nbf = num(payload, "nbf");
if (nbf !== undefined && now < nbf - skew) throw new TokenError("token not yet valid");
@@ -71,14 +78,26 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
return claimsToUser(verified.payload);
}
// The request middleware: read our session cookie, verify it → the User, or null for no
// cookie / any invalid token (fail-closed; the route then renders anonymous and gates deny).
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User | null> {
export interface SessionAuth {
expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate (§4)
user: User | null;
}
// The request middleware: read our session cookie, verify it → the User (fail-closed: any
// bad/expired/missing token ⇒ null). `expired` distinguishes a lapsed-but-intact token from
// no-cookie / tampered ones, so app.ts only pays an Ory round-trip to re-mint a genuinely
// expired session, never for anonymous or garbage requests.
export async function resolveSession(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionAuth> {
const token = parseCookies(cookieHeader)[SESSION_COOKIE];
if (!token) return null;
if (!token) return { expired: false, user: null };
try {
return await verifyToken(token, jwks, options);
} catch {
return null;
return { expired: false, user: await verifyToken(token, jwks, options) };
} catch (err) {
return { expired: err instanceof TokenError && err.expired, user: null };
}
}
// Convenience for callers that don't re-mint: just the User, or null.
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User | null> {
return (await resolveSession(cookieHeader, jwks, options)).user;
}

View File

@@ -6,7 +6,7 @@ import assert from "node:assert/strict";
import type { KetoClient, RelationTuple } from "./keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.ts";
import { completeLogin, readRoles, SESSION_COOKIE, sessionCookie } from "./login.ts";
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` });
@@ -85,6 +85,22 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
});
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
const keto = ketoStub({ listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
const dead = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic: publicStub() }, undefined);
assert.equal(dead.user, null);
assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/);
});
test("sessionCookie builds the HttpOnly/Lax JWT cookie; secure opt-in; JWT chars stay readable", () => {
const jwt = "aaa.bbb-_.ccc";
assert.equal(sessionCookie(jwt), `${SESSION_COOKIE}=${jwt}; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax`);

View File

@@ -6,6 +6,7 @@
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
// Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto.
import type { User } from "./context.ts";
import { serializeCookie, type CookieOptions } from "./cookie.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
@@ -16,7 +17,7 @@ import type { KratosPublic } from "./kratos-public.ts";
export const SESSION_COOKIE = "plainpages_jwt";
// Mirrors kratos.yml session.lifespan (30d) so the cookie survives browser restarts; the
// JWT inside is short-lived (~10m) and re-minted by the §4 middleware on expiry.
// JWT inside is short-lived (~10m) and re-minted on expiry by the §4 hot path (remintSession).
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
// The tokenizer template (kratos.yml session.whoami.tokenizer.templates.plainpages).
@@ -67,9 +68,32 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
return { email, identityId, jwt, roles };
}
export interface Reminted {
setCookie: string; // a fresh JWT cookie on success, else a cookie that clears the stale one
user: User | null;
}
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
// the long-lived Kratos session may still be live. A live session ⇒ re-read roles from Keto,
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
// anonymous instead of re-hitting Ory on every one.
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
}
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
// supplied by the caller (off in dev http; the §9 cookie hardening toggles it on for prod).
export function sessionCookie(jwt: string, options: { secure?: boolean } = {}): string {
const opts: CookieOptions = { httpOnly: true, maxAge: COOKIE_MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) };
return serializeCookie(SESSION_COOKIE, jwt, opts);
}
// Expire our session cookie (Max-Age=0), with the same attributes sessionCookie sets so the
// browser deletes the right one.
export function clearSessionCookie(options: { secure?: boolean } = {}): string {
const opts: CookieOptions = { httpOnly: true, maxAge: 0, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) };
return serializeCookie(SESSION_COOKIE, "", opts);
}