Say user throughout, noting Ory's identity naming in the docs

This commit is contained in:
2026-08-03 17:13:20 +02:00
parent 9966b6bd46
commit f38b5373bd
37 changed files with 259 additions and 251 deletions
+5 -5
View File
@@ -20,13 +20,13 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
assert.equal(body.credentials.password.config.password, "admin");
});
test("permissionTuple grants a permission to identity:<id> in the Permission namespace", () => {
test("permissionTuple grants a permission to user:<id> in the Permission namespace", () => {
const id = randomUUID();
assert.deepEqual(permissionTuple(id, "admin"), {
namespace: "Permission",
object: "admin",
relation: "granted",
subject_id: `identity:${id}`,
subject_id: `user:${id}`,
});
});
@@ -65,8 +65,8 @@ test("seedAdmin on a fresh stack creates the identity and grants every permissio
assert.equal(puts.length, 2); // one grant per permission
assert.ok(puts.every((p) => p.method === "PUT"));
assert.deepEqual(puts.map((p) => p.body), [
{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` },
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `identity:${id}` },
{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` },
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `user:${id}` },
]);
});
@@ -94,7 +94,7 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
});
assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${id}` });
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` });
});
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
+3 -3
View File
@@ -22,10 +22,10 @@ export function identityPayload(email: string, password: string) {
};
}
// Coarse-permission grant: `Permission:<permission>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
// Coarse-permission grant: `Permission:<permission>#members@user:<id>`. Subject ids are `user:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
export function permissionTuple(identityId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `identity:${identityId}` };
export function permissionTuple(userId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
}
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
+5 -5
View File
@@ -2,17 +2,17 @@ import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { test } from "node:test";
import { buildContext, type RequestContext, type SessionIdentity } from "../http/context.ts";
import { buildContext, type RequestContext, type User } from "../http/context.ts";
import { can, check, GuardError, requireSession } from "./guards.ts";
import type { KetoClient, RelationTuple } from "./keto-client.ts";
function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext {
function ctxFor(user: User | null, url = "/"): RequestContext {
const req = new IncomingMessage(new Socket());
req.url = url;
return buildContext(req, new ServerResponse(req), { identity: user });
return buildContext(req, new ServerResponse(req), { user });
}
const alice: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] };
const alice: User = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] };
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
assert.equal(requireSession(ctxFor(alice)), alice);
@@ -44,7 +44,7 @@ test("check asks Keto with the current user as subject; anonymous is denied with
const tuple = { namespace: "Resource", object: "doc1", relation: "view" };
assert.equal(await check(keto, ctxFor(alice), tuple), true);
assert.deepEqual(asked, { ...tuple, subject_id: "identity:u1" }); // subject is the signed-in user
assert.deepEqual(asked, { ...tuple, subject_id: "user:u1" }); // subject is the signed-in user
asked = undefined;
assert.equal(await check(keto, ctxFor(null), tuple), false); // fail-closed, no Keto call
+6 -6
View File
@@ -3,7 +3,7 @@
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
import type { RequestContext, SessionIdentity } from "../http/context.ts";
import type { RequestContext, User } from "../http/context.ts";
import type { KetoClient } from "./keto-client.ts";
import { localPath } from "../http/safe-url.ts";
@@ -32,9 +32,9 @@ export class GuardError extends Error {
}
// Assert a signed-in session and return the user. Anonymous ⇒ GuardError → /login (return_to kept).
export function requireSession(ctx: RequestContext): SessionIdentity {
if (!ctx.identity) throw new GuardError(401, "authentication required", loginRedirect(ctx));
return ctx.identity;
export function requireSession(ctx: RequestContext): User {
if (!ctx.user) throw new GuardError(401, "authentication required", loginRedirect(ctx));
return ctx.user;
}
// Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
@@ -49,6 +49,6 @@ export async function check(
ctx: RequestContext,
tuple: { namespace: string; object: string; relation: string },
): Promise<boolean> {
if (!ctx.identity) return false;
return keto.check({ ...tuple, subject_id: `identity:${ctx.identity.id}` });
if (!ctx.user) return false;
return keto.check({ ...tuple, subject_id: `user:${ctx.user.id}` });
}
+18 -18
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto";
import { test } from "node:test";
import { staticJwks } from "./jwks.ts";
import { authenticate, claimsToIdentity, resolveSession, verifyToken } from "./jwt-middleware.ts";
import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
import { SESSION_COOKIE } from "./login.ts";
const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url");
@@ -59,31 +59,31 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => {
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
});
test("claimsToIdentity requires sub + email, defaults permissions to [], keeps only string permissions", () => {
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/);
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/);
assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent
assert.deepEqual(claimsToIdentity({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
test("claimsToUser requires sub + email, defaults permissions to [], keeps only string permissions", () => {
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).permissions, []); // permissions absent
assert.deepEqual(claimsToUser({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
});
test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => {
test("resolveSession classifies the cookie; authenticate is its fail-closed user projection", async () => {
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
const identity = { email: "a@b.c", id: "u1", permissions: ["admin"] };
const user = { email: "a@b.c", id: "u1", permissions: ["admin"] };
// A valid token → the user, not expired.
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity });
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
// Present but past exp → the re-mint trigger (expired flagged, no user).
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, identity: null });
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, user: null });
// No cookie / non-ours / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip).
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, identity: null });
assert.deepEqual(await resolveSession("other=1", jwks, { now: NOW }), { expired: false, identity: null });
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, identity: null });
assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, identity: null });
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession("other=1", jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, user: null });
// authenticate() is the convenience wrapper — resolveSession(...).user, dropping the flag.
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), identity);
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), user);
assert.equal(await authenticate(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), null); // expired ⇒ null
assert.equal(await authenticate(undefined, jwks, { now: NOW }), null);
});
@@ -94,7 +94,7 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
// Revoked: thrown as *expired* so resolveSession flags it for the re-mint (re-read Keto / clear).
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: null });
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null });
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", permissions: ["admin"] });
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
+10 -10
View File
@@ -3,7 +3,7 @@
// check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
// (anonymous), so the route renders signed-out and the permission gate denies.
import type { SessionIdentity } from "../http/context.ts";
import type { User } from "../http/context.ts";
import { parseCookies } from "../http/cookie.ts";
import type { Denylist } from "./denylist.ts";
import { decodeJws, verifyJws } from "./jwt.ts";
@@ -61,7 +61,7 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
// always sets them; an empty email would read as anonymous in the shell); permissions defaults to [] and
// keeps only string entries (defensive).
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
export function claimsToUser(payload: Record<string, unknown>): User {
const sub = payload["sub"];
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
const email = payload["email"];
@@ -72,13 +72,13 @@ export function claimsToIdentity(payload: Record<string, unknown>): SessionIdent
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
// claims, project the User. Throws TokenError / the underlying verify error on any failure.
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity> {
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User> {
const { header } = decodeJws(token); // unverified — only to read `kid` for key selection
const jwk = await jwks.getKey(header.kid);
if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`);
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
validateClaims(verified.payload, options);
const user = claimsToIdentity(verified.payload);
const user = claimsToUser(verified.payload);
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
@@ -87,7 +87,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
export interface SessionAuth {
expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate
identity: SessionIdentity | null;
user: User | null;
}
// The request middleware: read our session cookie, verify it → the User (fail-closed: any
@@ -96,15 +96,15 @@ export interface SessionAuth {
// expired session, never for anonymous or garbage requests.
export async function resolveSession(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionAuth> {
const token = parseCookies(cookieHeader)[SESSION_COOKIE];
if (!token) return { expired: false, identity: null };
if (!token) return { expired: false, user: null };
try {
return { expired: false, identity: await verifyToken(token, jwks, options) };
return { expired: false, user: await verifyToken(token, jwks, options) };
} catch (err) {
return { expired: err instanceof TokenError && err.expired, identity: null };
return { expired: err instanceof TokenError && err.expired, user: null };
}
}
// Convenience for callers that don't re-mint: just the User, or null.
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity | null> {
return (await resolveSession(cookieHeader, jwks, options)).identity;
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User | null> {
return (await resolveSession(cookieHeader, jwks, options)).user;
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { createKetoClient, KetoError } from "./keto-client.ts";
const READ = "http://keto:4466";
const WRITE = "http://keto:4467";
const USER = "identity:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const USER = "user:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
function res(status: number, body?: unknown): Response {
const h = new Headers();
@@ -35,7 +35,7 @@ test("check GETs the read API and returns the allowed boolean (true and false)",
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
const deny = recorder(() => res(403, { allowed: false }));
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:nobody" }), false);
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:nobody" }), false);
});
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
+3 -3
View File
@@ -33,7 +33,7 @@ export interface RecoveryCode {
export interface KratosAdmin {
createIdentity(payload: unknown): Promise<Identity>;
createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
createRecoveryCode(userId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
deleteIdentity(id: string): Promise<void>;
getIdentity(id: string): Promise<Identity | null>;
listIdentities(opts?: ListOptions): Promise<IdentityList>;
@@ -67,8 +67,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
// user by Kratos; the code/link are also returned so an operator can hand them over directly.
async createRecoveryCode(identityId, opts = {}) {
const body: Record<string, unknown> = { identity_id: identityId };
async createRecoveryCode(userId, opts = {}) {
const body: Record<string, unknown> = { identity_id: userId };
if (opts.expiresIn) body.expires_in = opts.expiresIn;
const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" });
if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res);
+7 -7
View File
@@ -9,7 +9,7 @@ import type { KratosPublic, Session } from "./kratos-public.ts";
import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `identity:${ID}` });
const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `user:${ID}` });
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false,
@@ -49,11 +49,11 @@ test("readPermissions returns permissions held directly OR transitively (enumera
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
listRelations: async (q) => {
listQ.push(q);
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "identity:other" })] };
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "user:other" })] };
return { nextPageToken: "p2", tuples: [
permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
permission("admin", { subject_id: `identity:${ID}` }),
permission("viewer", { subject_id: "identity:stranger" }),
permission("admin", { subject_id: `user:${ID}` }),
permission("viewer", { subject_id: "user:stranger" }),
] };
},
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
@@ -79,7 +79,7 @@ test("completeLogin: read permissions → project onto metadata_public → token
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", permissions: ["admin"] });
assert.deepEqual(out, { email: "admin@plainpages.local", userId: ID, jwt: "h.p.s", permissions: ["admin"] });
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
});
@@ -105,12 +105,12 @@ test("remintSession: a live Kratos session → fresh cookie + refreshed user; a
// TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie.
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] });
assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] });
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
const dead = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic: publicStub() }, undefined);
assert.equal(dead.identity, null);
assert.equal(dead.user, null);
assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/);
});
+13 -13
View File
@@ -6,7 +6,7 @@
// 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie
// Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto.
import type { SessionIdentity } from "../http/context.ts";
import type { User } from "../http/context.ts";
import { serializeCookie, type CookieOptions } from "../http/cookie.ts";
import { currentLog } from "../logger.ts";
import type { KetoClient } from "./keto-client.ts";
@@ -32,18 +32,18 @@ export interface LoginDeps {
export interface CompletedLogin {
email: string | null;
identityId: string;
userId: string;
jwt: string;
permissions: string[];
}
// The coarse permissions a user holds — directly (`Permission:<name>#members@identity:<id>`) or transitively via a
// The coarse permissions a user holds — directly (`Permission:<name>#members@user:<id>`) or transitively via a
// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
// request; permission count is small, so the per-permission checks are cheap and run in parallel.
export async function readPermissions(keto: KetoClient, identityId: string): Promise<string[]> {
const subject_id = `identity:${identityId}`;
export async function readPermissions(keto: KetoClient, userId: string): Promise<string[]> {
const subject_id = `user:${userId}`;
const names = new Set<string>();
let pageToken: string | undefined;
do {
@@ -59,24 +59,24 @@ export async function readPermissions(keto: KetoClient, identityId: string): Pro
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {});
if (!session?.identity) return null;
const identityId = session.identity.id;
const userId = session.identity.id;
const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : null;
const permissions = await readPermissions(deps.keto, identityId);
await deps.kratosAdmin.updateMetadataPublic(identityId, { permissions });
const permissions = await readPermissions(deps.keto, userId);
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
const jwt = tokenized?.tokenized;
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
currentLog()?.info("session minted", { permissions: permissions.join(","), sub: identityId }); // login or TTL re-mint
return { email, identityId, jwt, permissions };
currentLog()?.info("session minted", { permissions: permissions.join(","), sub: userId }); // login or TTL re-mint
return { email, userId, jwt, permissions };
}
export interface Reminted {
setCookie: string; // a fresh JWT cookie on success, else a cookie that clears the stale one
identity: SessionIdentity | null;
user: User | null;
}
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
@@ -86,8 +86,8 @@ export interface Reminted {
// anonymous instead of re-hitting Ory on every one.
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), identity: null };
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, permissions: completed.permissions } };
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
}
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+3 -3
View File
@@ -44,7 +44,7 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
const pathname = ctx.url.pathname;
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
if (ctx.identity && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
const cookie = ctx.req.headers.cookie;
const flowId = ctx.url.searchParams.get("flow");
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
@@ -75,7 +75,7 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.identity
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
// is null and the "already signed in" short-circuit above 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), honouring return_to — never a 500.
@@ -218,7 +218,7 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
}
const flow = await kratos.createLogoutFlow(ctx.req.headers.cookie ? { cookie: ctx.req.headers.cookie } : {});
ctx.res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
ctx.log.info("logout", { sub: ctx.identity?.id ?? "" });
ctx.log.info("logout", { sub: ctx.user?.id ?? "" });
return { redirect: flow?.logoutUrl ?? "/login" };
};
}
+28 -28
View File
@@ -105,7 +105,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
t.after(() => rmSync(dir, { force: true, recursive: true }));
const portal: Plugin = {
apiVersion: "1.0.0",
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.identity }, view: "board" }),
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.user }, view: "board" }),
home: () => ({ data: { brand: "Acme" }, view: "welcome" }),
id: "portal",
};
@@ -125,7 +125,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
assert.equal(board.status, 200);
const html = await board.text();
assert.match(html, /<h1 class="page-title">My Portal<\/h1>/); // its own title in the native shell
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.identity
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.user
assert.doesNotMatch(html, /Avery Kline/); // the built-in mock People list is gone — fully replaced
});
@@ -516,7 +516,7 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
assert.equal(ok.status, 303);
});
// JWT middleware: a verified session cookie populates ctx.identity/permissions, which the gate reads.
// JWT middleware: a verified session cookie populates ctx.user/permissions, which the gate reads.
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
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] });
@@ -569,7 +569,7 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl
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: "identity:u1" }] }) });
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.
@@ -727,7 +727,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
});
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.identity
// 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.
@@ -884,7 +884,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
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: `identity:${identity.id}` }] }) });
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());
@@ -1178,7 +1178,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
{ 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: `identity:${ada}` }];
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 });
@@ -1192,26 +1192,26 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
// 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=identity:${grace}`);
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 === `identity:${grace}`));
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=identity:${grace}`)).status, 400);
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=identity:${grace}`)).status, 400); // already exists
assert.equal((await post("/admin/groups", `name=x&member=identity:${grace}`)).status, 403);
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=identity:${grace}`);
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`));
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=identity:${grace}`);
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`));
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}`));
// 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/);
@@ -1237,8 +1237,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
];
// grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada.
const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` },
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${ada}` },
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${ada}` },
];
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
const expandSet = (set: SubjectSet): ExpandTree => ({
@@ -1262,17 +1262,17 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
// Create: a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=identity:${ada}`);
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=user:${ada}`);
assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/permissions/viewer");
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`));
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now
// 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/permissions", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/permissions", `name=x&member=identity:${ada}`)).status, 403);
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/permissions", `name=x&member=user:${ada}`)).status, 403);
assert.equal(tuples.length, before);
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
@@ -1293,8 +1293,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
await post("/admin/permissions/editor/members", `_csrf=${token}&member=identity:${grace}`);
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
await post("/admin/permissions/editor/members", `_csrf=${token}&member=user:${grace}`);
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.equal(denylist.isRevoked(grace, 0), true);
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list.
@@ -1305,11 +1305,11 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor"));
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1).
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:admin1" });
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:admin1" });
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin"));
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1"));
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
+14 -14
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type SessionIdentity } from "./context.ts";
import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts";
@@ -40,7 +40,7 @@ export interface AppOptions {
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
jwks?: JwksProvider; // verify the session JWT → ctx.identity/permissions; absent ⇒ always anonymous
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
@@ -124,7 +124,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data));
return null;
}
return { data: { chrome: ctx.chrome, user: ctx.identity }, view: "home" };
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
};
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign
@@ -132,7 +132,7 @@ export function createApp(options: AppOptions = {}): Server {
// handler renders against its own views, same path as a plugin route. Else the built-in
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
if (!ctx.identity) return { redirect: loginRedirect(ctx), status: 303 };
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
csrf.setCookie();
if (dashboardPlugin) {
@@ -141,7 +141,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
return null;
}
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, identity: ctx.identity, nav: ctx.chrome.nav }) }, view: "index" };
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav }) }, view: "index" };
};
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
@@ -186,19 +186,19 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// Verify the session JWT once (cached JWKS) → ctx.identity/permissions; none/invalid ⇒ anonymous.
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
let user: SessionIdentity | null = null;
let user: User | null = null;
if (jwks) {
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
user = auth.identity;
user = auth.user;
if (!user && auth.expired && keto && kratos && kratosAdmin) {
try {
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
user = reminted.identity;
user = reminted.user;
res.appendHeader("set-cookie", reminted.setCookie);
} catch (err) {
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
@@ -223,10 +223,10 @@ export function createApp(options: AppOptions = {}): Server {
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
// or the public "/" with a standalone home, never composes the menu).
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, identity: user }));
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
// base context (no route params yet); reused for onRequest hooks and the landing routes.
const ctx = buildContext(req, res, { chrome, identity: user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const ctx = buildContext(req, res, { chrome, user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
// Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) {
@@ -245,12 +245,12 @@ export function createApp(options: AppOptions = {}): Server {
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = buildContext(req, res, { chrome, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
const routeCtx = buildContext(req, res, { chrome, user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
if (!isAuthorized(match.route, routeCtx.permissions)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.identity.id });
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
+5 -5
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { test } from "node:test";
import { buildContext, type SessionIdentity } from "./context.ts";
import { buildContext, type User } from "./context.ts";
import { createLogger } from "../logger.ts";
// A req/res pair without a live server — enough to build and inspect a context.
@@ -22,7 +22,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
assert.equal(ctx.query, ctx.url.searchParams); // same instance, not a copy
assert.equal(ctx.query.get("q"), "ann");
assert.equal(ctx.query.get("page"), "2");
assert.equal(ctx.identity, null);
assert.equal(ctx.user, null);
assert.deepEqual(ctx.permissions, []);
assert.deepEqual(ctx.params, {});
});
@@ -35,9 +35,9 @@ test("buildContext threads path params supplied by the router", () => {
test("buildContext threads the user and derives permissions from it", () => {
const { req, res } = reqRes("/");
const user: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
const ctx = buildContext(req, res, { identity: user });
assert.equal(ctx.identity, user);
const user: User = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
const ctx = buildContext(req, res, { user });
assert.equal(ctx.user, user);
assert.equal(ctx.permissions, user.permissions); // same reference, never a divergent copy — buildContext is the only writer
});
+10 -10
View File
@@ -5,11 +5,12 @@ import { createLogger, type Log } from "../logger.ts";
// The request context threaded to every route handler (plugin + built-in), built once
// per request by `buildContext`: the router supplies matched path `params`, the JWT
// middleware supplies `identity` (null until then). The host's single handler argument.
// middleware supplies `user` (null until then). The host's single handler argument.
// The authenticated Kratos identity, projected from verified session JWT claims:
// The signed-in user, projected from verified session JWT claims. Ory calls this record an
// "identity" (see README); Plainpages says user throughout.
// `id` = `sub`, plus `email` and the coarse `permissions` carried in the token.
export interface SessionIdentity {
export interface User {
email: string;
id: string;
permissions: string[];
@@ -19,21 +20,20 @@ export interface RequestContext {
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
chrome: PageChrome;
// The signed-in Kratos identity, or null when anonymous.
identity: SessionIdentity | null;
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
log: Log;
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
req: IncomingMessage;
res: ServerResponse;
permissions: string[]; // identity?.permissions ?? [] — coarse gate without a null-check
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
system?: SystemCapabilities;
url: URL;
user: User | null; // the signed-in user, or null when anonymous
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
verifyCsrf(submitted: string | null | undefined): boolean;
@@ -44,7 +44,7 @@ export interface BuildContextOptions {
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
// The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome;
identity?: SessionIdentity | null;
user?: User | null;
log?: Log;
params?: Record<string, string>;
system?: SystemCapabilities;
@@ -63,18 +63,18 @@ export function buildContext(
options: BuildContextOptions = {},
): RequestContext {
const url = new URL(req.url ?? "/", "http://localhost");
const identity = options.identity ?? null;
const user = options.user ?? null;
const buildChrome = options.chrome;
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
identity,
user,
log: options.log ?? SILENT_LOG,
params: options.params ?? {},
query: url.searchParams,
req,
res,
permissions: identity?.permissions ?? [],
permissions: user?.permissions ?? [],
...(options.system ? { system: options.system } : {}),
url,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
+3 -3
View File
@@ -1,6 +1,6 @@
// Guards the Ory Keto config: migrations run before the server (keto-migrate →
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
// points at, and the OPL declares the identity/permission/group/resource namespaces. Version pinning is
// points at, and the OPL declares the user/permission/group/resource namespaces. Version pinning is
// in compose.test.ts. Real boot is verified by running the stack; this catches edits.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -35,8 +35,8 @@ test("keto loads the OPL namespaces from the mounted file", () => {
"namespaces come from the committed OPL");
});
test("the OPL declares permission, group and a resource namespace over identity subjects", () => {
for (const ns of ["Identity", "Group", "Permission", "Resource"])
test("the OPL declares permission, group and a resource namespace over user subjects", () => {
for (const ns of ["User", "Group", "Permission", "Resource"])
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
// permission + group are subject sets read at login → JWT permissions claim (README).
assert.match(opl, /class Permission implements Namespace\s*{\s*related:\s*{\s*granted:/,
+1 -1
View File
@@ -6,7 +6,7 @@
export { definePlugin } from "./plugin.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { RequestContext, SessionIdentity } from "../http/context.ts";
export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
+2 -2
View File
@@ -48,7 +48,7 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
const chrome = buildPluginChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
identity: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
});
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
@@ -58,7 +58,7 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
});
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["admin"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened
+5 -5
View File
@@ -5,7 +5,7 @@
// admin plugin is installed) — run through composeNav (override + per-user filter) and
// current-marked for the request path.
import type { SessionIdentity } from "../http/context.ts";
import type { User } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
@@ -29,17 +29,17 @@ export interface ChromeOptions {
currentPath?: string; // request pathname; the matching nav leaf is marked current
menu: MenuConfig;
plugins?: Plugin[];
identity?: SessionIdentity | null;
user?: User | null;
}
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : [];
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
const permissions = opts.identity?.permissions ?? [];
const permissions = opts.user?.permissions ?? [];
const nav = composeNav(fragments, opts.menu.override, permissions);
if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
@@ -56,7 +56,7 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// Anonymous "Sign in" returns to the current page (it's host-relative, our own pathname).
signInHref: opts.currentPath ? `/login?return_to=${encodeURIComponent(opts.currentPath)}` : "/login",
...(b.theme != null ? { theme: b.theme } : {}),
user: shellUser(opts.identity),
user: shellUser(opts.user),
};
}
+1 -1
View File
@@ -8,7 +8,7 @@ import type { NavNode } from "./nav.ts";
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
const m = buildDashboardModel({ csrfToken: "tok.sig", user: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
assert.equal(m.shell.title, "Dashboard");
assert.equal(m.shell.csrfToken, "tok.sig");
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
+3 -3
View File
@@ -4,12 +4,12 @@
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
// once per request by the host, so the dashboard shows the exact same menu as every other page.
import type { SessionIdentity } from "../http/context.ts";
import type { User } from "../http/context.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts";
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; identity?: SessionIdentity | null } = {}) {
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; user?: User | null } = {}) {
return {
nav: opts.nav ?? [],
shell: buildShellContext({
@@ -17,7 +17,7 @@ export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfi
csrfToken: opts.csrfToken ?? "",
menu: opts.menu ?? DEFAULT_MENU,
title: "Dashboard",
identity: opts.identity ?? null,
user: opts.user ?? null,
}),
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
signInHref: "/login?return_to=%2Fx",
title: "Users",
identity: { email: "a@b.c", id: "u1", permissions: ["admin"] },
user: { email: "a@b.c", id: "u1", permissions: ["admin"] },
});
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
assert.equal(full.theme, "dark");
+7 -7
View File
@@ -5,7 +5,7 @@
// the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest".
import type { SessionIdentity } from "../http/context.ts";
import type { User } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.ts";
export interface ShellUser {
@@ -24,10 +24,10 @@ export interface ShellModel {
user: ShellUser;
}
export function shellUser(identity: SessionIdentity | null | undefined): ShellUser {
if (!identity) return { email: "", initials: "G", name: "Guest" };
const local = identity.email.split("@")[0] || identity.email;
return { email: identity.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
export function shellUser(user: User | null | undefined): ShellUser {
if (!user) return { email: "", initials: "G", name: "Guest" };
const local = user.email.split("@")[0] || user.email;
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
}
export function buildShellContext(opts: {
@@ -36,7 +36,7 @@ export function buildShellContext(opts: {
menu: MenuConfig;
signInHref?: string;
title: string;
identity?: SessionIdentity | null;
user?: User | null;
}): ShellModel {
const b = opts.menu.branding;
return {
@@ -46,6 +46,6 @@ export function buildShellContext(opts: {
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
...(b.theme != null ? { theme: b.theme } : {}),
title: opts.title,
user: shellUser(opts.identity),
user: shellUser(opts.user),
};
}