Files
plainpages/src/config.test.ts
lilleman 3c8090e8e3 Built-in OAuth2 login-challenge handler (todo §6); /oauth2/login resolves a Hydra login challenge via the Kratos session — skip→accept(subject), live session→accept(identity id), no session→bounce to /login?return_to back here so Kratos lands on the challenge once signed in. New src/hydra-admin.ts (fetch client: get/accept/reject login request + HydraError, mirrors the kratos/keto clients) + src/oauth-login.ts (pure resolveLoginChallenge); wired in app.ts (the absolute return URL derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates return_to against its allow-list; /login now bakes return_to into the flow init), config.hydraAdminUrl (default http://hydra:4445), server builds the client, compose web now gates on hydra healthy (the app consumes it). A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login) degrades to a recoverable 400, not a 500; a genuine Hydra 5xx outage still surfaces as 500. Tests-first: hydra-admin/oauth-login units + app/config/compose HTTP integration + full-stack e2e/oauth-login.spec.ts (compose.e2e-oauth.yml — registers an OAuth2 client, starts an auth flow, asserts the unauthenticated bounce and the authenticated accept; boot-verified then torn down). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one warning (4xx→400 degrade). Deferred §9: document that prod allowed_return_urls entries must be exact origins with a trailing /. typecheck + 253 units + 8 visual + oauth-login E2E green. Consent handler + client registration are the next §6 items.
2026-06-18 21:45:24 +02:00

89 lines
4.1 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { loadConfig } from "./config.ts";
// Explicit secure-secret enforcement (no environment sniffing): secrets are the only
// thing a hardened deploy must supply.
const secureEnv = {
COOKIE_SECRET: "real-cookie-secret",
CSRF_SECRET: "real-csrf-secret",
REQUIRE_SECURE_SECRETS: "true",
};
test("loads dev defaults when the environment is empty", () => {
const c = loadConfig({});
assert.equal(c.port, 3000);
assert.equal(c.cacheTemplates, false);
assert.equal(c.secureCookies, false); // dev runs http; prod sets SECURE_COOKIES=true
assert.equal(c.kratosPublicUrl, "http://kratos:4433");
assert.equal(c.kratosAdminUrl, "http://kratos:4434");
assert.equal(c.ketoReadUrl, "http://keto:4466");
assert.equal(c.ketoWriteUrl, "http://keto:4467");
assert.equal(c.hydraAdminUrl, "http://hydra:4445");
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", () => {
// The session JWT is signed by the tokenizer key (kratos.yml jwks_url); Kratos does NOT
// republish it at /.well-known/jwks.json, so the §4 verifier reads that same file://.
// gen-jwks.test.ts owns that the file is a valid ES256 signing key with a kid.
const url = new URL(loadConfig({}).jwksUrl);
assert.equal(url.protocol, "file:");
assert.match(url.pathname, /tokenizer\/jwks\.json$/);
});
test("JWT issuer/audience are optional: unset by default, pinned from the env", () => {
const def = loadConfig({});
assert.equal(def.jwtIssuer, undefined);
assert.equal(def.jwtAudience, undefined);
const c = loadConfig({ JWT_AUDIENCE: "plainpages", JWT_ISSUER: "https://id.example.com" });
assert.equal(c.jwtIssuer, "https://id.example.com");
assert.equal(c.jwtAudience, "plainpages");
});
test("parses explicit boolean toggles and rejects non-boolean values", () => {
assert.equal(loadConfig({ CACHE_TEMPLATES: "true" }).cacheTemplates, true);
assert.equal(loadConfig({ CACHE_TEMPLATES: "false" }).cacheTemplates, false);
assert.equal(loadConfig({ SECURE_COOKIES: "true" }).secureCookies, true);
assert.throws(() => loadConfig({ CACHE_TEMPLATES: "yes" }), /CACHE_TEMPLATES/);
});
test("reads overrides from the environment", () => {
const c = loadConfig({ COOKIE_SECRET: "x", KRATOS_PUBLIC_URL: "https://id.example.com", PORT: "8080" });
assert.equal(c.port, 8080);
assert.equal(c.kratosPublicUrl, "https://id.example.com");
assert.equal(c.cookieSecret, "x");
});
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/);
});
test("REQUIRE_SECURE_SECRETS rejects a missing or dev-throwaway secret", () => {
assert.throws(() => loadConfig({ REQUIRE_SECURE_SECRETS: "true" }), /COOKIE_SECRET/);
assert.throws(() => loadConfig({ COOKIE_SECRET: "real", REQUIRE_SECURE_SECRETS: "true" }), /CSRF_SECRET/);
assert.throws(
() => loadConfig({ COOKIE_SECRET: "dev-insecure-cookie-secret", CSRF_SECRET: "real", REQUIRE_SECURE_SECRETS: "true" }),
/COOKIE_SECRET/,
);
});
test("REQUIRE_SECURE_SECRETS succeeds with real secrets and still defaults the Ory URLs", () => {
const c = loadConfig(secureEnv);
assert.equal(c.cookieSecret, "real-cookie-secret");
assert.equal(c.csrfSecret, "real-csrf-secret");
assert.equal(c.kratosPublicUrl, "http://kratos:4433"); // only secrets are enforced; URLs still default
});