Split src/http/app.test.ts into one file per concern, and reset the reading-cost trend
CI / full-gate (push) Successful in 3m20s
CI / full-gate (push) Successful in 3m20s
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test, type TestContext } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createApp, type AppOptions } from "./app.ts";
|
||||
import { createDenylist } from "../auth/denylist.ts";
|
||||
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
||||
import { staticJwks } from "../auth/jwks.ts";
|
||||
import type { OAuth2Client } from "../auth/hydra-admin.ts";
|
||||
import type { RelationTuple } from "../auth/keto-client.ts";
|
||||
import type { Identity } from "../auth/kratos-admin.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import adminManifest from "../../examples/plugins/admin/plugin.ts";
|
||||
import { createI18n } from "../i18n/runtime.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "./app-harness.ts";
|
||||
|
||||
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
||||
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
|
||||
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
||||
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
||||
|
||||
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
||||
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||
const ADMIN_CSRF = "admin-secret";
|
||||
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
||||
// Mount the plugin's catalogs the way server.ts does, so its screens render words, not keys.
|
||||
const i18n = createI18n(await loadI18n({ pluginIds: [adminPlugin.id], pluginsDir: examplesPluginsDir }));
|
||||
const app = createApp({ csrfSecret: ADMIN_CSRF, i18n, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const token = issueCsrfToken(ADMIN_CSRF);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
||||
const get = (path: string, permissions: string[] = ADMIN_ALL) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
|
||||
const post = (path: string, body: string) =>
|
||||
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(ADMIN_ALL) }, method: "POST", redirect: "manual" });
|
||||
return { get, post, token, url };
|
||||
}
|
||||
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
|
||||
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
|
||||
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
|
||||
// The permission form's concurrency token, read off the page the way a browser would post it back.
|
||||
const baselineOf = (html: string): string => /name="baseline" value="([0-9a-f]+)"/.exec(html)?.[1] ?? "";
|
||||
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
||||
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
|
||||
const anon = await fetch(url + path, { redirect: "manual" });
|
||||
assert.equal(anon.status, 303);
|
||||
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
|
||||
assert.equal((await get(path, [])).status, 403);
|
||||
}
|
||||
|
||||
|
||||
// Built-in Users admin screen: gate + every CRUD action over HTTP against a mock Kratos admin.
|
||||
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
|
||||
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
|
||||
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
|
||||
const store: Identity[] = [mk("ada@example.com"), mk("babbage@example.com", { state: "inactive" }), mk("you@example.com", { id: "admin1" })];
|
||||
let lastCreate: { traits?: unknown } | undefined;
|
||||
const kratosAdmin = stubAdmin({
|
||||
createIdentity: async (payload) => { lastCreate = payload as { traits?: unknown }; const created = mk("grace@example.com"); store.push(created); return created; },
|
||||
createRecoveryCode: async (id) => ({ code: "123456", link: `http://kratos/self-service/recovery?code=123456&id=${id}` }),
|
||||
deleteIdentity: async (id) => { const i = store.findIndex((x) => x.id === id); if (i >= 0) store.splice(i, 1); },
|
||||
getIdentity: async (id) => store.find((x) => x.id === id) ?? null,
|
||||
listIdentities: async () => ({ identities: store, nextPageToken: null }),
|
||||
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
|
||||
});
|
||||
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
|
||||
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
|
||||
|
||||
await assertAdminGate(url, get, "/admin/users");
|
||||
|
||||
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
|
||||
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
|
||||
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
|
||||
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
|
||||
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
||||
assert.match(usersOnlyNav, /href="\/admin\/users"/);
|
||||
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
|
||||
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
|
||||
|
||||
// The read/write split: users:read opens the list but is refused on every mutation, and the
|
||||
// resources don't leak — a users holder is not a groups holder.
|
||||
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
|
||||
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
|
||||
const readOnlyPost = await fetch(url + "/admin/users", {
|
||||
body: `_csrf=${token}&email=nope@example.com`,
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
assert.equal(readOnlyPost.status, 403);
|
||||
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
|
||||
|
||||
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
||||
const listHtml = await (await get("/admin/users")).text();
|
||||
assert.match(listHtml, /ada@example\.com/);
|
||||
assert.match(listHtml, /href="\/admin\/users\/new"/);
|
||||
assert.doesNotMatch(await (await get("/admin/users?status=inactive")).text(), /ada@example\.com/);
|
||||
|
||||
// Create: the form renders; a valid post creates the identity and redirects to the list.
|
||||
assert.match(await (await get("/admin/users/new")).text(), /Create user/);
|
||||
const created = await post("/admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
|
||||
assert.equal(created.status, 303);
|
||||
assert.equal(created.headers.get("location"), "/admin/users");
|
||||
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
|
||||
|
||||
// A create with no CSRF token is refused and creates nothing.
|
||||
const before = store.length;
|
||||
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
|
||||
assert.equal(store.length, before);
|
||||
|
||||
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
|
||||
// collection: a delete with no token is refused (403) and removes nothing.
|
||||
assert.equal((await post(`/admin/users/${store[1]!.id}/delete`, "")).status, 403);
|
||||
assert.ok(store.some((x) => x.id === store[1]!.id));
|
||||
|
||||
// Edit: email is read-only + prefilled; a post rewrites the name.
|
||||
const target = store[0]!;
|
||||
const editHtml = await (await get(`/admin/users/${target.id}`)).text();
|
||||
assert.match(editHtml, /name="email"[^>]*readonly/);
|
||||
assert.match(editHtml, /value="ada@example\.com"/);
|
||||
const updated = await post(`/admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
|
||||
assert.equal(updated.status, 303);
|
||||
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
|
||||
|
||||
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
|
||||
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
|
||||
assert.equal(target.state, "inactive");
|
||||
assert.equal(denylist.isRevoked(target.id, 0), true);
|
||||
|
||||
// Recovery: renders the edit page (200) showing the generated code (code-based; no admin-host link).
|
||||
const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`);
|
||||
assert.equal(rec.status, 200);
|
||||
const recHtml = await rec.text();
|
||||
assert.match(recHtml, /Recovery code generated/);
|
||||
assert.match(recHtml, /<code>123456<\/code>/);
|
||||
assert.doesNotMatch(recHtml, /self-service\/recovery\?code=/); // the unreachable admin-API link is gone
|
||||
|
||||
// Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
|
||||
const confirm = await (await get(`/admin/users/${target.id}/delete`)).text();
|
||||
assert.match(confirm, /Cancel/);
|
||||
assert.match(confirm, new RegExp(`action="/admin/users/${target.id}/delete"`));
|
||||
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
|
||||
assert.equal(del.status, 303);
|
||||
assert.ok(!store.some((x) => x.id === target.id));
|
||||
|
||||
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
|
||||
assert.equal((await post(`/admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
|
||||
assert.ok(store.some((x) => x.id === "admin1"));
|
||||
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
||||
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
||||
|
||||
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
|
||||
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
||||
assert.equal((await get("/admin/users/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
|
||||
// fakeKeto (tuples are the only state) and a stub Kratos admin (resolves member emails).
|
||||
test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => {
|
||||
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
|
||||
const grace = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b02";
|
||||
const identities: Identity[] = [
|
||||
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
||||
];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
||||
|
||||
await assertAdminGate(url, get, "/admin/groups");
|
||||
|
||||
// List: the existing group shows + the "add" link.
|
||||
const listHtml = await (await get("/admin/groups")).text();
|
||||
assert.match(listHtml, /href="\/admin\/groups\/eng"/);
|
||||
assert.match(listHtml, /href="\/admin\/groups\/new"/);
|
||||
|
||||
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
|
||||
assert.match(await (await get("/admin/groups/new")).text(), /Create group/);
|
||||
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
|
||||
assert.equal(created.status, 303);
|
||||
assert.equal(created.headers.get("location"), "/admin/groups/design");
|
||||
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
|
||||
|
||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||
const before = tuples.length;
|
||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
|
||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=user:${grace}`)).status, 400); // already exists
|
||||
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
||||
assert.equal(tuples.length, before);
|
||||
|
||||
// Detail: lists the current member by email.
|
||||
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
|
||||
|
||||
// Add a member, then remove it.
|
||||
await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||
|
||||
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
|
||||
const permissionsBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||
await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
|
||||
|
||||
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
|
||||
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
|
||||
assert.equal(del.status, 303);
|
||||
assert.equal(del.headers.get("location"), "/admin/groups");
|
||||
assert.ok(!tuples.some((tp) => tp.object === "eng"));
|
||||
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
|
||||
// orphaned grant would resurrect the moment someone re-created a group with the same name.
|
||||
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
|
||||
|
||||
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
|
||||
assert.equal((await get("/admin/groups/Bad%20Name")).status, 404);
|
||||
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
||||
// so the checkboxes are a fixed list and the POST is the desired state.
|
||||
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const denylist = createDenylist();
|
||||
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||
|
||||
// The user edit page renders one checkbox per declared permission, ticked where already held.
|
||||
const edit = await (await get(`/admin/users/${ada}`)).text();
|
||||
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
|
||||
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
|
||||
}
|
||||
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
|
||||
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
|
||||
assert.doesNotMatch(edit, /Permissions saved/); // no confirmation before a save
|
||||
|
||||
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
||||
const baseline = baselineOf(edit);
|
||||
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||
assert.equal(saved.status, 303);
|
||||
assert.deepEqual(
|
||||
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
||||
["groups:read", "users:write"],
|
||||
);
|
||||
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
|
||||
|
||||
// The redirect lands on a page that confirms the save, and shows the new state.
|
||||
const landing = saved.headers.get("location") ?? "";
|
||||
assert.match(landing, new RegExp(`^/admin/users/${ada}\\?info-msg=permissions-saved$`));
|
||||
const confirmed = await (await get(landing)).text();
|
||||
assert.match(confirmed, /Permissions saved/);
|
||||
assert.match(confirmed, /value="users:write"[^>]*checked/);
|
||||
|
||||
// A crafted POST can't grant something no plugin declares.
|
||||
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
||||
|
||||
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||
const groupSaved = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
|
||||
assert.equal(groupSaved.headers.get("location"), "/admin/groups/eng?info-msg=permissions-saved");
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
||||
});
|
||||
|
||||
// Two admins open the same page; the second save would silently undo the first.
|
||||
test("admin permission grants: a save from a stale page is refused, and the page re-renders the current state", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||
|
||||
// Another admin grants users:write while this page is open.
|
||||
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||
const before = JSON.stringify(tuples);
|
||||
const refused = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
|
||||
assert.equal(refused.status, 409);
|
||||
const page = await refused.text();
|
||||
assert.match(page, /changed while you had the page open/);
|
||||
assert.equal(JSON.stringify(tuples), before, "nothing was applied");
|
||||
assert.match(page, /value="users:write"[^>]*checked/); // the re-render shows the other admin's grant
|
||||
assert.notEqual(baselineOf(page), stale);
|
||||
|
||||
// Saving from the re-rendered page goes through.
|
||||
const retried = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
|
||||
assert.equal(retried.status, 303);
|
||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
|
||||
|
||||
// A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too.
|
||||
assert.equal((await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
|
||||
|
||||
// Groups are checked the same way.
|
||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||
const groupStale = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
const groupRefused = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
|
||||
assert.equal(groupRefused.status, 409);
|
||||
assert.match(await groupRefused.text(), /changed while you had the page open/);
|
||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
|
||||
});
|
||||
|
||||
// A Keto error midway leaves some changes applied; the save says so and still revokes the user's live tokens.
|
||||
test("admin permission grants: a Keto failure midway reports a partial save and keeps what was applied", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [];
|
||||
let writes = 0;
|
||||
const keto = fakeKeto(tuples, {
|
||||
writeTuple: async (tp) => {
|
||||
if (++writes === 2) throw new Error("keto unreachable");
|
||||
tuples.push(tp);
|
||||
},
|
||||
});
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const denylist = createDenylist();
|
||||
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||
|
||||
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`);
|
||||
assert.equal(res.status, 502);
|
||||
const page = await res.text();
|
||||
assert.match(page, /stopped part-way/);
|
||||
assert.match(page, /value="groups:read"[^>]*checked/); // the first write landed, and the page shows what is stored
|
||||
assert.doesNotMatch(page, /value="users:write"[^>]*checked/);
|
||||
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
|
||||
assert.equal(denylist.isRevoked(ada, 0), true); // the half that was applied still revokes live tokens
|
||||
});
|
||||
|
||||
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
|
||||
// A save that changes nothing has nothing to refuse and nothing to revoke.
|
||||
test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const denylist = createDenylist();
|
||||
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
||||
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||
const before = JSON.stringify(tuples);
|
||||
|
||||
const repeated = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
|
||||
assert.equal(repeated.status, 303);
|
||||
assert.equal(JSON.stringify(tuples), before);
|
||||
assert.equal(denylist.isRevoked(ada, 0), false);
|
||||
});
|
||||
|
||||
// Keto answered the writes but not the re-read: the report must still reach the admin.
|
||||
test("admin permission grants: a partial save is still reported when Keto cannot be read again", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [];
|
||||
let writes = 0;
|
||||
let down = false;
|
||||
let failureTakesKetoDown = true;
|
||||
const keto = fakeKeto(tuples, {
|
||||
listRelations: async (q = {}) => {
|
||||
if (down) throw new Error("keto unreachable");
|
||||
return { nextPageToken: null, tuples: tuples.filter((tp) => matchesTuple(tp, q)) };
|
||||
},
|
||||
writeTuple: async (tp) => {
|
||||
if (++writes === 2) { down = failureTakesKetoDown; throw new Error("keto unreachable"); }
|
||||
tuples.push(tp);
|
||||
},
|
||||
});
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||
|
||||
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
|
||||
assert.equal(res.status, 502);
|
||||
assert.match(await res.text(), /stopped part-way/);
|
||||
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
|
||||
|
||||
// The same on a group, whose page needs Keto for its members as well.
|
||||
down = false;
|
||||
writes = 0;
|
||||
tuples.length = 0;
|
||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||
const groupRes = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`);
|
||||
assert.equal(groupRes.status, 502);
|
||||
assert.match(await groupRes.text(), /stopped part-way/);
|
||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed");
|
||||
|
||||
// With Keto still answering, the group page itself reports it and shows what is stored.
|
||||
failureTakesKetoDown = false;
|
||||
down = false;
|
||||
writes = 0;
|
||||
tuples.length = 1;
|
||||
const again = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`);
|
||||
assert.equal(again.status, 502);
|
||||
const page = await again.text();
|
||||
assert.match(page, /stopped part-way/);
|
||||
assert.match(page, /value="groups:read"[^>]*checked/);
|
||||
});
|
||||
|
||||
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
|
||||
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
|
||||
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
|
||||
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
|
||||
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||
const baseline = baselineOf(await (await get("/admin/users/admin1")).text());
|
||||
|
||||
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
|
||||
assert.equal(refused.status, 400);
|
||||
assert.match(await refused.text(), /lock yourself out/);
|
||||
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
|
||||
|
||||
// Granting yourself more is not a lockout, so it goes through.
|
||||
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||
assert.equal(granted.status, 303);
|
||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
||||
});
|
||||
|
||||
// The read/write split is only honest if the UI models it: a users:read holder must not be shown
|
||||
// buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
|
||||
test("admin screens render no write affordance for a read-only holder", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
// Hydra is wired so the clients screen renders for real — without it the page is a 503 and the
|
||||
// "no Register button" assertion below would pass without proving anything.
|
||||
const reporting = { client_id: "existing", client_name: "Reporting" };
|
||||
const hydra = stubHydra({ getClient: async (id) => (id === reporting.client_id ? reporting : null), listClients: async () => ({ clients: [reporting], nextPageToken: null }) });
|
||||
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
|
||||
const readOnly = ["users:read", "groups:read"];
|
||||
|
||||
const list = await (await get("/admin/users", readOnly)).text();
|
||||
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
|
||||
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
|
||||
|
||||
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
|
||||
const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
|
||||
assert.doesNotMatch(detail, /Save changes/);
|
||||
assert.doesNotMatch(detail, /Generate recovery code/);
|
||||
assert.doesNotMatch(detail, /Delete user/);
|
||||
assert.doesNotMatch(detail, /Save permissions/);
|
||||
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
|
||||
|
||||
const group = await (await get("/admin/groups/eng", readOnly)).text();
|
||||
assert.doesNotMatch(group, /Add a member/);
|
||||
assert.doesNotMatch(group, /Delete group/);
|
||||
assert.doesNotMatch(group, /Save permissions/);
|
||||
|
||||
// The OAuth2-clients screen is held to the same rule.
|
||||
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
||||
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
||||
const clients = await clientsRes.text();
|
||||
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
|
||||
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
|
||||
// The detail page is where Delete lives, so check it too and not just the list.
|
||||
const clientDetail = await (await get("/admin/clients/existing", ["oauth2-clients:read"])).text();
|
||||
assert.match(clientDetail, /Reporting/);
|
||||
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
|
||||
|
||||
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
|
||||
// rendering a form whose submit would 403.
|
||||
for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
|
||||
assert.equal((await get(path, readOnly)).status, 403, path);
|
||||
}
|
||||
assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
|
||||
|
||||
// A writer sees the affordances the reader didn't.
|
||||
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
|
||||
assert.match(writable, /Save changes/);
|
||||
assert.match(writable, /Save permissions/);
|
||||
});
|
||||
|
||||
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
||||
// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG).
|
||||
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
|
||||
const store: OAuth2Client[] = [
|
||||
{ client_id: "existing", client_name: "Reporting", redirect_uris: ["https://reporting.example/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" },
|
||||
];
|
||||
let seq = 0;
|
||||
const hydra = stubHydra({
|
||||
createClient: async (c) => { const created = { ...c, client_id: `gen-${++seq}`, client_secret: `secret-${seq}` }; store.push(created); return created; },
|
||||
deleteClient: async (id) => { const i = store.findIndex((c) => c.client_id === id); if (i >= 0) store.splice(i, 1); },
|
||||
getClient: async (id) => store.find((c) => c.client_id === id) ?? null,
|
||||
listClients: async () => ({ clients: store, nextPageToken: null }),
|
||||
});
|
||||
const { get, post, token, url } = await adminHarness(t, { hydra });
|
||||
|
||||
await assertAdminGate(url, get, "/admin/clients");
|
||||
|
||||
// List: the existing client shows + the "register" link.
|
||||
const listHtml = await (await get("/admin/clients")).text();
|
||||
assert.match(listHtml, /href="\/admin\/clients\/existing"/);
|
||||
assert.match(listHtml, /href="\/admin\/clients\/new"/);
|
||||
assert.match(listHtml, /Reporting/);
|
||||
|
||||
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the
|
||||
// client and shows the one-time secret + id.
|
||||
const formHtml = await (await get("/admin/clients/new")).text();
|
||||
assert.match(formHtml, /Register client/);
|
||||
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
|
||||
const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
|
||||
assert.equal(created.status, 200); // not a redirect — the secret is shown once
|
||||
const createdHtml = await created.text();
|
||||
assert.match(createdHtml, /Client registered/);
|
||||
assert.match(createdHtml, /secret-1/); // the one-time client_secret
|
||||
assert.match(createdHtml, /gen-1/); // the generated client_id
|
||||
assert.ok(store.some((c) => c.client_name === "Grafana" && c.token_endpoint_auth_method === "client_secret_basic"));
|
||||
|
||||
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
|
||||
const before = store.length;
|
||||
assert.equal((await post("/admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
|
||||
assert.equal((await post("/admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
|
||||
assert.equal(store.length, before);
|
||||
|
||||
// Detail: read-only info, never the secret again, a delete control.
|
||||
const detail = await (await get("/admin/clients/existing")).text();
|
||||
assert.match(detail, /reporting\.example\/cb/);
|
||||
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
|
||||
assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced
|
||||
assert.match(detail, /href="\/admin\/clients\/existing\/delete"/);
|
||||
|
||||
// Delete: a confirm step (GET) then the POST removes the client, back to the list.
|
||||
assert.match(await (await get("/admin/clients/existing/delete")).text(), /Cancel/);
|
||||
const del = await post("/admin/clients/existing/delete", `_csrf=${token}`);
|
||||
assert.equal(del.status, 303);
|
||||
assert.equal(del.headers.get("location"), "/admin/clients");
|
||||
assert.ok(!store.some((c) => c.client_id === "existing"));
|
||||
|
||||
// Unknown id → 404; malformed %-encoding doesn't 500.
|
||||
assert.equal((await get("/admin/clients/does-not-exist")).status, 404);
|
||||
assert.equal((await get("/admin/clients/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import assert from "node:assert/strict";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { test } from "node:test";
|
||||
import { createApp } from "./app.ts";
|
||||
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
||||
import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
|
||||
import { staticJwks } from "../auth/jwks.ts";
|
||||
import type { Identity } from "../auth/kratos-admin.ts";
|
||||
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import { createI18n } from "../i18n/runtime.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "./app-harness.ts";
|
||||
|
||||
// A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message.
|
||||
const node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" });
|
||||
const loginFlow = (id: string): Flow => ({
|
||||
id,
|
||||
ui: {
|
||||
action: `http://127.0.0.1:4433/self-service/login?flow=${id}`,
|
||||
messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }],
|
||||
method: "post",
|
||||
nodes: [
|
||||
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
||||
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
|
||||
node({ name: "password", required: true, type: "password" }, "Password"),
|
||||
node({ name: "method", type: "submit", value: "password" }, "Sign in"),
|
||||
{ attributes: { name: "provider", type: "submit", value: "google" }, group: "oidc", messages: [], meta: { label: { id: 1, text: "Sign in with Google", type: "info" } }, type: "input" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// GET dispatch for the themed auth pages: the same handler branches on session presence.
|
||||
test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a signed-in user is sent home, except /settings", async (t) => {
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: mockKratos(async (_t, id) => { if (id === "stale") throw new KratosError("gone", 410, ""); return loginFlow(id); }) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
// Anonymous, no ?flow= → init one + relay Kratos' CSRF cookie.
|
||||
const init = await fetch(url + "/login", { redirect: "manual" });
|
||||
assert.equal(init.status, 303);
|
||||
assert.equal(init.headers.get("location"), "/login?flow=new1");
|
||||
assert.match(init.headers.get("set-cookie") ?? "", /csrf_token=abc/);
|
||||
// A stale flow id (Kratos 410) bounces back to a fresh init.
|
||||
const stale = await fetch(url + "/login?flow=stale", { redirect: "manual" });
|
||||
assert.equal(stale.status, 303);
|
||||
assert.equal(stale.headers.get("location"), "/login");
|
||||
|
||||
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
|
||||
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
|
||||
for (const path of ["/login", "/registration"]) {
|
||||
const res = await fetch(url + path, signedIn);
|
||||
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
||||
assert.equal(res.headers.get("location"), "/dashboard");
|
||||
}
|
||||
assert.equal((await fetch(url + "/settings", signedIn)).headers.get("location"), "/settings?flow=new1");
|
||||
});
|
||||
|
||||
test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => {
|
||||
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.user
|
||||
// is null and the "already signed in" short-circuit can't fire. Initialising a login/registration
|
||||
// flow then returns Kratos 400 `session_already_available`; recover by completing login (mint the
|
||||
// JWT from the live session), preserving return_to — never fall through to the catch-all 500.
|
||||
const sessionRace = new KratosError("Kratos init login flow failed (400)", 400, JSON.stringify({ error: { id: "session_already_available" } }));
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw sessionRace; } } });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
const recover = await fetch(url + "/login", { redirect: "manual" });
|
||||
assert.equal(recover.status, 303);
|
||||
assert.equal(recover.headers.get("location"), "/auth/complete");
|
||||
// return_to is carried through so the deep link still lands after the JWT is minted.
|
||||
const deep = await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
|
||||
assert.equal(deep.headers.get("location"), "/auth/complete?return_to=%2Fadmin%2Fusers");
|
||||
|
||||
// A genuinely unexpected Kratos 400 is still surfaced as a 500 (not masked as a session race).
|
||||
const app2 = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw new KratosError("bad", 400, JSON.stringify({ error: { id: "security_csrf_violation" } })); } } });
|
||||
await new Promise<void>((r) => app2.listen(0, r));
|
||||
t.after(() => app2.close());
|
||||
const url2 = `http://localhost:${(app2.address() as AddressInfo).port}`;
|
||||
assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500);
|
||||
});
|
||||
|
||||
test("themed auth GET: an anonymous hit on /settings (Kratos 401) bounces to sign-in with the page as return_to, never 500", async (t) => {
|
||||
let failure = new KratosError("Kratos init settings flow failed (401)", 401, "");
|
||||
const throwing = async (): Promise<never> => { throw failure; };
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(throwing), initBrowserFlow: throwing } });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
const init = await fetch(url + "/settings", { redirect: "manual" });
|
||||
assert.equal(init.status, 303);
|
||||
assert.equal(init.headers.get("location"), "/login?return_to=%2Fsettings");
|
||||
const existing = await fetch(url + "/settings?flow=f1", { redirect: "manual" });
|
||||
assert.equal(existing.headers.get("location"), "/login?return_to=%2Fsettings");
|
||||
// /login redirecting to itself would loop, so a 401 there stays the catch-all 500.
|
||||
assert.equal((await fetch(url + "/login", { redirect: "manual" })).status, 500);
|
||||
// Only a 401 means "sign in": an Ory outage on /settings is still the honest 503.
|
||||
failure = new KratosError("Kratos init settings flow failed (500)", 500, "");
|
||||
assert.equal((await fetch(url + "/settings", { redirect: "manual" })).status, 503);
|
||||
});
|
||||
|
||||
// return_to: a deep-link login lands back on the requested page. The gate redirects to
|
||||
// /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion
|
||||
// returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
|
||||
test("login return_to: a first-party deep link is wrapped through /auth/complete; an absolute target passes through as-is", async (t) => {
|
||||
let lastReturnTo: string | undefined;
|
||||
const kratos: KratosPublic = {
|
||||
...mockKratos(async (_t, id) => loginFlow(id)),
|
||||
initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
|
||||
};
|
||||
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}`;
|
||||
|
||||
// A host-relative deep link → wrapped: Kratos returns to <origin>/auth/complete?return_to=<path>,
|
||||
// so the JWT is minted before the user lands on the page (query preserved, re-encoded).
|
||||
await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users?q=1"), { redirect: "manual" });
|
||||
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Fq%3D1$/);
|
||||
|
||||
// An absolute target (the OAuth2 login challenge) is passed to Kratos unchanged — Kratos
|
||||
// allow-lists it. A protocol-relative "//evil.com" is likewise not wrapped (Kratos rejects it).
|
||||
const abs = "http://localhost/oauth2/login?login_challenge=abc";
|
||||
await fetch(url + "/login?return_to=" + encodeURIComponent(abs), { redirect: "manual" });
|
||||
assert.equal(lastReturnTo, abs);
|
||||
await fetch(url + "/login?return_to=" + encodeURIComponent("//evil.com"), { redirect: "manual" });
|
||||
assert.equal(lastReturnTo, "//evil.com");
|
||||
});
|
||||
|
||||
test("login return_to: a chosen language alone still routes through /auth/complete, so it survives the sign-in", async (t) => {
|
||||
let lastReturnTo: string | undefined;
|
||||
const kratos: KratosPublic = {
|
||||
...mockKratos(async (_t, id) => loginFlow(id)),
|
||||
initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
|
||||
};
|
||||
const app = createApp({ i18n: createI18n(await loadI18n()), kratos });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
lastReturnTo = "unset";
|
||||
await fetch(url + "/login", { redirect: "manual" });
|
||||
assert.equal(lastReturnTo, undefined, "no target and no language: Kratos keeps its default");
|
||||
await fetch(url + "/login?locale=sv-SE", { redirect: "manual" });
|
||||
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?locale=sv-SE$/);
|
||||
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
|
||||
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Flocale%3Dsv-SE&locale=sv-SE$/);
|
||||
|
||||
// An absolute target still reaches Kratos beside a chosen language; the language is lost instead.
|
||||
const abs = "http://localhost/oauth2/login?login_challenge=abc";
|
||||
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent(abs), { redirect: "manual" });
|
||||
assert.equal(lastReturnTo, abs);
|
||||
});
|
||||
|
||||
// "Ory down ⇒ no logins" is documented; the auth path should say so honestly (503), not the
|
||||
// generic "error on our end" 500 the catch-all renders.
|
||||
test("auth flow when Ory is unreachable → an honest 503, not the catch-all 500", async (t) => {
|
||||
const boom = () => { throw new KratosError("kratos down", 503, ""); };
|
||||
const down: KratosPublic = { ...mockKratos(async () => boom()), initBrowserFlow: async () => boom() };
|
||||
const app = createApp({ kratos: down });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
const init = await fetch(url + "/login", { redirect: "manual" }); // init (no ?flow=) with Kratos down
|
||||
assert.equal(init.status, 503);
|
||||
assert.match(await init.text(), /unavailable/i);
|
||||
assert.equal((await fetch(url + "/login?flow=f1")).status, 503); // fetching a flow, Kratos down
|
||||
|
||||
// A network-level throw (refused/timeout — not a KratosError) is treated the same way.
|
||||
const refused: KratosPublic = { ...mockKratos(async () => { throw new Error("ECONNREFUSED"); }), initBrowserFlow: async () => { throw new Error("ECONNREFUSED"); } };
|
||||
const app2 = createApp({ kratos: refused });
|
||||
await new Promise<void>((r) => app2.listen(0, r));
|
||||
t.after(() => app2.close());
|
||||
assert.equal((await fetch(`http://localhost:${(app2.address() as AddressInfo).port}/login`, { redirect: "manual" })).status, 503);
|
||||
});
|
||||
|
||||
test("renders a fetched flow as the themed auth page: fields post straight to Kratos, errors surface", async (t) => {
|
||||
const app = createApp({ kratos: mockKratos(async (_t, id) => loginFlow(id)) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/login?flow=f1`)).text();
|
||||
|
||||
// The form posts to flow.ui.action (Kratos owns CSRF); csrf rides as a hidden input.
|
||||
assert.match(html, /<form class="auth-card" method="post" action="http:\/\/127\.0\.0\.1:4433\/self-service\/login\?flow=f1"/);
|
||||
assert.match(html, /<input type="hidden" name="csrf_token" value="tok">/);
|
||||
assert.match(html, /name="identifier"/);
|
||||
assert.match(html, /name="password"[^>]*type="password"/);
|
||||
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
|
||||
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
|
||||
// Configured OIDC provider → an SSO submit button in the same form (posts provider=google);
|
||||
// `formnovalidate` so it bypasses the required email/password fields (SSO needs neither).
|
||||
assert.match(html, /<div class="sso"/);
|
||||
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google" formnovalidate>.*Sign in with Google<\/span><\/button>/s);
|
||||
// The flow-level error renders as an alert.
|
||||
assert.match(html, /class="alert alert-neg"/);
|
||||
assert.match(html, /The credentials are invalid\./); // 4000006 → our wording (README → Translating)
|
||||
});
|
||||
|
||||
// Login completion: /auth/complete is where Kratos lands the browser after login.
|
||||
test("login completion (/auth/complete): a live session mints the JWT cookie; no session → /login, no cookie", async (t) => {
|
||||
const identity: Identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55", traits: { email: "admin@plainpages.local" } };
|
||||
let projected: unknown;
|
||||
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
||||
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${identity.id}` }] }) });
|
||||
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const q = returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : "";
|
||||
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
||||
};
|
||||
|
||||
// Live Kratos session: permissions from Keto → projection → tokenize → JWT cookie, land on the dashboard.
|
||||
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
|
||||
assert.equal(ok.status, 303);
|
||||
assert.equal(ok.headers.get("location"), "/dashboard");
|
||||
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
||||
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions projected onto the identity for the tokenizer
|
||||
|
||||
// return_to: a safe host-relative target lands the user back where they were headed; an
|
||||
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
||||
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "/admin/users?q=1")).headers.get("location"), "/admin/users?q=1");
|
||||
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "//evil.com")).headers.get("location"), "/dashboard");
|
||||
|
||||
// No Kratos session: nothing minted, bounce to /login with no cookie.
|
||||
const none = await complete(createApp({ keto: fakeKeto(), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) }));
|
||||
assert.equal(none.status, 303);
|
||||
assert.equal(none.headers.get("location"), "/login");
|
||||
assert.equal(none.headers.get("set-cookie"), null);
|
||||
});
|
||||
|
||||
test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clears our JWT; bad token → 403", async (t) => {
|
||||
const logoutUrl = "http://127.0.0.1:4433/self-service/logout?token=lt";
|
||||
// Real Kratos keys off its own session cookie (plainpages_session), not our always-present CSRF cookie.
|
||||
const kratos: KratosPublic = { ...mockKratos(async () => { throw new Error("unused"); }), createLogoutFlow: async (o) => (o?.cookie?.includes("plainpages_session") ? { logoutToken: "lt", logoutUrl } : null) };
|
||||
const csrfSecret = "logout-secret";
|
||||
const app = createApp({ csrfSecret, kratos });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const token = issueCsrfToken(csrfSecret);
|
||||
const post = (cookie: string, body: string) =>
|
||||
fetch(url + "/logout", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie }, method: "POST", redirect: "manual" });
|
||||
|
||||
// Valid double-submit (cookie token === form token) + active session → Kratos logout URL, JWT cleared.
|
||||
const out = await post(`${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=x; plainpages_session=s`, `_csrf=${token}`);
|
||||
assert.equal(out.status, 303);
|
||||
assert.equal(out.headers.get("location"), logoutUrl);
|
||||
assert.match(out.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
|
||||
|
||||
// No active Kratos session → clear our cookie and land on /login ourselves.
|
||||
const none = await post(`${CSRF_COOKIE}=${token}`, `_csrf=${token}`);
|
||||
assert.equal(none.status, 303);
|
||||
assert.equal(none.headers.get("location"), "/login");
|
||||
assert.match(none.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
|
||||
|
||||
// Missing field and a forged token are both refused (no Kratos call, no cookie cleared).
|
||||
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "")).status, 403);
|
||||
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "_csrf=forged.sig")).status, 403);
|
||||
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
|
||||
});
|
||||
|
||||
|
||||
const oauthSession = (): Session => ({ active: true, identity: { id: OAUTH_SUBJECT, traits: { email: "ada@x.io" } } });
|
||||
|
||||
test("OAuth2 login challenge (/oauth2/login): a Kratos session accepts via Hydra; no session bounces to /login; missing challenge → 400", async (t) => {
|
||||
const identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55" };
|
||||
let acceptedSubject: string | undefined;
|
||||
const hydra = stubHydra({ acceptLoginRequest: async (_c, b) => { acceptedSubject = b.subject; return { redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }; } });
|
||||
|
||||
const signedIn = createApp({ hydra, kratos: withWhoami(async () => ({ active: true, identity }) as Session) });
|
||||
await new Promise<void>((r) => signedIn.listen(0, r));
|
||||
t.after(() => signedIn.close());
|
||||
const base = `http://localhost:${(signedIn.address() as AddressInfo).port}`;
|
||||
|
||||
// Signed in: accept the challenge with the Kratos identity → 303 to Hydra's resume URL.
|
||||
const accept = await fetch(base + "/oauth2/login?login_challenge=chal1", { headers: { cookie: "plainpages_session=s" }, redirect: "manual" });
|
||||
assert.equal(accept.status, 303);
|
||||
assert.match(accept.headers.get("location") ?? "", /\/oauth2\/auth\?login_verifier=v/);
|
||||
assert.equal(acceptedSubject, identity.id);
|
||||
|
||||
// Missing login_challenge → 400 (someone hit the endpoint directly).
|
||||
assert.equal((await fetch(base + "/oauth2/login", { redirect: "manual" })).status, 400);
|
||||
|
||||
// Not signed in: bounce to the themed login, return_to carrying an absolute URL back to here.
|
||||
const anon = createApp({ hydra: stubHydra(), kratos: withWhoami(async () => null) });
|
||||
await new Promise<void>((r) => anon.listen(0, r));
|
||||
t.after(() => anon.close());
|
||||
const bounce = await fetch(`http://localhost:${(anon.address() as AddressInfo).port}/oauth2/login?login_challenge=chal1`, { redirect: "manual" });
|
||||
assert.equal(bounce.status, 303);
|
||||
const loc = bounce.headers.get("location") ?? "";
|
||||
assert.match(loc, /^\/login\?return_to=/);
|
||||
assert.match(decodeURIComponent(loc.split("return_to=")[1]!), /^http:\/\/[^/]+\/oauth2\/login\?login_challenge=chal1$/);
|
||||
});
|
||||
|
||||
test("OAuth2 consent challenge (/oauth2/consent): skip auto-accepts; a third-party shows the screen; allow/deny POST; CSRF-guarded; missing challenge", async (t) => {
|
||||
const csrfSecret = "consent-secret";
|
||||
let granted: { grant_scope?: string[]; session?: unknown } | undefined;
|
||||
const hydra = stubHydra({
|
||||
acceptConsentRequest: async (_c, b) => { granted = b; return { redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }; },
|
||||
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
||||
});
|
||||
const app = createApp({ csrfSecret, hydra, kratos: withWhoami(async () => oauthSession()) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const token = issueCsrfToken(csrfSecret);
|
||||
const post = (body: string) =>
|
||||
fetch(base + "/oauth2/consent", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${token}` }, method: "POST", redirect: "manual" });
|
||||
|
||||
// Third-party (default stub: not first-party, not skipped) → 200 consent screen listing the
|
||||
// client + scopes, with a CSRF cookie its form echoes back; posts to our own /oauth2/consent.
|
||||
const page = await fetch(base + "/oauth2/consent?consent_challenge=cons1", { redirect: "manual" });
|
||||
assert.equal(page.status, 200);
|
||||
const html = await page.text();
|
||||
assert.match(html, /Authorize Acme Reports/);
|
||||
assert.match(html, /openid/);
|
||||
assert.match(html, /profile/);
|
||||
assert.match(html, /action="\/oauth2\/consent"/);
|
||||
// Informed consent: the screen names the account being authorized + offers a sign-out escape.
|
||||
assert.match(html, /Signed in as.*ada@x\.io/s);
|
||||
assert.match(html, /action="\/logout".*Sign out/s);
|
||||
assert.match(page.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
|
||||
|
||||
// Allow → 303 to Hydra, granting the scopes re-read from the challenge (never form-supplied) +
|
||||
// id_token claims from the Kratos identity.
|
||||
const allow = await post(`_csrf=${token}&consent_challenge=cons1&decision=allow`);
|
||||
assert.equal(allow.status, 303);
|
||||
assert.match(allow.headers.get("location") ?? "", /\/oauth2\/auth\?consent_verifier=v/);
|
||||
assert.deepEqual(granted?.grant_scope, ["openid", "profile"]);
|
||||
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io" } });
|
||||
|
||||
// Deny → 303 back to the client with access_denied.
|
||||
const deny = await post(`_csrf=${token}&consent_challenge=cons1&decision=deny`);
|
||||
assert.equal(deny.status, 303);
|
||||
assert.equal(deny.headers.get("location"), "http://acme.example/cb?error=access_denied");
|
||||
|
||||
// Forged/missing CSRF → 403 (no Hydra call); missing challenge → 400.
|
||||
assert.equal((await post("decision=allow")).status, 403);
|
||||
assert.equal((await fetch(base + "/oauth2/consent", { redirect: "manual" })).status, 400);
|
||||
|
||||
// A Hydra-skipped client auto-accepts on GET (no screen) → 303 to Hydra.
|
||||
const skip = createApp({ hydra: stubHydra({ getConsentRequest: async () => ({ challenge: "cons1", requested_scope: ["openid"], skip: true, subject: OAUTH_SUBJECT }) }), kratos: withWhoami(async () => oauthSession()) });
|
||||
await new Promise<void>((r) => skip.listen(0, r));
|
||||
t.after(() => skip.close());
|
||||
const auto = await fetch(`http://localhost:${(skip.address() as AddressInfo).port}/oauth2/consent?consent_challenge=cons1`, { redirect: "manual" });
|
||||
assert.equal(auto.status, 303);
|
||||
assert.match(auto.headers.get("location") ?? "", /consent_verifier=v/);
|
||||
});
|
||||
|
||||
test("OAuth2 RP-initiated logout (/oauth2/logout): accepts the logout challenge → 303 to Hydra; missing → 400", async (t) => {
|
||||
let acceptedChallenge: string | undefined;
|
||||
const hydra = stubHydra({ acceptLogoutRequest: async (c) => { acceptedChallenge = c; return { redirect: "http://acme.example/post-logout" }; } });
|
||||
const app = createApp({ hydra, kratos: withWhoami(async () => null) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
const ok = await fetch(base + "/oauth2/logout?logout_challenge=lc1", { redirect: "manual" });
|
||||
assert.equal(ok.status, 303);
|
||||
assert.equal(ok.headers.get("location"), "http://acme.example/post-logout");
|
||||
assert.equal(acceptedChallenge, "lc1");
|
||||
|
||||
assert.equal((await fetch(base + "/oauth2/logout", { redirect: "manual" })).status, 400);
|
||||
});
|
||||
|
||||
// All three OAuth2 challenge endpoints share one degrade contract (the documented "byte-identical"
|
||||
// behaviour): a stale/consumed challenge (Hydra 4xx — back button, slow login) → recoverable 400,
|
||||
// a genuine Hydra outage (5xx) → 500.
|
||||
test("OAuth2 challenge endpoints degrade identically: stale Hydra 4xx → 400, outage 5xx → 500", async (t) => {
|
||||
const endpoints: { make: (status: number) => Partial<HydraAdmin>; path: string }[] = [
|
||||
{ make: (s) => ({ getLoginRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/login?login_challenge=x" },
|
||||
{ make: (s) => ({ getConsentRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/consent?consent_challenge=x" },
|
||||
{ make: (s) => ({ acceptLogoutRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/logout?logout_challenge=x" },
|
||||
];
|
||||
for (const { make, path } of endpoints) {
|
||||
for (const [status, expected] of [[410, 400], [503, 500]] as const) {
|
||||
const app = createApp({ hydra: stubHydra(make(status)), kratos: withWhoami(async () => null) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}${path}`, { redirect: "manual" });
|
||||
assert.equal(res.status, expected, `${path} ${status} → ${expected}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Shared fixtures for the app.*.test.ts files: a signing key for session JWTs, an app starter,
|
||||
// and stub Ory clients.
|
||||
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { TestContext } from "node:test";
|
||||
import { createApp } from "./app.ts";
|
||||
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
||||
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||
import type { FlowType, KratosPublic } from "../auth/kratos-public.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
|
||||
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
|
||||
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
export const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
||||
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
|
||||
export function mintJwt(payload: Record<string, unknown>): string {
|
||||
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
|
||||
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
|
||||
}
|
||||
// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
|
||||
export const session = (permissions: string[] = []): string =>
|
||||
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
|
||||
|
||||
export async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string): Promise<string> {
|
||||
const app = createApp(pluginsDir ? { plugins, pluginsDir } : { plugins });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
return `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
}
|
||||
|
||||
export 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"); },
|
||||
whoami: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
export const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
|
||||
createIdentity: async () => { throw new Error("unused"); },
|
||||
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
|
||||
deleteIdentity: async () => {},
|
||||
getIdentity: async () => null,
|
||||
listIdentities: async () => ({ identities: [], nextPageToken: null }),
|
||||
updateIdentity: async () => { throw new Error("unused"); },
|
||||
updateMetadataPublic: async () => ({ id: "x" }),
|
||||
...over,
|
||||
});
|
||||
const sameSet = (a?: SubjectSet, b?: SubjectSet): boolean =>
|
||||
(!a && !b) || (!!a && !!b && a.namespace === b.namespace && a.object === b.object && a.relation === b.relation);
|
||||
export const matchesTuple = (t: RelationTuple, f: Partial<RelationTuple>): boolean =>
|
||||
(f.namespace === undefined || t.namespace === f.namespace) &&
|
||||
(f.object === undefined || t.object === f.object) &&
|
||||
(f.relation === undefined || t.relation === f.relation) &&
|
||||
(f.subject_id === undefined || t.subject_id === f.subject_id) &&
|
||||
(f.subject_set === undefined || sameSet(t.subject_set, f.subject_set));
|
||||
// A stateful in-memory KetoClient over a tuple array (writes mutate it); used by login + the admin screens.
|
||||
export const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}): KetoClient => ({
|
||||
check: async () => false,
|
||||
deleteTuple: async (f) => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); },
|
||||
expand: async () => ({ type: "leaf" }),
|
||||
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }),
|
||||
writeTuple: async (tp) => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); },
|
||||
...over,
|
||||
});
|
||||
export const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
||||
|
||||
// OAuth2 login challenge: another app logs in *through* us; Hydra hands the browser here.
|
||||
export const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
|
||||
acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }),
|
||||
acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }),
|
||||
acceptLogoutRequest: async () => ({ redirect: "http://acme.example/post-logout" }),
|
||||
createClient: async (c) => ({ ...c, client_id: "c1", client_secret: "s3cr3t" }),
|
||||
deleteClient: async () => {},
|
||||
getClient: async () => null,
|
||||
getConsentRequest: async () => ({ challenge: "cons1", client: { client_name: "Acme Reports" }, requested_scope: ["openid", "profile"], skip: false, subject: OAUTH_SUBJECT }),
|
||||
getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }),
|
||||
listClients: async () => ({ clients: [], nextPageToken: null }),
|
||||
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
||||
rejectLoginRequest: async () => { throw new Error("unused"); },
|
||||
...over,
|
||||
});
|
||||
export const OAUTH_SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test, type TestContext } from "node:test";
|
||||
import { createApp } from "./app.ts";
|
||||
import { staticJwks } from "../auth/jwks.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { createI18n } from "../i18n/runtime.ts";
|
||||
import type { MenuConfig } from "../ui/menu-config.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
import { ecJwk, mintJwt } from "./app-harness.ts";
|
||||
|
||||
// ---- language (i18n) ----
|
||||
|
||||
// The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE).
|
||||
async function localeApp(t: TestContext): Promise<string> {
|
||||
const app = createApp({ i18n: createI18n(await loadI18n()), jwks: staticJwks([ecJwk]) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
return `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
}
|
||||
|
||||
test("?locale serves that language and carries the choice onto the links the page renders", async (t) => {
|
||||
const url = await localeApp(t);
|
||||
const html = await (await fetch(`${url}/?locale=sv-SE`)).text();
|
||||
|
||||
assert.match(html, /<html lang="sv-SE" dir="ltr">/); // the document says what language it is in
|
||||
assert.match(html, /Logga in/); // the landing page's own words
|
||||
assert.doesNotMatch(html, /Operational web apps/);
|
||||
// The chosen locale rides along, so clicking through the app stays in Swedish without a cookie.
|
||||
assert.match(html, /href="\/login\?locale=sv-SE"/);
|
||||
// …and the picker offers the other installed locale, pointing at this same page.
|
||||
assert.match(html, /hreflang="en-US"/);
|
||||
assert.match(html, /href="\/\?locale=en-US"/);
|
||||
});
|
||||
|
||||
test("Accept-Language decides when the URL doesn't, and a lone language matches its region", async (t) => {
|
||||
const url = await localeApp(t);
|
||||
const swedish = await (await fetch(`${url}/`, { headers: { "accept-language": "sv;q=0.9, en;q=0.4" } })).text();
|
||||
assert.match(swedish, /<html lang="sv-SE"/);
|
||||
// The visitor never asked for a locale in the URL, so the links stay clean.
|
||||
assert.match(swedish, /href="\/login"/);
|
||||
|
||||
const english = await (await fetch(`${url}/`, { headers: { "accept-language": "de-DE" } })).text();
|
||||
assert.match(english, /<html lang="en-US"/); // nothing matches ⇒ the baseline
|
||||
});
|
||||
|
||||
test("an uninstalled or malformed ?locale falls back instead of failing", async (t) => {
|
||||
const url = await localeApp(t);
|
||||
for (const bad of ["sv-FI", "klingon", "../../etc"]) {
|
||||
const res = await fetch(`${url}/?locale=${encodeURIComponent(bad)}`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(await res.text(), /<html lang="en-US"/, `expected en-US for ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a redirect the host emits keeps the visitor's language", async (t) => {
|
||||
const url = await localeApp(t);
|
||||
const res = await fetch(`${url}/dashboard?locale=sv-SE`, { redirect: "manual" }); // anonymous ⇒ sign in first
|
||||
assert.equal(res.status, 303);
|
||||
const location = res.headers.get("location") ?? "";
|
||||
assert.match(location, /^\/login\?/);
|
||||
assert.match(location, /locale=sv-SE/);
|
||||
});
|
||||
|
||||
test("the error pages speak the visitor's language too", async (t) => {
|
||||
const url = await localeApp(t);
|
||||
const html = await (await fetch(`${url}/no-such-page?locale=sv-SE`)).text();
|
||||
assert.match(html, /<html lang="sv-SE"/);
|
||||
assert.match(html, /Sidan hittades inte/);
|
||||
});
|
||||
|
||||
test("a plugin that owns a landing page, or short-circuits a hook, translates from its own catalog", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-i18n-plugin-"));
|
||||
mkdirSync(join(dir, "demo", "i18n"), { recursive: true });
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n');
|
||||
|
||||
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
|
||||
const demo: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
dashboard: (ctx) => ({ html: ctx.t("demo.hello") }),
|
||||
home: (ctx) => ({ html: ctx.t("demo.hello") }),
|
||||
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
|
||||
id: "demo",
|
||||
};
|
||||
const i18n = createI18n(await loadI18n({ pluginIds: ["demo"], pluginsDir: dir }));
|
||||
const app = createApp({ i18n, jwks: staticJwks([ecJwk]), plugins: [demo], pluginsDir: dir });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const cookie = `${SESSION_COOKIE}=${mintJwt({ email: "a@b", exp: nowSec + 600, permissions: [], sub: "u1" })}`;
|
||||
|
||||
assert.equal(await (await fetch(`${url}/`)).text(), "Hello from the plugin");
|
||||
assert.equal(await (await fetch(`${url}/hooked`)).text(), "Hello from the plugin");
|
||||
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
|
||||
});
|
||||
|
||||
test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => {
|
||||
// The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a
|
||||
// broken menu takes the error pages down with it.
|
||||
let built = 0;
|
||||
const menu: MenuConfig = { branding: { get name() { built++; return "Plainpages"; } }, override: {} };
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), menu });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
const res = await fetch(`${url}/no-such-page`);
|
||||
assert.equal(res.status, 404);
|
||||
assert.match(await res.text(), /Page not found/);
|
||||
assert.equal(built, 0);
|
||||
});
|
||||
|
||||
test("a POST-rendered page still offers the language picker, pointed at a page that answers GET", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-post-lang-"));
|
||||
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
// The view renders the picker exactly as the shell does.
|
||||
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<%- include("partials/locale-switch") %>`);
|
||||
const demo: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "demo",
|
||||
routes: [
|
||||
{ handler: () => ({ view: "page" }), method: "GET", path: "/thing" },
|
||||
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing" },
|
||||
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing/act" }, // POST-only: no GET sibling
|
||||
],
|
||||
};
|
||||
const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo], pluginsDir: dir });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const post = (path: string, headers: Record<string, string> = {}) => fetch(url + path, { headers, method: "POST" });
|
||||
|
||||
// A POST whose path also answers GET → the picker points at that page.
|
||||
assert.match(await (await post("/demo/thing?locale=sv-SE")).text(), /href="\/demo\/thing\?locale=en-US"/);
|
||||
// A POST-only path → the page the form was submitted from, so the link can't dead-end on a 405.
|
||||
const fromForm = await post("/demo/thing/act?locale=sv-SE", { referer: `${url}/demo/thing?locale=sv-SE` });
|
||||
assert.match(await fromForm.text(), /href="\/demo\/thing\?locale=en-US"/);
|
||||
// …and with no referer to fall back on, the front page.
|
||||
assert.match(await (await post("/demo/thing/act?locale=sv-SE")).text(), /href="\/\?locale=en-US"/);
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { createApp } from "./app.ts";
|
||||
import { readFormBody } from "./body.ts";
|
||||
import { createDenylist } from "../auth/denylist.ts";
|
||||
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
||||
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||
import { staticJwks } from "../auth/jwks.ts";
|
||||
import type { Identity } from "../auth/kratos-admin.ts";
|
||||
import { KratosError, type Session } from "../auth/kratos-public.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "./app-harness.ts";
|
||||
|
||||
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
||||
const demoPlugin: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "demo",
|
||||
routes: [
|
||||
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
|
||||
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
|
||||
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
|
||||
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
|
||||
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
|
||||
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
|
||||
],
|
||||
};
|
||||
|
||||
test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
||||
mkdirSync(join(dir, "demo", "public"), { recursive: true });
|
||||
// The view also include()s a core building-block partial, proving plugin views reuse them.
|
||||
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<h1>Hello <%= who %></h1><%- include("partials/theme-switch") %>`);
|
||||
writeFileSync(join(dir, "demo", "public", "app.css"), ".demo{color:red}");
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
const url = await startApp(t, [demoPlugin], dir);
|
||||
|
||||
// Path param + html
|
||||
const hi = await fetch(url + "/demo/hello/world");
|
||||
assert.equal(hi.status, 200);
|
||||
assert.match(await hi.text(), /Hi world/);
|
||||
|
||||
// json
|
||||
const data = await fetch(url + "/demo/data");
|
||||
assert.match(data.headers.get("content-type") ?? "", /application\/json/);
|
||||
assert.deepEqual(await data.json(), { ok: true });
|
||||
|
||||
// redirect (POST → 303 Location)
|
||||
const go = await fetch(url + "/demo/go", { method: "POST", redirect: "manual" });
|
||||
assert.equal(go.status, 303);
|
||||
assert.equal(go.headers.get("location"), "/demo/hello/world");
|
||||
|
||||
// view rendered from the plugin's own views/, including a core partial
|
||||
const page = await (await fetch(url + "/demo/page")).text();
|
||||
assert.match(page, /Hello Plainpages/);
|
||||
assert.match(page, /role="radiogroup"/); // core partials/theme-switch resolved
|
||||
|
||||
// static asset served from the plugin's own public/ at /public/<id>/
|
||||
const css = await fetch(url + "/public/demo/app.css");
|
||||
assert.equal(css.status, 200);
|
||||
assert.match(css.headers.get("content-type") ?? "", /text\/css/);
|
||||
assert.match(await css.text(), /\.demo/);
|
||||
assert.equal((await fetch(url + "/public/demo/..%2f..%2fplugin.ts")).status, 403); // traversal still blocked
|
||||
|
||||
// gated route, anonymous → redirect to sign in (like the built-in screens), not a dead-end 403;
|
||||
// the requested page is preserved as return_to so login lands the user back there.
|
||||
const denied = await fetch(url + "/demo/secret", { redirect: "manual" });
|
||||
assert.equal(denied.status, 303);
|
||||
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||
|
||||
// a route marked public is reachable anonymously — no gate, no redirect.
|
||||
const open = await fetch(url + "/demo/public-page", { redirect: "manual" });
|
||||
assert.equal(open.status, 200);
|
||||
assert.match(await open.text(), /open to all/);
|
||||
|
||||
// known path + wrong method → 405 with Allow; unknown path → 404
|
||||
const wrong = await fetch(url + "/demo/data", { method: "DELETE" });
|
||||
assert.equal(wrong.status, 405);
|
||||
assert.match(wrong.headers.get("allow") ?? "", /GET/);
|
||||
assert.equal((await fetch(url + "/demo/nope")).status, 404);
|
||||
});
|
||||
|
||||
test("a plugin view renders the native chrome; its forms are CSRF-guarded via ctx.verifyCsrf", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||
mkdirSync(join(dir, "panelkit", "views"), { recursive: true });
|
||||
// The view composes the core shell from ctx.chrome — branding, the global nav — and its own
|
||||
// CSRF-guarded form carrying chrome.csrfToken (the representative way a plugin form gets the token,
|
||||
// independent of the shell's auth-dependent profile/sign-out block).
|
||||
writeFileSync(join(dir, "panelkit", "views", "panel.ejs"),
|
||||
`<%- include("partials/shell", { body: '<form method="post" action="/panelkit/save"><input type="hidden" name="_csrf" value="' + chrome.csrfToken + '" /></form>', brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), title, user: chrome.user }) %>`);
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
|
||||
const plugin: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "panelkit",
|
||||
nav: [{ href: "/panelkit/panel", icon: "i-grid", id: "panelkit", label: "Panel kit" }],
|
||||
routes: [
|
||||
{ handler: (ctx) => ({ data: { chrome: ctx.chrome, title: "Panel" }, view: "panel" }), method: "GET", path: "/panel" },
|
||||
{
|
||||
handler: async (ctx) => {
|
||||
const form = await readFormBody(ctx.req);
|
||||
if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, "bad csrf");
|
||||
return { redirect: "/panelkit/panel" };
|
||||
},
|
||||
method: "POST", path: "/save",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const secret = "test-csrf-secret";
|
||||
const app = createApp({ csrfSecret: secret, plugins: [plugin], pluginsDir: dir });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
// GET renders the shell: branding (DEFAULT_MENU), the (ungated) plugin nav, and a CSRF cookie
|
||||
// whose token is embedded in the plugin's own form (double-submit).
|
||||
const res = await fetch(url + "/panelkit/panel");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.text();
|
||||
assert.match(body, /class="brand-name">Plainpages/);
|
||||
assert.match(body, /Panel kit/);
|
||||
const cookieTok = /plainpages_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
|
||||
assert.ok(cookieTok, "a plugin route issues the CSRF cookie when fresh");
|
||||
assert.equal(/name="_csrf" value="([^"]+)"/.exec(body)?.[1], cookieTok);
|
||||
|
||||
// POST with no token → 403 (ctx.verifyCsrf fails closed); matching cookie + field → 303.
|
||||
assert.equal((await fetch(url + "/panelkit/save", { method: "POST", redirect: "manual" })).status, 403);
|
||||
const tok = issueCsrfToken(secret);
|
||||
const ok = await fetch(url + "/panelkit/save", {
|
||||
body: `_csrf=${encodeURIComponent(tok)}`,
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${tok}` },
|
||||
method: "POST", redirect: "manual",
|
||||
});
|
||||
assert.equal(ok.status, 303);
|
||||
});
|
||||
|
||||
// JWT middleware: a verified session cookie populates ctx.user/permissions, which the gate reads.
|
||||
// The key + mintJwt + session() helper live in app-harness.ts.
|
||||
test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => {
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
|
||||
|
||||
// Token carrying the gating permission → the handler runs (200).
|
||||
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" })}`);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(await ok.text(), "secret");
|
||||
|
||||
// No cookie and an expired token both render anonymous → the gate bounces to sign in (303 → /login,
|
||||
// remembering the gated page as return_to).
|
||||
const noCookie = await secret();
|
||||
assert.equal(noCookie.status, 303);
|
||||
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`)).status, 303);
|
||||
|
||||
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
|
||||
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
|
||||
// composition + permission-filtering is covered in the admin-screen tests below.
|
||||
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["admin"], sub: "u1" })}` } });
|
||||
assert.equal(dash.status, 200);
|
||||
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
||||
assert.equal(anonDash.status, 303);
|
||||
assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard");
|
||||
});
|
||||
|
||||
test("revocation denylist: a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {
|
||||
const denylist = createDenylist(); // no Ory clients ⇒ a revoked token drops straight to anonymous (no re-mint)
|
||||
const app = createApp({ denylist, jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, permissions: ["demo:read"], sub: "u1" })}` } });
|
||||
|
||||
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
|
||||
|
||||
denylist.revoke("u1");
|
||||
assert.equal((await secret(nowSec - 5)).status, 303); // the pre-revoke token now bounces to /login
|
||||
assert.equal((await secret(nowSec + 5)).status, 200); // a fresh re-login (iat after the revoke) still works
|
||||
});
|
||||
|
||||
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, permissions: ["demo:read"], sub: "u1" });
|
||||
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }] }) });
|
||||
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["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 now-anonymous request bounces to sign in.
|
||||
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 }, redirect: "manual" });
|
||||
assert.equal(denied.status, 303);
|
||||
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||
assert.match(denied.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
|
||||
|
||||
// Ory unreachable (not a dead session): whoami throws → degrade to anonymous (bounce to /login, not 500),
|
||||
// and leave the cookie untouched so the token can re-mint once Ory recovers.
|
||||
const down = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: withWhoami(async () => { throw new KratosError("kratos down", 503, ""); }), kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
||||
await new Promise<void>((r) => down.listen(0, r));
|
||||
t.after(() => down.close());
|
||||
const outage = await fetch(`http://localhost:${(down.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired }, redirect: "manual" });
|
||||
assert.equal(outage.status, 303);
|
||||
assert.equal(outage.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||
assert.equal(outage.headers.get("set-cookie"), null);
|
||||
});
|
||||
|
||||
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 = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "guarded",
|
||||
routes: [
|
||||
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
|
||||
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
|
||||
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
|
||||
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
|
||||
{ handler: () => ({ html: "mine" }), method: "GET", path: "/mine", session: true }, // declarative session gate
|
||||
],
|
||||
};
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const auth = (permissions: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions, sub: "u1" })}` } });
|
||||
|
||||
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
|
||||
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
|
||||
assert.equal(anon.status, 303);
|
||||
assert.equal(anon.headers.get("location"), "/login?return_to=%2Fguarded%2Fme");
|
||||
const me = await fetch(url + "/guarded/me", auth([]));
|
||||
assert.equal(me.status, 200);
|
||||
assert.match(await me.text(), /hi a@b\.c/);
|
||||
|
||||
// can: signed-in but lacking the permission → 403 page; carrying it → 200.
|
||||
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
|
||||
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
|
||||
|
||||
// check (live Keto): the keto verdict gates the handler.
|
||||
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
|
||||
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
||||
|
||||
// declarative route `permission` gate: anonymous → sign in, signed-in-without-permission → the 403 page, with → 200.
|
||||
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
|
||||
assert.equal(gAnon.status, 303);
|
||||
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
|
||||
const gDenied = await fetch(url + "/guarded/gated", auth([]));
|
||||
assert.equal(gDenied.status, 403);
|
||||
assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP
|
||||
assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200);
|
||||
|
||||
// declarative `session` gate: anonymous → sign in, and any signed-in user through, grant or none.
|
||||
const sAnon = await fetch(url + "/guarded/mine", { redirect: "manual" });
|
||||
assert.equal(sAnon.status, 303);
|
||||
assert.equal(sAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fmine");
|
||||
assert.equal((await fetch(url + "/guarded/mine", auth([]))).status, 200);
|
||||
});
|
||||
|
||||
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
|
||||
const seen: string[] = [];
|
||||
const hooked: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
hooks: {
|
||||
onRequest: (c) => (c.url.pathname === "/hooked/blocked" ? { html: "blocked by hook", status: 403 } : undefined),
|
||||
onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`),
|
||||
},
|
||||
id: "hooked",
|
||||
routes: [{ handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" }],
|
||||
};
|
||||
const url = await startApp(t, [hooked]);
|
||||
|
||||
// onRequest short-circuits before routing — handler never runs; a fresh CSRF cookie still rides the
|
||||
// response so a form the hook renders has its matching double-submit cookie.
|
||||
const blocked = await fetch(url + "/hooked/blocked");
|
||||
assert.equal(blocked.status, 403);
|
||||
assert.match(await blocked.text(), /blocked by hook/);
|
||||
assert.match(blocked.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
|
||||
|
||||
// A normal route runs the handler; onResponse observed its result.
|
||||
assert.match(await (await fetch(url + "/hooked/ok")).text(), /handler ran/);
|
||||
assert.ok(seen.includes("/hooked/ok:handler ran"));
|
||||
});
|
||||
|
||||
+5
-1410
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user