Rename the Keto User namespace to Identity, matching Kratos
This commit is contained in:
@@ -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("roleTuple grants a role to user:<id> in the Role namespace", () => {
|
||||
test("roleTuple grants a role to identity:<id> in the Role namespace", () => {
|
||||
const id = randomUUID();
|
||||
assert.deepEqual(roleTuple(id, "admin"), {
|
||||
namespace: "Role",
|
||||
object: "admin",
|
||||
relation: "members",
|
||||
subject_id: `user:${id}`,
|
||||
subject_id: `identity:${id}`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,8 +65,8 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one
|
||||
assert.equal(puts.length, 2); // one grant per role
|
||||
assert.ok(puts.every((p) => p.method === "PUT"));
|
||||
assert.deepEqual(puts.map((p) => p.body), [
|
||||
{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` },
|
||||
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` },
|
||||
{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` },
|
||||
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `identity:${id}` },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
|
||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
|
||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` });
|
||||
});
|
||||
|
||||
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
||||
|
||||
@@ -22,10 +22,10 @@ export function identityPayload(email: string, password: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<kratos-id>`
|
||||
// Coarse-role grant: `Role:<role>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
|
||||
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
|
||||
export function roleTuple(identityId: string, role: string) {
|
||||
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
|
||||
return { namespace: "Role", object: role, relation: "members", subject_id: `identity:${identityId}` };
|
||||
}
|
||||
|
||||
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
|
||||
|
||||
@@ -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 User } from "../http/context.ts";
|
||||
import { buildContext, type RequestContext, type SessionIdentity } from "../http/context.ts";
|
||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
||||
|
||||
function ctxFor(user: User | null, url = "/"): RequestContext {
|
||||
function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext {
|
||||
const req = new IncomingMessage(new Socket());
|
||||
req.url = url;
|
||||
return buildContext(req, new ServerResponse(req), { user });
|
||||
return buildContext(req, new ServerResponse(req), { identity: user });
|
||||
}
|
||||
|
||||
const alice: User = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
|
||||
const alice: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["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: "user:u1" }); // subject is the signed-in user
|
||||
assert.deepEqual(asked, { ...tuple, subject_id: "identity: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
@@ -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, User } from "../http/context.ts";
|
||||
import type { RequestContext, SessionIdentity } 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): User {
|
||||
if (!ctx.user) throw new GuardError(401, "authentication required", loginRedirect(ctx));
|
||||
return ctx.user;
|
||||
export function requireSession(ctx: RequestContext): SessionIdentity {
|
||||
if (!ctx.identity) throw new GuardError(401, "authentication required", loginRedirect(ctx));
|
||||
return ctx.identity;
|
||||
}
|
||||
|
||||
// Coarse role 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.user) return false;
|
||||
return keto.check({ ...tuple, subject_id: `user:${ctx.user.id}` });
|
||||
if (!ctx.identity) return false;
|
||||
return keto.check({ ...tuple, subject_id: `identity:${ctx.identity.id}` });
|
||||
}
|
||||
|
||||
@@ -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, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
|
||||
import { authenticate, claimsToIdentity, 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("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => {
|
||||
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" }).roles, []); // roles absent
|
||||
assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
||||
test("claimsToIdentity requires sub + email, defaults roles to [], keeps only string roles", () => {
|
||||
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" }).roles, []); // roles absent
|
||||
assert.deepEqual(claimsToIdentity({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
||||
});
|
||||
|
||||
test("resolveSession classifies the cookie; authenticate is its fail-closed user projection", async () => {
|
||||
test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => {
|
||||
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
|
||||
const user = { email: "a@b.c", id: "u1", roles: ["admin"] };
|
||||
const identity = { email: "a@b.c", id: "u1", roles: ["admin"] };
|
||||
|
||||
// A valid token → the user, not expired.
|
||||
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
|
||||
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity });
|
||||
// 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, user: null });
|
||||
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, identity: 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, 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 });
|
||||
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 });
|
||||
|
||||
// authenticate() is the convenience wrapper — resolveSession(...).user, dropping the flag.
|
||||
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), user);
|
||||
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), identity);
|
||||
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, user: null });
|
||||
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: 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", roles: ["admin"] });
|
||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
|
||||
|
||||
+10
-10
@@ -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 role gate denies.
|
||||
import type { User } from "../http/context.ts";
|
||||
import type { SessionIdentity } 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); roles defaults to [] and
|
||||
// keeps only string entries (defensive).
|
||||
export function claimsToUser(payload: Record<string, unknown>): User {
|
||||
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
|
||||
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 claimsToUser(payload: Record<string, unknown>): User {
|
||||
|
||||
// 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<User> {
|
||||
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity> {
|
||||
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 = claimsToUser(verified.payload);
|
||||
const user = claimsToIdentity(verified.payload);
|
||||
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
|
||||
// resolveSession routes it through the re-mint (fresh roles 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
|
||||
user: User | null;
|
||||
identity: SessionIdentity | 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, user: null };
|
||||
if (!token) return { expired: false, identity: null };
|
||||
try {
|
||||
return { expired: false, user: await verifyToken(token, jwks, options) };
|
||||
return { expired: false, identity: await verifyToken(token, jwks, options) };
|
||||
} catch (err) {
|
||||
return { expired: err instanceof TokenError && err.expired, user: null };
|
||||
return { expired: err instanceof TokenError && err.expired, identity: 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<User | null> {
|
||||
return (await resolveSession(cookieHeader, jwks, options)).user;
|
||||
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity | null> {
|
||||
return (await resolveSession(cookieHeader, jwks, options)).identity;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createKetoClient, KetoError } from "./keto-client.ts";
|
||||
|
||||
const READ = "http://keto:4466";
|
||||
const WRITE = "http://keto:4467";
|
||||
const USER = "user:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||
const USER = "identity: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: "Role", object: "admin", relation: "members", subject_id: "user:nobody" }), false);
|
||||
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:nobody" }), false);
|
||||
});
|
||||
|
||||
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { KratosPublic, Session } from "./kratos-public.ts";
|
||||
import { completeLogin, readRoles, remintSession, 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 roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `identity:${ID}` });
|
||||
|
||||
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
||||
check: async () => false,
|
||||
@@ -49,11 +49,11 @@ test("readRoles returns roles held directly OR transitively (enumerate defined r
|
||||
// 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: [role("editor", { subject_id: "user:other" })] };
|
||||
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "identity:other" })] };
|
||||
return { nextPageToken: "p2", tuples: [
|
||||
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
||||
role("admin", { subject_id: `user:${ID}` }),
|
||||
role("viewer", { subject_id: "user:stranger" }),
|
||||
role("admin", { subject_id: `identity:${ID}` }),
|
||||
role("viewer", { subject_id: "identity:stranger" }),
|
||||
] };
|
||||
},
|
||||
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
|
||||
@@ -105,12 +105,12 @@ test("remintSession: a live Kratos session → fresh cookie + refreshed user; a
|
||||
|
||||
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
|
||||
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
||||
assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
|
||||
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, roles: ["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.user, null);
|
||||
assert.equal(dead.identity, null);
|
||||
assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/);
|
||||
});
|
||||
|
||||
|
||||
+6
-6
@@ -6,7 +6,7 @@
|
||||
// 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 type { User } from "../http/context.ts";
|
||||
import type { SessionIdentity } from "../http/context.ts";
|
||||
import { serializeCookie, type CookieOptions } from "../http/cookie.ts";
|
||||
import { currentLog } from "../logger.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
@@ -37,13 +37,13 @@ export interface CompletedLogin {
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
// The coarse roles a user holds — directly (`Role:<name>#members@user:<id>`) or transitively via a
|
||||
// The coarse roles a user holds — directly (`Role:<name>#members@identity:<id>`) or transitively via a
|
||||
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
|
||||
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
|
||||
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
|
||||
// request; role count is small, so the per-role checks are cheap and run in parallel.
|
||||
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
||||
const subject_id = `user:${identityId}`;
|
||||
const subject_id = `identity:${identityId}`;
|
||||
const names = new Set<string>();
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
@@ -76,7 +76,7 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
|
||||
|
||||
export interface Reminted {
|
||||
setCookie: string; // a fresh JWT cookie on success, else a cookie that clears the stale one
|
||||
user: User | null;
|
||||
identity: SessionIdentity | 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), user: null };
|
||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
|
||||
if (!completed) return { setCookie: clearSessionCookie(options), identity: null };
|
||||
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
|
||||
}
|
||||
|
||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||
|
||||
+3
-3
@@ -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.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
|
||||
if (ctx.identity && (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.user
|
||||
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.identity
|
||||
// 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.user?.id ?? "" });
|
||||
ctx.log.info("logout", { sub: ctx.identity?.id ?? "" });
|
||||
return { redirect: flow?.logoutUrl ?? "/login" };
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user