E2E for token timeout + refresh (todo §4); full-stack auth-refresh.spec.ts (real Ory stack): a lapsed session JWT is silently re-minted from the live Kratos session (roles re-read from Keto), and cleared once the session is revoked; ory/kratos/e2e.yml shortens the tokenizer ttl to 8s + adds JWT_CLOCK_SKEW_SEC config so re-mint fires at expiry; scope visual suite to visual.spec.ts

This commit is contained in:
2026-06-18 11:32:23 +02:00
parent 4b2173cb84
commit b5af4ba6cd
9 changed files with 204 additions and 6 deletions

View File

@@ -21,6 +21,7 @@ test("loads dev defaults when the environment is empty", () => {
assert.equal(c.ketoWriteUrl, "http://keto:4467");
assert.match(c.cookieSecret, /dev-insecure/);
assert.match(c.csrfSecret, /dev-insecure/);
assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift
});
test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an http endpoint", () => {
@@ -59,6 +60,12 @@ test("rejects an invalid PORT", () => {
for (const PORT of ["0", "70000", "abc", "3000.5"]) assert.throws(() => loadConfig({ PORT }), /PORT/);
});
test("JWT_CLOCK_SKEW_SEC: parses a non-negative integer, rejects junk (E2E shortens it to 0)", () => {
assert.equal(loadConfig({ JWT_CLOCK_SKEW_SEC: "0" }).jwtClockSkewSec, 0);
assert.equal(loadConfig({ JWT_CLOCK_SKEW_SEC: "120" }).jwtClockSkewSec, 120);
for (const v of ["-1", "1.5", "abc"]) assert.throws(() => loadConfig({ JWT_CLOCK_SKEW_SEC: v }), /JWT_CLOCK_SKEW_SEC/);
});
test("rejects a malformed Ory URL", () => {
assert.throws(() => loadConfig({ KETO_READ_URL: "not a url" }), /KETO_READ_URL/);
});

View File

@@ -14,6 +14,7 @@ export interface Config {
csrfSecret: string;
jwksUrl: string;
jwtAudience: string | undefined;
jwtClockSkewSec: number;
jwtIssuer: string | undefined;
ketoReadUrl: string;
ketoWriteUrl: string;
@@ -71,6 +72,15 @@ function readPort(env: Env): number {
return port;
}
// A non-negative integer count of seconds, with a default. Used for the JWT exp/nbf leeway.
function readNonNegInt(env: Env, key: string, devDefault: number): number {
const raw = env[key];
if (raw === undefined) return devDefault;
const n = Number(raw);
if (!Number.isInteger(n) || n < 0) throw new Error(`config: ${key} must be a non-negative integer, got "${raw}"`);
return n;
}
export function loadConfig(env: Env = process.env): Config {
const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
return {
@@ -83,6 +93,8 @@ export function loadConfig(env: Env = process.env): Config {
jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"),
// Optional, off by default: pin the session-JWT issuer/audience for a hardened deploy.
jwtAudience: readOptional(env, "JWT_AUDIENCE"),
// exp/nbf leeway (s) for Kratos↔web clock drift; the auth E2E sets 0 to time tokens out fast.
jwtClockSkewSec: readNonNegInt(env, "JWT_CLOCK_SKEW_SEC", 60),
jwtIssuer: readOptional(env, "JWT_ISSUER"),
ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"),
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),

View File

@@ -23,7 +23,7 @@ console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugi
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
const server = createApp({
auth: { audience: config.jwtAudience, issuer: config.jwtIssuer },
auth: { audience: config.jwtAudience, clockSkewSec: config.jwtClockSkewSec, issuer: config.jwtIssuer },
cache: config.cacheTemplates,
csrfSecret: config.csrfSecret,
jwks,