Refuse an emailless identity where the session is minted, and rows the upstream should not have sent
CI / full-gate (push) Successful in 2m53s
CI / full-gate (push) Successful in 2m53s
This commit is contained in:
+14
-2
@@ -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
@@ -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 } };
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user