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:
@@ -6,7 +6,9 @@ import { dirname, join } from "node:path";
|
||||
import { after, before, test, type TestContext } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createApp } from "./app.ts";
|
||||
import { KratosError, type Flow, type FlowType, type KratosPublic, type UiNode } from "./kratos-public.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
||||
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "./kratos-public.ts";
|
||||
import type { Plugin } from "./plugin.ts";
|
||||
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
||||
|
||||
@@ -265,6 +267,55 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
|
||||
assert.match(html, /The provided credentials are invalid\./);
|
||||
});
|
||||
|
||||
// Login completion (§4): /auth/complete is where Kratos lands the browser after login.
|
||||
const stubAdmin = (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: "x" }),
|
||||
...over,
|
||||
});
|
||||
const stubKeto = (over: Partial<KetoClient>): KetoClient => ({
|
||||
check: async () => false,
|
||||
deleteTuple: async () => {},
|
||||
expand: async () => ({ type: "leaf" }),
|
||||
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
||||
writeTuple: async () => {},
|
||||
...over,
|
||||
});
|
||||
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
||||
|
||||
test("login completion: mints the session JWT (roles from Keto → projection → tokenize) and sets the 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 = stubKeto({ listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
|
||||
|
||||
const app = createApp({ keto, kratos, kratosAdmin });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete`, { headers: { cookie: "plainpages_session=s" }, redirect: "manual" });
|
||||
|
||||
assert.equal(res.status, 303);
|
||||
assert.equal(res.headers.get("location"), "/");
|
||||
assert.match(res.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
||||
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
|
||||
});
|
||||
|
||||
test("login completion with no Kratos session redirects to /login and sets no cookie", async (t) => {
|
||||
const app = createApp({ keto: stubKeto({}), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete`, { redirect: "manual" });
|
||||
|
||||
assert.equal(res.status, 303);
|
||||
assert.equal(res.headers.get("location"), "/login");
|
||||
assert.equal(res.headers.get("set-cookie"), null);
|
||||
});
|
||||
|
||||
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
||||
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
||||
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
|
||||
|
||||
21
src/app.ts
21
src/app.ts
@@ -7,7 +7,10 @@ import { buildDashboardModel } from "./dashboard.ts";
|
||||
import { PLUGINS_DIR } from "./discovery.ts";
|
||||
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
||||
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||
import { KratosError, type KratosPublic } from "./kratos-public.ts";
|
||||
import { completeLogin, sessionCookie } from "./login.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import type { Plugin, RouteResult } from "./plugin.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||
@@ -20,7 +23,9 @@ export interface AppOptions {
|
||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
||||
// Off by default so edits show live; the app itself never inspects the environment.
|
||||
cache?: boolean;
|
||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
|
||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
|
||||
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion (§4)
|
||||
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
|
||||
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
|
||||
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
||||
@@ -30,7 +35,9 @@ export interface AppOptions {
|
||||
|
||||
export function createApp(options: AppOptions = {}): Server {
|
||||
const cache = options.cache ?? false;
|
||||
const keto = options.keto;
|
||||
const kratos = options.kratos;
|
||||
const kratosAdmin = options.kratosAdmin;
|
||||
const menu = options.menu ?? DEFAULT_MENU;
|
||||
const plugins = options.plugins ?? [];
|
||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||
@@ -112,6 +119,20 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return;
|
||||
}
|
||||
|
||||
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
|
||||
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
|
||||
// and store it as the cookie; no active session bounces back to sign in (§4).
|
||||
if (pathname === "/auth/complete" && method === "GET" && kratos && kratosAdmin && keto) {
|
||||
const completed = await completeLogin({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
|
||||
if (!completed) {
|
||||
res.writeHead(303, { location: "/login" }).end();
|
||||
return;
|
||||
}
|
||||
// secure: off in dev http; the §9 cookie hardening toggles it on for prod.
|
||||
res.writeHead(303, { location: "/", "set-cookie": sessionCookie(completed.jwt) }).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
||||
// Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts.
|
||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, [], menu) }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Kratos admin-API client (§4): typed fetch wrappers over Ory Kratos' admin endpoints —
|
||||
// identity CRUD + the surgical metadata_admin update the login flow projects roles into.
|
||||
// identity CRUD + the surgical metadata_public update the login flow projects roles into.
|
||||
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
|
||||
// result mapping (201/200/404/4xx). Live wiring is verified by login completion (§4).
|
||||
import { test } from "node:test";
|
||||
@@ -89,14 +89,14 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
|
||||
assert.equal(calls[0]!.body, JSON.stringify(body));
|
||||
});
|
||||
|
||||
test("updateMetadataAdmin PATCHes a JSON-Patch `add /metadata_admin` so it never clobbers traits", async () => {
|
||||
const identity = { id: ID, metadata_admin: { roles: ["admin"] } };
|
||||
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
|
||||
const identity = { id: ID, metadata_public: { roles: ["admin"] } };
|
||||
const { calls, fetchImpl } = recorder(() => res(200, identity));
|
||||
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataAdmin(ID, { roles: ["admin"] });
|
||||
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] });
|
||||
assert.deepEqual(out, identity);
|
||||
assert.equal(calls[0]!.method, "PATCH");
|
||||
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
|
||||
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_admin", value: { roles: ["admin"] } }]);
|
||||
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
|
||||
});
|
||||
|
||||
test("deleteIdentity DELETEs by id (204 resolves; non-204 throws a KratosError)", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Kratos admin-API client (todo §4): typed `fetch` wrappers over Ory Kratos' admin
|
||||
// endpoints — identity CRUD and the surgical `metadata_admin` update login completion
|
||||
// endpoints — identity CRUD and the surgical `metadata_public` update login completion
|
||||
// projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
||||
// `fetchImpl`-injectable like kratos-public.ts. Reuses that module's `KratosError` so a
|
||||
// caller can branch on `.status`. Admin endpoints listen on the internal-only admin port.
|
||||
@@ -32,7 +32,7 @@ export interface KratosAdmin {
|
||||
getIdentity(id: string): Promise<Identity | null>;
|
||||
listIdentities(opts?: ListOptions): Promise<IdentityList>;
|
||||
updateIdentity(id: string, payload: unknown): Promise<Identity>;
|
||||
updateMetadataAdmin(id: string, metadata: unknown): Promise<Identity>;
|
||||
updateMetadataPublic(id: string, metadata: unknown): Promise<Identity>;
|
||||
}
|
||||
|
||||
// Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a
|
||||
@@ -88,12 +88,13 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
||||
return (await res.json()) as Identity;
|
||||
},
|
||||
|
||||
// JSON Patch `add` sets metadata_admin whether it's currently absent, null, or set, and
|
||||
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
|
||||
// touches nothing else — so the login role projection never clobbers traits/state.
|
||||
async updateMetadataAdmin(id, metadata) {
|
||||
const patch = [{ op: "add", path: "/metadata_admin", value: metadata }];
|
||||
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
|
||||
async updateMetadataPublic(id, metadata) {
|
||||
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
|
||||
const res = await http(identity(id), { body: JSON.stringify(patch), headers: json, method: "PATCH" });
|
||||
if (res.status !== 200) return fail("update metadata_admin", res);
|
||||
if (res.status !== 200) return fail("update metadata_public", res);
|
||||
return (await res.json()) as Identity;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface Flow {
|
||||
export interface Session {
|
||||
active?: boolean;
|
||||
expires_at?: string;
|
||||
identity?: { id: string; metadata_admin?: unknown; traits?: Record<string, unknown> };
|
||||
identity?: { id: string; metadata_public?: unknown; traits?: Record<string, unknown> }; // whoami strips metadata_admin
|
||||
tokenized?: string; // the signed JWT — present only when `tokenize_as` was requested
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ test("self-service flows return to our themed pages", () => {
|
||||
`${flow} flow points at our /${flow} page`);
|
||||
});
|
||||
|
||||
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
|
||||
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/127\.0\.0\.1:3000\/auth\/complete/,
|
||||
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete (§4)");
|
||||
});
|
||||
|
||||
test("recovery + verification run on email code, delivered by a courier", () => {
|
||||
assert.ok((kratosYml.match(/use:\s*code/g) ?? []).length >= 2,
|
||||
"recovery + verification both use the email-code method");
|
||||
@@ -70,10 +75,12 @@ test("session tokenizer template 'plainpages' mints a short-lived signed JWT", (
|
||||
"claims via the committed mapper");
|
||||
});
|
||||
|
||||
test("the tokenizer claims mapper emits email + roles from the metadata_admin projection", () => {
|
||||
test("the tokenizer claims mapper emits email + roles from the metadata_public projection", () => {
|
||||
// metadata_public, not _admin: the session Kratos hands the tokenizer carries only public
|
||||
// metadata (admin metadata is stripped), so the roles projection must live in metadata_public.
|
||||
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
|
||||
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
|
||||
assert.match(mapper, /metadata_admin/, "roles ← metadata_admin (the per-login Keto projection, §4)");
|
||||
assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection, §4)");
|
||||
});
|
||||
|
||||
test("social sign-in is off by default — a clean clone stays password-only", () => {
|
||||
|
||||
92
src/login.test.ts
Normal file
92
src/login.test.ts
Normal 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$/);
|
||||
});
|
||||
75
src/login.ts
Normal file
75
src/login.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Login completion (todo §4): turn a fresh Kratos session into our locally-verifiable
|
||||
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
|
||||
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
|
||||
// 2. read roles from Keto → the source of truth for the `roles` claim
|
||||
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
|
||||
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
|
||||
// Order matters: the projection is written before tokenizing, because the claims mapper
|
||||
// reads only the identity, never Keto.
|
||||
import { serializeCookie, type CookieOptions } from "./cookie.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||
import type { KratosPublic } from "./kratos-public.ts";
|
||||
|
||||
// Our session cookie — the signed JWT the hot path verifies in-process. Distinct from
|
||||
// Kratos' own `plainpages_session` cookie (the long-lived login the JWT is re-minted off).
|
||||
export const SESSION_COOKIE = "plainpages_jwt";
|
||||
|
||||
// Mirrors kratos.yml session.lifespan (30d) so the cookie survives browser restarts; the
|
||||
// JWT inside is short-lived (~10m) and re-minted by the §4 middleware on expiry.
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
||||
|
||||
// The tokenizer template (kratos.yml session.whoami.tokenizer.templates.plainpages).
|
||||
const TOKENIZE_AS = "plainpages";
|
||||
|
||||
export interface LoginDeps {
|
||||
keto: KetoClient;
|
||||
kratosAdmin: KratosAdmin;
|
||||
kratosPublic: KratosPublic;
|
||||
}
|
||||
|
||||
export interface CompletedLogin {
|
||||
email: string | null;
|
||||
identityId: string;
|
||||
jwt: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
// The coarse roles Keto grants a subject directly: `Role:<name>#members@user:<id>`. Returns
|
||||
// the de-duped, sorted role names (the tuple `object`). One logical read, paged defensively.
|
||||
// Group→role inheritance lands with the Groups screen (§5); MVP grants are direct.
|
||||
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
||||
const subject_id = `user:${identityId}`;
|
||||
const roles = new Set<string>();
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const page = await keto.listRelations({ namespace: "Role", relation: "members", subject_id, ...(pageToken ? { pageToken } : {}) });
|
||||
for (const t of page.tuples) roles.add(t.object);
|
||||
pageToken = page.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
return [...roles].sort();
|
||||
}
|
||||
|
||||
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 emailTrait = session.identity.traits?.["email"];
|
||||
const email = typeof emailTrait === "string" ? emailTrait : null;
|
||||
|
||||
const roles = await readRoles(deps.keto, identityId);
|
||||
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles });
|
||||
|
||||
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");
|
||||
|
||||
return { email, identityId, jwt, roles };
|
||||
}
|
||||
|
||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||
// supplied by the caller (off in dev http; the §9 cookie hardening toggles it on for prod).
|
||||
export function sessionCookie(jwt: string, options: { secure?: boolean } = {}): string {
|
||||
const opts: CookieOptions = { httpOnly: true, maxAge: COOKIE_MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) };
|
||||
return serializeCookie(SESSION_COOKIE, jwt, opts);
|
||||
}
|
||||
@@ -2,18 +2,23 @@ import { createApp } from "./app.ts";
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { discoverPlugins } from "./discovery.ts";
|
||||
import { runBootHooks } from "./hooks.ts";
|
||||
import { createKetoClient } from "./keto-client.ts";
|
||||
import { createKratosAdmin } from "./kratos-admin.ts";
|
||||
import { createKratosPublic } from "./kratos-public.ts";
|
||||
import { loadMenuConfig } from "./menu-config.ts";
|
||||
|
||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||
const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed
|
||||
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl }); // themed self-service routes (§4)
|
||||
// Ory clients for the themed self-service routes + login completion (§4).
|
||||
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl });
|
||||
const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl });
|
||||
const keto = createKetoClient({ readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl });
|
||||
|
||||
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
||||
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
|
||||
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
|
||||
|
||||
const server = createApp({ cache: config.cacheTemplates, kratos, menu, plugins }).listen(config.port, () => {
|
||||
const server = createApp({ cache: config.cacheTemplates, keto, kratos, kratosAdmin, menu, plugins }).listen(config.port, () => {
|
||||
console.log(`Listening on http://localhost:${config.port}`);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user