Login completion (todo §4); /auth/complete: roles from Keto → metadata_public projection → tokenize → plainpages_jwt cookie; fix tokenizer projection metadata_admin→metadata_public (whoami strips admin metadata)

This commit is contained in:
2026-06-17 23:15:28 +02:00
parent 26a7821611
commit 38157605d0
13 changed files with 288 additions and 28 deletions

92
src/login.test.ts Normal file
View File

@@ -0,0 +1,92 @@
// Login completion (§4): turn a Kratos session into our session JWT — read roles from Keto,
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
// the live, full-stack login is verified by the §8 Playwright E2E.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { KetoClient, RelationTuple } from "./keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.ts";
import { completeLogin, readRoles, SESSION_COOKIE, sessionCookie } from "./login.ts";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` });
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false,
deleteTuple: async () => {},
expand: async () => ({ type: "leaf" }),
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
writeTuple: async () => {},
...over,
});
const adminStub = (over: Partial<KratosAdmin> = {}): KratosAdmin => ({
createIdentity: async () => { throw new Error("unused"); },
deleteIdentity: async () => {},
getIdentity: async () => null,
listIdentities: async () => ({ identities: [], nextPageToken: null }),
updateIdentity: async () => { throw new Error("unused"); },
updateMetadataPublic: async () => ({ id: ID }),
...over,
});
const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
getFlow: async () => { throw new Error("unused"); },
initBrowserFlow: async () => { throw new Error("unused"); },
submitFlow: async () => { throw new Error("unused"); },
whoami: async () => null,
...over,
});
test("readRoles reads direct Role memberships from Keto — paged, de-duped, sorted", async () => {
const calls: unknown[] = [];
const keto = ketoStub({
listRelations: async (q) => {
calls.push(q);
if (!q?.pageToken) return { nextPageToken: "p2", tuples: [roleTuple("editor"), roleTuple("admin")] };
return { nextPageToken: null, tuples: [roleTuple("admin")] }; // duplicate across pages
},
});
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
assert.deepEqual(calls[0], { namespace: "Role", relation: "members", subject_id: `user:${ID}` });
assert.equal((calls[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
});
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
const events: string[] = [];
let projected: unknown;
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({
whoami: async (o) => {
if (o?.tokenizeAs) { events.push("tokenize"); return { active: true, identity, tokenized: "h.p.s" } as Session; }
events.push("whoami"); return { active: true, identity } as Session;
},
});
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
const keto = ketoStub({ listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
});
test("completeLogin returns null and touches nothing when there is no active session", async () => {
let touched = false;
const keto = ketoStub({ listRelations: async () => { touched = true; return { nextPageToken: null, tuples: [] }; } });
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
assert.equal(await completeLogin({ keto, kratosAdmin, kratosPublic: publicStub() }, undefined), null);
assert.equal(touched, false);
});
test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => {
const identity: Identity = { id: ID, traits: {} };
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
});
test("sessionCookie builds the HttpOnly/Lax JWT cookie; secure opt-in; JWT chars stay readable", () => {
const jwt = "aaa.bbb-_.ccc";
assert.equal(sessionCookie(jwt), `${SESSION_COOKIE}=${jwt}; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax`);
assert.match(sessionCookie(jwt, { secure: true }), /; SameSite=Lax; Secure$/);
});