Built-in OAuth2 client-registration admin screen (todo §6); /admin/clients lists/registers/deletes the Hydra OAuth2 clients other apps log in through us with. New src/admin-clients.ts (pure builders + handleAdminClients, mirroring the §5 Users/Roles screens): list (search/paginate over one fetched Hydra page), register (GET form + POST), read-only detail, delete-confirm. src/hydra-admin.ts gains the client half of the admin API — createClient/listClients/getClient/deleteClient over /admin/clients (+ a nextPageToken Link parser like kratos-admin) and the registration fields on OAuth2Client. Register builds a standard authorization-code client (+ refresh_token), confidential (client_secret_basic) or public (PKCE/none), with an optional first-party auto-consent flag; Hydra returns the client_secret once, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) and it is never re-shown (getClient carries no secret; the detail test asserts it). Writes go only to Hydra; gated admin-only (anon->/login, non-admin->403) + every mutation CSRF-guarded via requireAdmin/guardedForm like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx -> 500 (mirrors oauth-login.ts); :id via safeDecode (malformed->404). Wired into app.ts (/admin/clients, gated on the hydra client present) and the shared adminSection (Users.Groups.Roles.OAuth2 clients, i-globe) so it shows for admins and is invisible otherwise. New views (admin/clients, client-form, client-detail + partials/client-{form,detail}-body) reuse the shell/filter-bar/data-table/field blocks; one .detail-list CSS rule; README Layout/§6 updated. Tests-first: hydra-admin.test.ts (client CRUD contracts incl. Link pagination/404->null/204), admin-clients.test.ts (builder/validation/payload matrix), app.test.ts HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-%->404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead URL.protocol check in validateClientInput). Boot-verified the client CRUD live against real Hydra v26.2.0 (create->201 w/ one-time secret -> list finds it -> get -> delete -> get null); torn down. typecheck + 274 units green.
This commit is contained in:
105
src/admin-clients.test.ts
Normal file
105
src/admin-clients.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Built-in OAuth2 clients admin screen (§6): the pure view-model + Hydra-payload builders. A client
|
||||
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
|
||||
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
buildClientDetailModel,
|
||||
buildClientFormModel,
|
||||
buildClientsListModel,
|
||||
type ClientInput,
|
||||
clientPayload,
|
||||
parseRedirectUris,
|
||||
toClientView,
|
||||
validateClientInput,
|
||||
} from "./admin-clients.ts";
|
||||
|
||||
const input = (over: Partial<ClientInput> = {}): ClientInput =>
|
||||
({ firstParty: false, name: "Acme", public: false, redirectUris: ["https://acme.example/cb"], scope: "openid offline_access", ...over });
|
||||
|
||||
test("toClientView maps Hydra fields → view (public from auth method, first-party from metadata, scopes split)", () => {
|
||||
assert.deepEqual(
|
||||
toClientView({ client_id: "c1", client_name: "Acme", metadata: { first_party: true }, redirect_uris: ["https://a/cb"], scope: "openid email", token_endpoint_auth_method: "client_secret_basic" }),
|
||||
{ firstParty: true, id: "c1", name: "Acme", public: false, redirectUris: ["https://a/cb"], scopes: ["openid", "email"] },
|
||||
);
|
||||
// A public client + no name → falls back to the id; absent metadata/scope tolerated.
|
||||
assert.deepEqual(
|
||||
toClientView({ client_id: "c2", token_endpoint_auth_method: "none" }),
|
||||
{ firstParty: false, id: "c2", name: "c2", public: true, redirectUris: [], scopes: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("parseRedirectUris splits on newlines/whitespace/commas and drops empties", () => {
|
||||
assert.deepEqual(parseRedirectUris("https://a/cb\n https://b/cb \n\n, https://c/cb"), ["https://a/cb", "https://b/cb", "https://c/cb"]);
|
||||
assert.deepEqual(parseRedirectUris(" "), []);
|
||||
});
|
||||
|
||||
test("clientPayload maps the input to Hydra's create body (auth-code + refresh, type via auth method)", () => {
|
||||
assert.deepEqual(clientPayload(input()), {
|
||||
client_name: "Acme",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
metadata: { first_party: false },
|
||||
redirect_uris: ["https://acme.example/cb"],
|
||||
response_types: ["code"],
|
||||
scope: "openid offline_access",
|
||||
token_endpoint_auth_method: "client_secret_basic",
|
||||
});
|
||||
const pub = clientPayload(input({ firstParty: true, public: true }));
|
||||
assert.equal(pub.token_endpoint_auth_method, "none");
|
||||
assert.deepEqual(pub.metadata, { first_party: true });
|
||||
});
|
||||
|
||||
test("validateClientInput requires a name, ≥1 redirect URI, and absolute redirect URLs", () => {
|
||||
assert.equal(validateClientInput(input()), null);
|
||||
assert.match(validateClientInput(input({ name: "" }))!, /name/i);
|
||||
assert.match(validateClientInput(input({ redirectUris: [] }))!, /redirect/i);
|
||||
assert.match(validateClientInput(input({ redirectUris: ["not a url"] }))!, /redirect URI/i);
|
||||
});
|
||||
|
||||
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
|
||||
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
|
||||
|
||||
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.shell.title, "OAuth2 clients");
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "app-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/clients/id-00");
|
||||
|
||||
const one = buildClientsListModel({ clients, url: "http://x/admin/clients?q=app-07" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
});
|
||||
|
||||
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
|
||||
const m = buildClientFormModel({ csrfToken: "tok.sig" });
|
||||
assert.equal(m.shell.title, "Register client");
|
||||
assert.equal(m.form.action, "/admin/clients");
|
||||
assert.equal(m.form.submitLabel, "Register client");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
assert.equal(m.form.nameField.required, true);
|
||||
assert.equal(m.form.scopeField.value, "openid offline_access"); // sensible default
|
||||
|
||||
const err = buildClientFormModel({ error: "Add at least one redirect URI.", values: { firstParty: true, name: "Acme", public: true, redirectUris: ["https://a/cb"], scope: "openid" } });
|
||||
assert.equal(err.error, "Add at least one redirect URI.");
|
||||
assert.equal(err.form.nameField.value, "Acme");
|
||||
assert.equal(err.form.redirectUris, "https://a/cb");
|
||||
assert.equal(err.form.public, true);
|
||||
assert.equal(err.form.firstParty, true);
|
||||
});
|
||||
|
||||
test("buildClientDetailModel: client info + delete action; the one-time secret + created banner show only right after create", () => {
|
||||
const client = toClientView({ client_id: "c1", client_name: "Acme", redirect_uris: ["https://a/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" });
|
||||
|
||||
const plain = buildClientDetailModel({ client });
|
||||
assert.equal(plain.shell.title, "Acme");
|
||||
assert.equal(plain.delete.action, "/admin/clients/c1/delete");
|
||||
assert.equal(plain.created, false);
|
||||
assert.equal(plain.secret, undefined);
|
||||
|
||||
const fresh = buildClientDetailModel({ client, created: true, secret: "s3cr3t" });
|
||||
assert.equal(fresh.created, true);
|
||||
assert.equal(fresh.secret, "s3cr3t");
|
||||
assert.equal(fresh.shell.title, "Client registered");
|
||||
});
|
||||
345
src/admin-clients.ts
Normal file
345
src/admin-clients.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
// Built-in OAuth2 clients admin screen (todo §6): register / list / delete the OAuth2 clients other
|
||||
// apps log in *through* us with (Ory Hydra, the §6 login+consent handlers). A client is an Ory Hydra
|
||||
// OAuth2 client; writes go only to Hydra. Hydra returns the client_secret once, on create — so the
|
||||
// register POST renders the new client's detail page (with the one-time secret) directly instead of a
|
||||
// PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the
|
||||
// imperative shell app.ts dispatches to — gated admin-only, CSRF-guarded.
|
||||
|
||||
import { ADMIN_CLIENTS_BASE, adminNav, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
|
||||
import { safeDecode } from "./admin-groups.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
import type { RequestContext, User } from "./context.ts";
|
||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
|
||||
import { parseListQuery } from "./list-query.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import { paginate } from "./paginate.ts";
|
||||
import type { RouteResult } from "./plugin.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
// One Hydra page is fetched and filtered/paged in memory — its list API has no search. Ample for an
|
||||
// admin tool (the OAuth2 clients of a deployment number in the dozens); raise if one outgrows it.
|
||||
const LIST_FETCH_SIZE = 250;
|
||||
const DEFAULT_SCOPE = "openid offline_access";
|
||||
|
||||
export interface ClientView {
|
||||
firstParty: boolean;
|
||||
id: string; // client_id
|
||||
name: string;
|
||||
public: boolean; // public (PKCE, no secret) vs confidential
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export interface ClientInput {
|
||||
firstParty: boolean;
|
||||
name: string;
|
||||
public: boolean;
|
||||
redirectUris: string[];
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export function toClientView(client: OAuth2Client): ClientView {
|
||||
const id = client.client_id ?? "";
|
||||
return {
|
||||
firstParty: (client.metadata as { first_party?: unknown } | undefined)?.first_party === true,
|
||||
id,
|
||||
name: client.client_name?.trim() || id || "(unnamed)",
|
||||
public: client.token_endpoint_auth_method === "none",
|
||||
redirectUris: client.redirect_uris ?? [],
|
||||
scopes: (client.scope ?? "").split(/\s+/).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
// Split a textarea value into redirect URIs (one per line / whitespace / comma), dropping empties.
|
||||
export function parseRedirectUris(raw: string): string[] {
|
||||
return raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Hydra's create body. We register a standard authorization-code web/native client (+ refresh);
|
||||
// the type (confidential vs public/PKCE) and auto-consent ride the auth method + metadata.
|
||||
export function clientPayload(input: ClientInput): Record<string, unknown> {
|
||||
return {
|
||||
client_name: input.name,
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
metadata: { first_party: input.firstParty },
|
||||
redirect_uris: input.redirectUris,
|
||||
response_types: ["code"],
|
||||
scope: input.scope,
|
||||
token_endpoint_auth_method: input.public ? "none" : "client_secret_basic",
|
||||
};
|
||||
}
|
||||
|
||||
export function validateClientInput(input: ClientInput): string | null {
|
||||
if (!input.name) return "Enter a name for the client.";
|
||||
if (!input.redirectUris.length) return "Add at least one redirect URI.";
|
||||
for (const uri of input.redirectUris) {
|
||||
try {
|
||||
new URL(uri); // must be an absolute URL — any scheme (public/native clients use custom ones)
|
||||
} catch {
|
||||
return `"${uri}" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- list view model ----
|
||||
|
||||
interface ListState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
q: string;
|
||||
}
|
||||
|
||||
function detailHref(id: string): string {
|
||||
return `${ADMIN_CLIENTS_BASE}/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
||||
const s = { ...state, ...overrides };
|
||||
const p = new URLSearchParams();
|
||||
if (s.q) p.set("q", s.q);
|
||||
if (s.page > 1) p.set("page", String(s.page));
|
||||
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
||||
const qs = p.toString();
|
||||
return qs ? `${ADMIN_CLIENTS_BASE}?${qs}` : ADMIN_CLIENTS_BASE;
|
||||
}
|
||||
|
||||
export function buildClientsListModel(opts: {
|
||||
clients: OAuth2Client[];
|
||||
csrfToken?: string;
|
||||
menu?: MenuConfig;
|
||||
url: URL | URLSearchParams | string;
|
||||
user?: User | null;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
const needle = query.q.toLowerCase();
|
||||
|
||||
const all = opts.clients.map(toClientView);
|
||||
const list = all.filter((c) => !needle || c.name.toLowerCase().includes(needle) || c.id.toLowerCase().includes(needle));
|
||||
|
||||
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
|
||||
const start = (page.page - 1) * page.pageSize;
|
||||
const rows = list.slice(start, start + page.pageSize);
|
||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q };
|
||||
|
||||
return {
|
||||
filterBar: listFilterBar(state),
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
|
||||
pagination: listPagination(state, page),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: "OAuth2 clients",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
table: listTable(rows),
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: ClientView[]) {
|
||||
return {
|
||||
caption: "OAuth2 clients",
|
||||
columns: [{ label: "Name" }, { label: "Client ID" }, { label: "Type" }],
|
||||
rows: rows.map((c) => ({
|
||||
cells: [
|
||||
{ rowHeader: { href: detailHref(c.id), text: c.name } },
|
||||
{ className: "cell-muted", text: c.id },
|
||||
{ badge: { label: c.public ? "Public" : "Confidential", tone: c.public ? "warn" : "info" } },
|
||||
],
|
||||
name: c.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function listFilterBar(state: ListState) {
|
||||
const pills: { label: string; remove: string; value: string }[] = [];
|
||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||
return {
|
||||
applyLabel: "Apply",
|
||||
clearHref: ADMIN_CLIENTS_BASE,
|
||||
label: "Filter clients",
|
||||
pills,
|
||||
rows: [[
|
||||
{ label: "Search clients", name: "q", placeholder: "Search name or client ID…", type: "search", value: state.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
return {
|
||||
label: "Clients pagination",
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- register form + detail view models ----
|
||||
|
||||
export function buildClientFormModel(opts: {
|
||||
csrfToken?: string;
|
||||
error?: string;
|
||||
menu?: MenuConfig;
|
||||
user?: User | null;
|
||||
values?: Partial<ClientInput>;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const v = opts.values;
|
||||
const nameField: FieldConfig = {
|
||||
autocomplete: "off", icon: "i-box", id: "name", label: "Name", name: "name", required: true, value: v?.name ?? "",
|
||||
};
|
||||
const scopeField: FieldConfig = {
|
||||
hint: "Space-separated scopes the client may request.", id: "scope", label: "Scopes", name: "scope",
|
||||
value: v?.scope ?? DEFAULT_SCOPE,
|
||||
};
|
||||
return {
|
||||
error: opts.error,
|
||||
form: {
|
||||
action: ADMIN_CLIENTS_BASE,
|
||||
cancelHref: ADMIN_CLIENTS_BASE,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
firstParty: v?.firstParty ?? false,
|
||||
nameField,
|
||||
public: v?.public ?? false,
|
||||
redirectUris: (v?.redirectUris ?? []).join("\n"),
|
||||
scopeField,
|
||||
submitLabel: "Register client",
|
||||
},
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: "Register client",
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildClientDetailModel(opts: {
|
||||
client: ClientView;
|
||||
created?: boolean; // just registered → success banner + the one-time secret (if any)
|
||||
csrfToken?: string;
|
||||
menu?: MenuConfig;
|
||||
secret?: string; // one-time client_secret (confidential clients), shown once right after create
|
||||
user?: User | null;
|
||||
}) {
|
||||
const menu = opts.menu ?? DEFAULT_MENU;
|
||||
const base = detailHref(opts.client.id);
|
||||
return {
|
||||
client: opts.client,
|
||||
created: opts.created ?? false,
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
delete: { action: `${base}/delete` },
|
||||
nav: adminNav(opts.user?.roles ?? [], menu, "clients"),
|
||||
secret: opts.secret,
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu,
|
||||
title: opts.created ? "Client registered" : opts.client.name,
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
export interface AdminClientsDeps {
|
||||
csrfSecret: string;
|
||||
hydra: HydraAdmin;
|
||||
menu: MenuConfig;
|
||||
render: (view: string, data: Record<string, unknown>) => Promise<string>;
|
||||
}
|
||||
|
||||
function readClientInput(form: URLSearchParams): ClientInput {
|
||||
return {
|
||||
firstParty: form.get("firstParty") === "on",
|
||||
name: (form.get("name") ?? "").trim(),
|
||||
public: form.get("public") === "on",
|
||||
redirectUris: parseRedirectUris(form.get("redirectUris") ?? ""),
|
||||
scope: (form.get("scope") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleAdminClients(ctx: RequestContext, csrfToken: string, deps: AdminClientsDeps): Promise<RouteResult | null> {
|
||||
const path = ctx.url.pathname;
|
||||
if (path !== ADMIN_CLIENTS_BASE && !path.startsWith(`${ADMIN_CLIENTS_BASE}/`)) return null;
|
||||
|
||||
const user = requireAdmin(ctx); // signed-in admin only (else GuardError → /login or 403)
|
||||
const { hydra, menu, render } = deps;
|
||||
const method = (ctx.req.method ?? "GET").toUpperCase();
|
||||
const seg = path.slice(ADMIN_CLIENTS_BASE.length).split("/").filter(Boolean);
|
||||
const form = await guardedForm(ctx, deps.csrfSecret); // parsed + CSRF-verified on POST, else undefined
|
||||
|
||||
const renderForm = async (extra: { error?: string; values?: Partial<ClientInput> }): Promise<RouteResult> =>
|
||||
({ html: await render("admin/client-form", { model: buildClientFormModel({ csrfToken, menu, user, ...extra }) }) });
|
||||
const renderDetail = async (client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): Promise<RouteResult> =>
|
||||
({ html: await render("admin/client-detail", { model: buildClientDetailModel({ client: toClientView(client), csrfToken, menu, user, ...extra }) }) });
|
||||
const notFound = async (): Promise<RouteResult> => ({ html: await render("404", { title: "Not found" }), status: 404 });
|
||||
|
||||
// /admin/clients — list (GET) · register (POST)
|
||||
if (seg.length === 0) {
|
||||
if (method === "GET") {
|
||||
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
||||
return { html: await render("admin/clients", { model: buildClientsListModel({ clients, csrfToken, menu, url: ctx.url, user }) }) };
|
||||
}
|
||||
if (method === "POST") {
|
||||
const input = readClientInput(form!);
|
||||
const error = validateClientInput(input);
|
||||
if (error) return { ...(await renderForm({ error, values: input })), status: 400 };
|
||||
let created: OAuth2Client;
|
||||
try {
|
||||
created = await hydra.createClient(clientPayload(input));
|
||||
} catch (err) {
|
||||
// A Hydra 4xx (bad redirect/scope it rejects) is the operator's input — re-render the form;
|
||||
// a 5xx (Hydra down) rethrows → 500. Mirrors the §6 challenge-handler degrade.
|
||||
if (err instanceof HydraError && err.status < 500) {
|
||||
return { ...(await renderForm({ error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input })), status: 400 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Show the one-time secret now (Hydra never returns it again) — render the detail directly.
|
||||
return renderDetail(created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// /admin/clients/new — register form
|
||||
if (seg.length === 1 && seg[0] === "new" && method === "GET") return renderForm({});
|
||||
|
||||
// /admin/clients/:id …
|
||||
const id = safeDecode(seg[0]!);
|
||||
if (id === null) return notFound();
|
||||
const client = await hydra.getClient(id);
|
||||
if (!client) return notFound();
|
||||
const base = detailHref(id);
|
||||
|
||||
if (seg.length === 1 && method === "GET") return renderDetail(client);
|
||||
|
||||
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
|
||||
const name = toClientView(client).name;
|
||||
return { html: await render("admin/confirm", { model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client", csrfToken,
|
||||
current: "clients", menu, message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client", user,
|
||||
}) }) };
|
||||
}
|
||||
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
|
||||
await hydra.deleteClient(id);
|
||||
return { redirect: ADMIN_CLIENTS_BASE };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -16,13 +16,15 @@ export const ADMIN_PERMISSION = "admin"; // role token gating the admin section
|
||||
export const ADMIN_USERS_BASE = "/admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||
export const ADMIN_ROLES_BASE = "/admin/roles";
|
||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
|
||||
export type AdminScreen = "groups" | "roles" | "users";
|
||||
export type AdminScreen = "clients" | "groups" | "roles" | "users";
|
||||
|
||||
const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [
|
||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
||||
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
|
||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
|
||||
];
|
||||
|
||||
// The gated "Admin" header + its three screens; `current` marks the active screen and opens the
|
||||
|
||||
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { createApp, type AppOptions } from "./app.ts";
|
||||
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
|
||||
import { staticJwks } from "./jwks.ts";
|
||||
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts";
|
||||
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
||||
@@ -498,8 +498,12 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear
|
||||
const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
|
||||
acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }),
|
||||
acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }),
|
||||
createClient: async (c) => ({ ...c, client_id: "c1", client_secret: "s3cr3t" }),
|
||||
deleteClient: async () => {},
|
||||
getClient: async () => null,
|
||||
getConsentRequest: async () => ({ challenge: "cons1", client: { client_name: "Acme Reports" }, requested_scope: ["openid", "profile"], skip: false, subject: OAUTH_SUBJECT }),
|
||||
getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }),
|
||||
listClients: async () => ({ clients: [], nextPageToken: null }),
|
||||
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
||||
rejectLoginRequest: async () => { throw new Error("unused"); },
|
||||
...over,
|
||||
@@ -839,6 +843,63 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
assert.equal((await get("/admin/roles/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
// Built-in OAuth2 clients admin screen (§6): gate + list/register/detail/delete over HTTP against an
|
||||
// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG).
|
||||
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
|
||||
const store: OAuth2Client[] = [
|
||||
{ client_id: "existing", client_name: "Reporting", redirect_uris: ["https://reporting.example/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" },
|
||||
];
|
||||
let seq = 0;
|
||||
const hydra = stubHydra({
|
||||
createClient: async (c) => { const created = { ...c, client_id: `gen-${++seq}`, client_secret: `secret-${seq}` }; store.push(created); return created; },
|
||||
deleteClient: async (id) => { const i = store.findIndex((c) => c.client_id === id); if (i >= 0) store.splice(i, 1); },
|
||||
getClient: async (id) => store.find((c) => c.client_id === id) ?? null,
|
||||
listClients: async () => ({ clients: store, nextPageToken: null }),
|
||||
});
|
||||
const { get, post, token, url } = await adminHarness(t, { hydra });
|
||||
|
||||
await assertAdminGate(url, get, "/admin/clients");
|
||||
|
||||
// List: the existing client shows + the "register" link.
|
||||
const listHtml = await (await get("/admin/clients")).text();
|
||||
assert.match(listHtml, /href="\/admin\/clients\/existing"/);
|
||||
assert.match(listHtml, /href="\/admin\/clients\/new"/);
|
||||
assert.match(listHtml, /Reporting/);
|
||||
|
||||
// Register: the form renders; a valid post creates the client and shows the one-time secret + id.
|
||||
assert.match(await (await get("/admin/clients/new")).text(), /Register client/);
|
||||
const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
|
||||
assert.equal(created.status, 200); // not a redirect — the secret is shown once
|
||||
const createdHtml = await created.text();
|
||||
assert.match(createdHtml, /Client registered/);
|
||||
assert.match(createdHtml, /secret-1/); // the one-time client_secret
|
||||
assert.match(createdHtml, /gen-1/); // the generated client_id
|
||||
assert.ok(store.some((c) => c.client_name === "Grafana" && c.token_endpoint_auth_method === "client_secret_basic"));
|
||||
|
||||
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
|
||||
const before = store.length;
|
||||
assert.equal((await post("/admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
|
||||
assert.equal((await post("/admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
|
||||
assert.equal(store.length, before);
|
||||
|
||||
// Detail: read-only info, never the secret again, a delete control.
|
||||
const detail = await (await get("/admin/clients/existing")).text();
|
||||
assert.match(detail, /reporting\.example\/cb/);
|
||||
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
|
||||
assert.match(detail, /href="\/admin\/clients\/existing\/delete"/);
|
||||
|
||||
// Delete: a confirm step (GET) then the POST removes the client, back to the list.
|
||||
assert.match(await (await get("/admin/clients/existing/delete")).text(), /Cancel/);
|
||||
const del = await post("/admin/clients/existing/delete", `_csrf=${token}`);
|
||||
assert.equal(del.status, 303);
|
||||
assert.equal(del.headers.get("location"), "/admin/clients");
|
||||
assert.ok(!store.some((c) => c.client_id === "existing"));
|
||||
|
||||
// Unknown id → 404; malformed %-encoding doesn't 500.
|
||||
assert.equal((await get("/admin/clients/does-not-exist")).status, 404);
|
||||
assert.equal((await get("/admin/clients/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
13
src/app.ts
13
src/app.ts
@@ -3,7 +3,8 @@ import { createServer, type Server, type ServerResponse } from "node:http";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import { ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts";
|
||||
import { type AdminClientsDeps, handleAdminClients } from "./admin-clients.ts";
|
||||
import { type AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts";
|
||||
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts";
|
||||
import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts";
|
||||
@@ -86,6 +87,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render } : null;
|
||||
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
|
||||
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
|
||||
// OAuth2 clients (§6) write to Hydra; wired only when the Hydra admin client is present.
|
||||
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;
|
||||
|
||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||
@@ -181,6 +184,14 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (adminClientsDeps && pathname.startsWith(ADMIN_CLIENTS_BASE)) {
|
||||
const result = await handleAdminClients(ctx, csrf.token, adminClientsDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
|
||||
const flowType = AUTH_FLOWS[pathname];
|
||||
|
||||
@@ -73,11 +73,11 @@ test("dashboard applies the central menu config: branding + nav override (rename
|
||||
});
|
||||
|
||||
test("dashboard menu wires in the permission-gated Admin section (only for admins)", () => {
|
||||
// An admin sees the Admin section with the three built-in screens.
|
||||
// An admin sees the Admin section with the four built-in screens.
|
||||
const admin = buildDashboardModel(new URL("http://x/"), ["admin"]);
|
||||
const adminNode = admin.nav.find((n) => n.label === "Admin");
|
||||
assert.ok(adminNode, "admin role → Admin section present");
|
||||
assert.deepEqual(adminNode!.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles"]);
|
||||
assert.deepEqual(adminNode!.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
|
||||
|
||||
// A non-admin (default []) never sees it — composeNav drops the gated header + its subtree.
|
||||
const plain = buildDashboardModel(new URL("http://x/"));
|
||||
|
||||
@@ -84,3 +84,39 @@ test("a non-2xx response throws a HydraError carrying the status", async () => {
|
||||
(e: unknown) => e instanceof HydraError && e.status === 404,
|
||||
);
|
||||
});
|
||||
|
||||
// OAuth2 client registration (§6): create/list/get/delete clients over Hydra's admin API.
|
||||
test("createClient POSTs the client and returns it (incl. the one-time client_secret)", async () => {
|
||||
const created = { client_id: "c1", client_name: "Acme", client_secret: "s3cr3t", redirect_uris: ["https://acme/cb"] };
|
||||
const { calls, fetchImpl } = recorder(() => res(201, created));
|
||||
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).createClient({ client_name: "Acme", redirect_uris: ["https://acme/cb"] });
|
||||
assert.deepEqual(out, created);
|
||||
assert.equal(calls[0]!.method, "POST");
|
||||
assert.match(calls[0]!.url, /\/admin\/clients$/);
|
||||
assert.deepEqual(JSON.parse(calls[0]!.body!), { client_name: "Acme", redirect_uris: ["https://acme/cb"] });
|
||||
});
|
||||
|
||||
test("listClients GETs a page and parses the Link rel=next page_token", async () => {
|
||||
const body = JSON.stringify([{ client_id: "c1" }, { client_id: "c2" }]);
|
||||
const headers = new Headers({ "content-type": "application/json", link: '</admin/clients?page_token=tok2&page_size=2>; rel="next"' });
|
||||
const { calls, fetchImpl } = recorder(() => new Response(body, { headers, status: 200 }));
|
||||
const out = await createHydraAdmin({ baseUrl: BASE, fetchImpl }).listClients({ pageSize: 2 });
|
||||
assert.deepEqual(out.clients.map((c) => c.client_id), ["c1", "c2"]);
|
||||
assert.equal(out.nextPageToken, "tok2");
|
||||
assert.equal(calls[0]!.method, "GET");
|
||||
assert.match(calls[0]!.url, /\/admin\/clients\?page_size=2$/);
|
||||
});
|
||||
|
||||
test("getClient returns the client; a 404 → null", async () => {
|
||||
const found = await createHydraAdmin({ baseUrl: BASE, fetchImpl: recorder(() => res(200, { client_id: "c1" })).fetchImpl }).getClient("c1");
|
||||
assert.deepEqual(found, { client_id: "c1" });
|
||||
const missing = await createHydraAdmin({ baseUrl: BASE, fetchImpl: recorder(() => res(404, { error: "Not Found" })).fetchImpl }).getClient("gone");
|
||||
assert.equal(missing, null);
|
||||
});
|
||||
|
||||
test("deleteClient DELETEs the client by id (204)", async () => {
|
||||
const { calls, fetchImpl } = recorder(() => res(204));
|
||||
await createHydraAdmin({ baseUrl: BASE, fetchImpl }).deleteClient("c1");
|
||||
assert.equal(calls[0]!.method, "DELETE");
|
||||
assert.match(calls[0]!.url, /\/admin\/clients\/c1$/);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,18 @@
|
||||
export interface OAuth2Client {
|
||||
client_id?: string;
|
||||
client_name?: string;
|
||||
client_secret?: string; // write-only: Hydra returns it once, on create, for a confidential client
|
||||
grant_types?: string[];
|
||||
metadata?: Record<string, unknown>; // arbitrary client metadata; `first_party: true` ⇒ auto-consent (§6)
|
||||
redirect_uris?: string[];
|
||||
response_types?: string[];
|
||||
scope?: string; // space-separated
|
||||
token_endpoint_auth_method?: string; // "client_secret_basic" (confidential) | "none" (public/PKCE)
|
||||
}
|
||||
|
||||
export interface ClientList {
|
||||
clients: OAuth2Client[];
|
||||
nextPageToken: string | null; // cursor for the next page; null on the last
|
||||
}
|
||||
|
||||
// A login request Hydra hands us at /oauth2/login. `skip` ⇒ Hydra already authenticated this
|
||||
@@ -79,12 +90,23 @@ export class HydraError extends Error {
|
||||
export interface HydraAdmin {
|
||||
acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>;
|
||||
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
|
||||
createClient(client: OAuth2Client): Promise<OAuth2Client>;
|
||||
deleteClient(id: string): Promise<void>;
|
||||
getClient(id: string): Promise<OAuth2Client | null>;
|
||||
getConsentRequest(challenge: string): Promise<ConsentRequest>;
|
||||
getLoginRequest(challenge: string): Promise<LoginRequest>;
|
||||
listClients(opts?: { pageSize?: number; pageToken?: string }): Promise<ClientList>;
|
||||
rejectConsentRequest(challenge: string, body: RejectRequest): Promise<Completed>;
|
||||
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
|
||||
}
|
||||
|
||||
// Hydra paginates with a Link header; pull the page_token of rel="next" (the href is relative, so
|
||||
// resolve against a throwaway base to read the query param). Mirrors kratos-admin's helper.
|
||||
function nextPageToken(link: string | null): string | null {
|
||||
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
|
||||
return href ? new URL(href, "http://hydra").searchParams.get("page_token") : null;
|
||||
}
|
||||
|
||||
export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin {
|
||||
const base = config.baseUrl.replace(/\/+$/, "");
|
||||
const http = config.fetchImpl ?? fetch;
|
||||
@@ -92,6 +114,8 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f
|
||||
// Hydra keys the login/consent handshake off a ?login_challenge=/?consent_challenge= query.
|
||||
const reqUrl = (kind: "consent" | "login", challenge: string, action = "") =>
|
||||
`${base}/admin/oauth2/auth/requests/${kind}${action}?${kind}_challenge=${encodeURIComponent(challenge)}`;
|
||||
const clientsUrl = `${base}/admin/clients`;
|
||||
const clientUrl = (id: string) => `${clientsUrl}/${encodeURIComponent(id)}`;
|
||||
|
||||
async function fail(action: string, res: Response): Promise<never> {
|
||||
throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text());
|
||||
@@ -113,6 +137,26 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f
|
||||
return put("accept login", reqUrl("login", challenge, "/accept"), body);
|
||||
},
|
||||
|
||||
// OAuth2 client registration (§6, admin screen). Hydra generates the client_id/secret when
|
||||
// omitted; the secret rides the 201 body and is never retrievable afterwards.
|
||||
async createClient(client) {
|
||||
const res = await http(clientsUrl, { body: JSON.stringify(client), headers: json, method: "POST" });
|
||||
if (res.status !== 201) return fail("create client", res);
|
||||
return (await res.json()) as OAuth2Client;
|
||||
},
|
||||
|
||||
async deleteClient(id) {
|
||||
const res = await http(clientUrl(id), { method: "DELETE" });
|
||||
if (res.status !== 204) await fail("delete client", res);
|
||||
},
|
||||
|
||||
async getClient(id) {
|
||||
const res = await http(clientUrl(id));
|
||||
if (res.status === 404) return null;
|
||||
if (res.status !== 200) return fail("get client", res);
|
||||
return (await res.json()) as OAuth2Client;
|
||||
},
|
||||
|
||||
async getConsentRequest(challenge) {
|
||||
const res = await http(reqUrl("consent", challenge));
|
||||
if (res.status !== 200) return fail("get consent request", res);
|
||||
@@ -125,6 +169,15 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f
|
||||
return (await res.json()) as LoginRequest;
|
||||
},
|
||||
|
||||
async listClients(opts = {}) {
|
||||
const url = new URL(clientsUrl);
|
||||
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 clients", res);
|
||||
return { clients: (await res.json()) as OAuth2Client[], nextPageToken: nextPageToken(res.headers.get("link")) };
|
||||
},
|
||||
|
||||
async rejectConsentRequest(challenge, body) {
|
||||
return put("reject consent", reqUrl("consent", challenge, "/reject"), body);
|
||||
},
|
||||
|
||||
@@ -13,13 +13,18 @@ const REDIRECT = "http://hydra/oauth2/auth?consent_verifier=v";
|
||||
const DENIED = "http://client/cb?error=access_denied";
|
||||
|
||||
function stubHydra(consent: ConsentRequest, capture?: (b: AcceptConsent) => void): HydraAdmin {
|
||||
const unused = async () => { throw new Error("unused"); };
|
||||
return {
|
||||
acceptConsentRequest: async (_c, body) => { capture?.(body); return { redirect: REDIRECT }; },
|
||||
acceptLoginRequest: async () => { throw new Error("unused"); },
|
||||
acceptLoginRequest: unused,
|
||||
createClient: unused,
|
||||
deleteClient: unused,
|
||||
getClient: unused,
|
||||
getConsentRequest: async () => consent,
|
||||
getLoginRequest: async () => { throw new Error("unused"); },
|
||||
getLoginRequest: unused,
|
||||
listClients: unused,
|
||||
rejectConsentRequest: async () => ({ redirect: DENIED }),
|
||||
rejectLoginRequest: async () => { throw new Error("unused"); },
|
||||
rejectLoginRequest: unused,
|
||||
};
|
||||
}
|
||||
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({
|
||||
|
||||
@@ -15,8 +15,12 @@ function stubHydra(login: LoginRequest, capture?: (b: AcceptLogin) => void): Hyd
|
||||
return {
|
||||
acceptConsentRequest: unused,
|
||||
acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; },
|
||||
createClient: unused,
|
||||
deleteClient: unused,
|
||||
getClient: unused,
|
||||
getConsentRequest: unused,
|
||||
getLoginRequest: async () => login,
|
||||
listClients: unused,
|
||||
rejectConsentRequest: unused,
|
||||
rejectLoginRequest: unused,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user