Files
plainpages/src/auth/kratos-admin.ts
T
2026-08-03 17:13:20 +02:00

119 lines
5.4 KiB
TypeScript

// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts";
export interface Identity {
id: string;
metadata_admin?: unknown;
metadata_public?: unknown;
schema_id?: string;
state?: string;
traits?: Record<string, unknown>;
}
export interface IdentityList {
identities: Identity[];
nextPageToken: string | null; // keyset cursor for the next page; null on the last page
}
export interface ListOptions {
credentialsIdentifier?: string; // exact-match filter on a login identifier (e.g. email)
ids?: string[];
pageSize?: number;
pageToken?: string;
}
// A one-time recovery code + the self-service link wrapping it (admin "trigger recovery").
export interface RecoveryCode {
code: string;
link: string;
}
export interface KratosAdmin {
createIdentity(payload: unknown): Promise<Identity>;
createRecoveryCode(userId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
deleteIdentity(id: string): Promise<void>;
getIdentity(id: string): Promise<Identity | null>;
listIdentities(opts?: ListOptions): Promise<IdentityList>;
updateIdentity(id: string, payload: 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
// relative path, so resolve it against a throwaway base just to read the query param).
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://kratos").searchParams.get("page_token") : null;
}
export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin {
const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
const json = { "content-type": "application/json" };
const identity = (id: string) => `${base}/admin/identities/${encodeURIComponent(id)}`;
async function fail(action: string, res: Response): Promise<never> {
throw new KratosError(`Kratos admin ${action} failed (${res.status})`, res.status, await res.text());
}
return {
async createIdentity(payload) {
const res = await http(`${base}/admin/identities`, { body: JSON.stringify(payload), headers: json, method: "POST" });
if (res.status !== 201) return fail("create identity", res);
return (await res.json()) as Identity;
},
// 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(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);
const data = (await res.json()) as { recovery_code: string; recovery_link: string };
return { code: data.recovery_code, link: data.recovery_link };
},
async deleteIdentity(id) {
const res = await http(identity(id), { method: "DELETE" });
if (res.status !== 204) await fail("delete identity", res);
},
async getIdentity(id) {
const res = await http(identity(id));
if (res.status === 404) return null;
if (res.status !== 200) return fail("get identity", res);
return (await res.json()) as Identity;
},
async listIdentities(opts = {}) {
const url = new URL(`${base}/admin/identities`);
if (opts.credentialsIdentifier) url.searchParams.set("credentials_identifier", opts.credentialsIdentifier);
for (const id of opts.ids ?? []) url.searchParams.append("ids", id);
if (opts.pageSize !== undefined) url.searchParams.set("page_size", String(opts.pageSize));
if (opts.pageToken) url.searchParams.set("page_token", opts.pageToken);
const res = await http(url);
if (res.status !== 200) return fail("list identities", res);
return { identities: (await res.json()) as Identity[], nextPageToken: nextPageToken(res.headers.get("link")) };
},
async updateIdentity(id, payload) {
const res = await http(identity(id), { body: JSON.stringify(payload), headers: json, method: "PUT" });
if (res.status !== 200) return fail("update identity", res);
return (await res.json()) as Identity;
},
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
// touches nothing else — so the login permission projection never clobbers traits/state.
// (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_public", res);
return (await res.json()) as Identity;
},
};
}