Refuse an emailless identity where the session is minted, and rows the upstream should not have sent
CI / full-gate (push) Successful in 2m53s

This commit is contained in:
2026-09-02 18:24:56 +02:00
parent 390ac5f112
commit 18dc4f3136
14 changed files with 59 additions and 33 deletions
+14 -2
View File
@@ -92,12 +92,24 @@ test("completeLogin returns null and touches nothing when there is no active ses
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: {} };
test("completeLogin throws if the tokenizer yields no JWT", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
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/);
});
// An identity with no email is no session, decided here so /auth/complete and remintSession cannot
// disagree: `claimsToUser` reads a token carrying none as anonymous, so minting one would hand the
// browser a cookie every later request refuses.
test("completeLogin refuses an identity carrying no email, before it mints anything", async () => {
const identity: Identity = { id: ID, traits: {} };
let touched = false;
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity, tokenized: "h.p.s" }) as Session });
assert.equal(await completeLogin({ keto: ketoStub(), kratosAdmin, kratosPublic }, "c"), null);
assert.equal(touched, false); // no Keto read, no metadata write, no JWT
});
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
+9 -8
View File
@@ -31,7 +31,7 @@ export interface LoginDeps {
}
export interface CompletedLogin {
email: string | null;
email: string;
userId: string;
jwt: string;
permissions: string[];
@@ -61,7 +61,13 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
if (!session?.identity) return null;
const userId = session.identity.id;
const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : null;
const email = typeof emailTrait === "string" ? emailTrait : "";
// No email is no session: `claimsToUser` reads a token carrying none as anonymous, so minting one
// would hand the browser a cookie every later request refuses.
if (!email) {
currentLog()?.warn("session dropped: identity has no email", { sub: userId });
return null;
}
const permissions = await readPermissions(deps.keto, userId);
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
@@ -86,12 +92,7 @@ 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);
// No email is no session, exactly as `claimsToUser` reads a token carrying none: a User with an
// empty email reads as anonymous in the shell, and is a blank key to whatever scopes on it.
if (!completed?.email) {
if (completed) currentLog()?.warn("session dropped: identity has no email", { sub: completed.userId });
return { setCookie: clearSessionCookie(options), user: null };
}
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } };
}
+6 -1
View File
@@ -4,6 +4,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { AUTH_FLOWS } from "./flow-view.ts";
import { gatesSet } from "./gate.ts";
import type { HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
@@ -39,8 +40,12 @@ test("hydra alone ⇒ only RP-initiated logout of the OAuth2 group (login/consen
});
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
const got = keys(buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin })));
const routes = buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin }));
const got = keys(routes);
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
assert.ok(got.includes(key), key);
}
// Discovery enforces exactly one gate per plugin declaration; nothing checks the host's own table
// at boot, so a route added here without a gate would be silently public.
for (const route of routes) assert.deepEqual(gatesSet(route), ["public"], `${route.method} ${route.path}`);
});
-2
View File
@@ -20,8 +20,6 @@ export interface RequestCsrf {
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
export type PluginContextFactory = (pluginId: string) => RequestContext;
// `Gate` carries `permission`/`public`/`session`, checked before the handler runs — the same rule
// the plugin router and the menu read.
export interface BuiltinRoute extends Gate {
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
// (the landing slots dispatch a plugin's own result against that plugin's views).
-1
View File
@@ -25,7 +25,6 @@ export type RouteResult =
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
// `Gate` carries `permission`/`public`/`session`, checked before the handler runs.
export interface Route extends Gate {
handler: RouteHandler;
method: HttpMethod;
-1
View File
@@ -8,7 +8,6 @@ import type { User } from "../http/context.ts";
import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
// `Gate` carries `permission`/`public`/`session` — consumed by the filter, never rendered.
export interface NavNode extends Gate {
id?: string; // stable key for override targeting; stripped from the rendered tree
children?: NavNode[];