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:
12
README.md
12
README.md
@@ -504,6 +504,11 @@ challenge** is wired too (`src/oauth-consent.ts` at `/oauth2/consent`): a first-
|
|||||||
requested scopes; any other client gets a themed consent screen whose CSRF-guarded Allow/Deny
|
requested scopes; any other client gets a themed consent screen whose CSRF-guarded Allow/Deny
|
||||||
accepts or rejects. id_token claims (email, name) come from the Kratos identity.
|
accepts or rejects. id_token claims (email, name) come from the Kratos identity.
|
||||||
|
|
||||||
|
Those clients are registered from the admin **OAuth2 clients** screen (`/admin/clients`,
|
||||||
|
`src/admin-clients.ts`): register (Hydra shows the generated `client_secret` **once**, on the
|
||||||
|
confirmation page — confidential clients), list, and delete. Confidential vs public (PKCE) and the
|
||||||
|
first-party auto-consent flag are set at registration; writes go only to Hydra.
|
||||||
|
|
||||||
## Stateless — no application database
|
## Stateless — no application database
|
||||||
|
|
||||||
Plainpages and its plugins hold **no state of their own**. The only database in the
|
Plainpages and its plugins hold **no state of their own**. The only database in the
|
||||||
@@ -545,7 +550,7 @@ src/jwks.ts JwksProvider — resolve the verify key by kid; createJwksP
|
|||||||
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4)
|
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4)
|
||||||
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4)
|
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4)
|
||||||
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
|
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
|
||||||
src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject (§6)
|
src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD (§6)
|
||||||
src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6)
|
src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6)
|
||||||
src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6)
|
src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6)
|
||||||
src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4)
|
src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4)
|
||||||
@@ -561,7 +566,8 @@ src/dashboard.ts buildDashboardModel(): the home "/" People list view model
|
|||||||
src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
|
src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
|
||||||
src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
|
src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
|
||||||
src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded
|
src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded
|
||||||
src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift
|
src/admin-clients.ts Built-in OAuth2 clients admin screen (§6): list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded
|
||||||
|
src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift
|
||||||
src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile)
|
src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile)
|
||||||
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
|
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
|
||||||
src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
||||||
@@ -572,7 +578,7 @@ src/discovery.ts discoverPlugins(): scan plugins/, import + validate each pl
|
|||||||
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
||||||
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
|
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
|
||||||
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
|
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
|
||||||
views/ Core EJS templates: index (app-shell dashboard), admin/ (Users/Groups/Roles lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500, partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite)
|
views/ Core EJS templates: index (app-shell dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500, partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite)
|
||||||
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
||||||
config/menu.ts Central menu override + branding (optional; defaults apply if absent)
|
config/menu.ts Central menu override + branding (optional; defaults apply if absent)
|
||||||
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
||||||
|
|||||||
@@ -678,6 +678,9 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
.admin-actions { flex-flow: row wrap; gap: 10px; align-items: center; }
|
.admin-actions { flex-flow: row wrap; gap: 10px; align-items: center; }
|
||||||
.admin-actions form { margin: 0; }
|
.admin-actions form { margin: 0; }
|
||||||
.plain-list { display: flex; flex-direction: column; gap: 6px; }
|
.plain-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.detail-list { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; margin: 0; font-size: var(--fz-sm); }
|
||||||
|
.detail-list dt { color: var(--text-faint); }
|
||||||
|
.detail-list dd { margin: 0; word-break: break-word; }
|
||||||
.btn-danger { color: var(--neg); border-color: var(--neg-bd); }
|
.btn-danger { color: var(--neg); border-color: var(--neg-bd); }
|
||||||
.btn-danger:hover { background: var(--neg-bg); }
|
.btn-danger:hover { background: var(--neg-bg); }
|
||||||
.recovery-link { word-break: break-all; }
|
.recovery-link { word-break: break-all; }
|
||||||
|
|||||||
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_USERS_BASE = "/admin/users";
|
||||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||||
export const ADMIN_ROLES_BASE = "/admin/roles";
|
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 }[] = [
|
const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [
|
||||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
||||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
||||||
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
|
{ 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
|
// 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 { createApp, type AppOptions } from "./app.ts";
|
||||||
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
||||||
import { can, check, GuardError, requireSession } from "./guards.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 { staticJwks } from "./jwks.ts";
|
||||||
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts";
|
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts";
|
||||||
import type { Identity, KratosAdmin } from "./kratos-admin.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 => ({
|
const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
|
||||||
acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }),
|
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" }),
|
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 }),
|
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: "" }),
|
getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }),
|
||||||
|
listClients: async () => ({ clients: [], nextPageToken: null }),
|
||||||
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
||||||
rejectLoginRequest: async () => { throw new Error("unused"); },
|
rejectLoginRequest: async () => { throw new Error("unused"); },
|
||||||
...over,
|
...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);
|
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", () => {
|
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
||||||
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
||||||
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), 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 { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
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 AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts";
|
||||||
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts";
|
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts";
|
||||||
import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.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 adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render } : null;
|
||||||
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, 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;
|
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 => {
|
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||||
@@ -181,6 +184,14 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return;
|
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).
|
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
|
||||||
const flowType = AUTH_FLOWS[pathname];
|
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)", () => {
|
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 admin = buildDashboardModel(new URL("http://x/"), ["admin"]);
|
||||||
const adminNode = admin.nav.find((n) => n.label === "Admin");
|
const adminNode = admin.nav.find((n) => n.label === "Admin");
|
||||||
assert.ok(adminNode, "admin role → Admin section present");
|
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.
|
// A non-admin (default []) never sees it — composeNav drops the gated header + its subtree.
|
||||||
const plain = buildDashboardModel(new URL("http://x/"));
|
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,
|
(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 {
|
export interface OAuth2Client {
|
||||||
client_id?: string;
|
client_id?: string;
|
||||||
client_name?: 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)
|
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
|
// 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 {
|
export interface HydraAdmin {
|
||||||
acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>;
|
acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>;
|
||||||
acceptLoginRequest(challenge: string, body: AcceptLogin): 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>;
|
getConsentRequest(challenge: string): Promise<ConsentRequest>;
|
||||||
getLoginRequest(challenge: string): Promise<LoginRequest>;
|
getLoginRequest(challenge: string): Promise<LoginRequest>;
|
||||||
|
listClients(opts?: { pageSize?: number; pageToken?: string }): Promise<ClientList>;
|
||||||
rejectConsentRequest(challenge: string, body: RejectRequest): Promise<Completed>;
|
rejectConsentRequest(challenge: string, body: RejectRequest): Promise<Completed>;
|
||||||
rejectLoginRequest(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 {
|
export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin {
|
||||||
const base = config.baseUrl.replace(/\/+$/, "");
|
const base = config.baseUrl.replace(/\/+$/, "");
|
||||||
const http = config.fetchImpl ?? fetch;
|
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.
|
// Hydra keys the login/consent handshake off a ?login_challenge=/?consent_challenge= query.
|
||||||
const reqUrl = (kind: "consent" | "login", challenge: string, action = "") =>
|
const reqUrl = (kind: "consent" | "login", challenge: string, action = "") =>
|
||||||
`${base}/admin/oauth2/auth/requests/${kind}${action}?${kind}_challenge=${encodeURIComponent(challenge)}`;
|
`${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> {
|
async function fail(action: string, res: Response): Promise<never> {
|
||||||
throw new HydraError(`Hydra admin ${action} failed (${res.status})`, res.status, await res.text());
|
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);
|
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) {
|
async getConsentRequest(challenge) {
|
||||||
const res = await http(reqUrl("consent", challenge));
|
const res = await http(reqUrl("consent", challenge));
|
||||||
if (res.status !== 200) return fail("get consent request", res);
|
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;
|
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) {
|
async rejectConsentRequest(challenge, body) {
|
||||||
return put("reject consent", reqUrl("consent", challenge, "/reject"), 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";
|
const DENIED = "http://client/cb?error=access_denied";
|
||||||
|
|
||||||
function stubHydra(consent: ConsentRequest, capture?: (b: AcceptConsent) => void): HydraAdmin {
|
function stubHydra(consent: ConsentRequest, capture?: (b: AcceptConsent) => void): HydraAdmin {
|
||||||
|
const unused = async () => { throw new Error("unused"); };
|
||||||
return {
|
return {
|
||||||
acceptConsentRequest: async (_c, body) => { capture?.(body); return { redirect: REDIRECT }; },
|
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,
|
getConsentRequest: async () => consent,
|
||||||
getLoginRequest: async () => { throw new Error("unused"); },
|
getLoginRequest: unused,
|
||||||
|
listClients: unused,
|
||||||
rejectConsentRequest: async () => ({ redirect: DENIED }),
|
rejectConsentRequest: async () => ({ redirect: DENIED }),
|
||||||
rejectLoginRequest: async () => { throw new Error("unused"); },
|
rejectLoginRequest: unused,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({
|
const stubKratos = (whoami: KratosPublic["whoami"]): KratosPublic => ({
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ function stubHydra(login: LoginRequest, capture?: (b: AcceptLogin) => void): Hyd
|
|||||||
return {
|
return {
|
||||||
acceptConsentRequest: unused,
|
acceptConsentRequest: unused,
|
||||||
acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; },
|
acceptLoginRequest: async (_c, body) => { capture?.(body); return { redirect: "http://hydra/oauth2/auth?login_verifier=v" }; },
|
||||||
|
createClient: unused,
|
||||||
|
deleteClient: unused,
|
||||||
|
getClient: unused,
|
||||||
getConsentRequest: unused,
|
getConsentRequest: unused,
|
||||||
getLoginRequest: async () => login,
|
getLoginRequest: async () => login,
|
||||||
|
listClients: unused,
|
||||||
rejectConsentRequest: unused,
|
rejectConsentRequest: unused,
|
||||||
rejectLoginRequest: unused,
|
rejectLoginRequest: unused,
|
||||||
};
|
};
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -104,7 +104,7 @@ everything via Docker.
|
|||||||
## 6. Hydra — OAuth2/OIDC provider (can ship after the rest)
|
## 6. Hydra — OAuth2/OIDC provider (can ship after the rest)
|
||||||
- [x] Login-challenge handler: authenticate via Kratos session, accept/reject. → `src/hydra-admin.ts` (`createHydraAdmin`): typed `fetch` wrappers over Hydra's OAuth2 admin API (port 4445, no SDK, `fetchImpl`-injectable like the kratos/keto clients) — `getLoginRequest`/`acceptLoginRequest`/`rejectLoginRequest` + a `HydraError` carrying `.status`. `src/oauth-login.ts` (`resolveLoginChallenge`, pure): `getLoginRequest` → **skip** (Hydra already authenticated the subject) ⇒ accept it without touching Kratos; a live **Kratos session** (`whoami`) ⇒ accept with that identity as the subject (`remember`, browser-session lifetime); **no session** ⇒ bounce to our themed `/login?return_to=<absolute self URL>`, so Kratos lands back on the challenge once signed in. Wired into `app.ts` at `GET /oauth2/login` (gated on `hydra`+`kratos` present; missing `login_challenge`→400; the absolute return target derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates `return_to` against its allow-list); `/login` now bakes a `return_to` into the Kratos flow init so the round-trip works. `config.ts` gains `hydraAdminUrl` (default `http://hydra:4445`); `server.ts` builds the client; `compose.yml` `web` now gates on `hydra` healthy (the app consumes it). Tests-first: `hydra-admin.test.ts` (request contracts + error mapping), `oauth-login.test.ts` (skip/session/no-session matrix), `app.test.ts` (HTTP: accept→Hydra redirect / no-session→/login bounce / missing-challenge→400 / `/login` return_to forwarding), `config.test.ts` + `compose.test.ts` (web↦hydra dep). Full-stack E2E `e2e/oauth-login.spec.ts` (`compose.e2e-oauth.yml`): boots the real stack incl. Hydra, registers an OAuth2 client, starts an authorization flow, asserts the unauthenticated bounce **and** the authenticated accept (→ Hydra `/oauth2/auth?…login_verifier=…`) — green, then torn down. Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one stability warning — a stale/invalid/consumed challenge (Hydra 4xx, user-reachable via back button/slow login) now degrades to a recoverable 400 instead of a 500, while a genuine Hydra 5xx outage still surfaces as 500 (mirrors the themed-flow + §4 re-mint hardening). Deferred (reviewer-scoped, §9): document that prod `allowed_return_urls` entries must be exact origins with a trailing `/` (the return_to safety leans on Kratos' allow-list). typecheck + 253 units + 8 visual E2E green. Consent handler + client registration are the next §6 items.
|
- [x] Login-challenge handler: authenticate via Kratos session, accept/reject. → `src/hydra-admin.ts` (`createHydraAdmin`): typed `fetch` wrappers over Hydra's OAuth2 admin API (port 4445, no SDK, `fetchImpl`-injectable like the kratos/keto clients) — `getLoginRequest`/`acceptLoginRequest`/`rejectLoginRequest` + a `HydraError` carrying `.status`. `src/oauth-login.ts` (`resolveLoginChallenge`, pure): `getLoginRequest` → **skip** (Hydra already authenticated the subject) ⇒ accept it without touching Kratos; a live **Kratos session** (`whoami`) ⇒ accept with that identity as the subject (`remember`, browser-session lifetime); **no session** ⇒ bounce to our themed `/login?return_to=<absolute self URL>`, so Kratos lands back on the challenge once signed in. Wired into `app.ts` at `GET /oauth2/login` (gated on `hydra`+`kratos` present; missing `login_challenge`→400; the absolute return target derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates `return_to` against its allow-list); `/login` now bakes a `return_to` into the Kratos flow init so the round-trip works. `config.ts` gains `hydraAdminUrl` (default `http://hydra:4445`); `server.ts` builds the client; `compose.yml` `web` now gates on `hydra` healthy (the app consumes it). Tests-first: `hydra-admin.test.ts` (request contracts + error mapping), `oauth-login.test.ts` (skip/session/no-session matrix), `app.test.ts` (HTTP: accept→Hydra redirect / no-session→/login bounce / missing-challenge→400 / `/login` return_to forwarding), `config.test.ts` + `compose.test.ts` (web↦hydra dep). Full-stack E2E `e2e/oauth-login.spec.ts` (`compose.e2e-oauth.yml`): boots the real stack incl. Hydra, registers an OAuth2 client, starts an authorization flow, asserts the unauthenticated bounce **and** the authenticated accept (→ Hydra `/oauth2/auth?…login_verifier=…`) — green, then torn down. Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one stability warning — a stale/invalid/consumed challenge (Hydra 4xx, user-reachable via back button/slow login) now degrades to a recoverable 400 instead of a 500, while a genuine Hydra 5xx outage still surfaces as 500 (mirrors the themed-flow + §4 re-mint hardening). Deferred (reviewer-scoped, §9): document that prod `allowed_return_urls` entries must be exact origins with a trailing `/` (the return_to safety leans on Kratos' allow-list). typecheck + 253 units + 8 visual E2E green. Consent handler + client registration are the next §6 items.
|
||||||
- [x] Consent-challenge handler: show / auto-accept first-party, grant scopes, accept/reject. → `src/hydra-admin.ts` gains the consent half of the handshake (`getConsentRequest`/`acceptConsentRequest`/`rejectConsentRequest` + `ConsentRequest`/`AcceptConsent`/`ConsentSession` types; the login/consent URL builder folded into one `reqUrl(kind,…)` + a shared `put()`). `src/oauth-consent.ts` (pure, sibling of `oauth-login.ts`): `resolveConsentChallenge` → **skip** (Hydra already consented / a skip-consent client) or **first-party** (the client's Hydra `metadata.first_party === true`) ⇒ auto-accept, else return a `view` to show the themed consent screen; `acceptConsent` (re-reads the challenge so scopes/audience are **never** client-supplied) + `rejectConsent` (access_denied). The grant carries an OIDC `session.id_token` with `email`/`name` projected from the Kratos identity (`whoami` traits; absent ⇒ omitted). Wired in `app.ts` at `GET|POST /oauth2/consent` (gated on `hydra`+`kratos`): GET shows/auto-accepts (sets the page CSRF cookie when fresh), POST is **CSRF-guarded** (same signed double-submit as `/logout`) and dispatches `decision=allow`→accept / else→reject → 303 to Hydra; a stale/consumed challenge (Hydra 4xx) degrades to a recoverable 400, a genuine outage (5xx) → 500 (mirrors `/oauth2/login`). `views/oauth-consent.ejs` + `partials/consent-body.ejs` reuse the auth-card: the consent screen lists the requested scopes (friendly labels for the standard OIDC ones) with Allow/Deny submit buttons. Tests-first: `hydra-admin.test.ts` (consent request contracts), `oauth-consent.test.ts` (skip/first-party/third-party/audience/id_token/accept-refetch/reject matrix), `app.test.ts` HTTP integration (auto-accept / screen render+CSRF cookie / allow+deny / forged-CSRF→403 / missing→400 / stale→400 / outage→500). Stability-reviewer run as a local PR: APPROVE, no Critical/High. Extended the full-stack E2E `e2e/oauth-login.spec.ts` to drive the **whole** authorization-code flow against real Hydra — login accept → follow the login_verifier through Hydra → web's consent screen (third-party client `e2e-login`, scopes listed) → Allow → consent_verifier → the client callback with a real `code` (per-host cookie jars; Hydra resume URLs rebased onto the compose host). typecheck + 262 units + 8 visual + OAuth login+consent E2E green; stack torn down. OAuth2 client registration is the next §6 item.
|
- [x] Consent-challenge handler: show / auto-accept first-party, grant scopes, accept/reject. → `src/hydra-admin.ts` gains the consent half of the handshake (`getConsentRequest`/`acceptConsentRequest`/`rejectConsentRequest` + `ConsentRequest`/`AcceptConsent`/`ConsentSession` types; the login/consent URL builder folded into one `reqUrl(kind,…)` + a shared `put()`). `src/oauth-consent.ts` (pure, sibling of `oauth-login.ts`): `resolveConsentChallenge` → **skip** (Hydra already consented / a skip-consent client) or **first-party** (the client's Hydra `metadata.first_party === true`) ⇒ auto-accept, else return a `view` to show the themed consent screen; `acceptConsent` (re-reads the challenge so scopes/audience are **never** client-supplied) + `rejectConsent` (access_denied). The grant carries an OIDC `session.id_token` with `email`/`name` projected from the Kratos identity (`whoami` traits; absent ⇒ omitted). Wired in `app.ts` at `GET|POST /oauth2/consent` (gated on `hydra`+`kratos`): GET shows/auto-accepts (sets the page CSRF cookie when fresh), POST is **CSRF-guarded** (same signed double-submit as `/logout`) and dispatches `decision=allow`→accept / else→reject → 303 to Hydra; a stale/consumed challenge (Hydra 4xx) degrades to a recoverable 400, a genuine outage (5xx) → 500 (mirrors `/oauth2/login`). `views/oauth-consent.ejs` + `partials/consent-body.ejs` reuse the auth-card: the consent screen lists the requested scopes (friendly labels for the standard OIDC ones) with Allow/Deny submit buttons. Tests-first: `hydra-admin.test.ts` (consent request contracts), `oauth-consent.test.ts` (skip/first-party/third-party/audience/id_token/accept-refetch/reject matrix), `app.test.ts` HTTP integration (auto-accept / screen render+CSRF cookie / allow+deny / forged-CSRF→403 / missing→400 / stale→400 / outage→500). Stability-reviewer run as a local PR: APPROVE, no Critical/High. Extended the full-stack E2E `e2e/oauth-login.spec.ts` to drive the **whole** authorization-code flow against real Hydra — login accept → follow the login_verifier through Hydra → web's consent screen (third-party client `e2e-login`, scopes listed) → Allow → consent_verifier → the client callback with a real `code` (per-host cookie jars; Hydra resume URLs rebased onto the compose host). typecheck + 262 units + 8 visual + OAuth login+consent E2E green; stack torn down. OAuth2 client registration is the next §6 item.
|
||||||
- [ ] OAuth2 client registration (admin UI or CLI).
|
- [x] OAuth2 client registration (admin UI or CLI). → Built-in **OAuth2 clients** admin screen (`src/admin-clients.ts`, `/admin/clients`) — the §6 client side of Hydra (apps that log in *through* us). `src/hydra-admin.ts` gains the client half of the admin API: `createClient`/`listClients`/`getClient`/`deleteClient` over `/admin/clients` (+ a `nextPageToken` Link parser, mirrors kratos-admin) and the registration fields on `OAuth2Client`. The screen mirrors the §5 Users/Roles pattern — pure builders (`toClientView`, `clientPayload`, `validateClientInput`, `parseRedirectUris`, `buildClients{List,Form,Detail}Model`) + `handleAdminClients` (the imperative shell app.ts dispatches `/admin/clients*` to). Routes: `GET /admin/clients` (list — search/paginate over one Hydra page), `GET|POST /admin/clients/new`+`/` (register), `GET /admin/clients/:id` (read-only detail), `GET|POST …/:id/delete` (confirm + delete). 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) — never re-shown (`getClient` carries no secret; detail asserts it). Writes go **only to Hydra**; gated admin-only (anon→/login, non-admin→403) + every mutation CSRF-guarded, 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 the shared `adminSection` (Users·Groups·Roles·**OAuth2 clients**, `i-globe`) so it shows for admins, 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. 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. Review/comment/test-cleanup are the next §6 items.
|
||||||
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
||||||
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
||||||
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
||||||
|
|||||||
17
views/admin/client-detail.ejs
Normal file
17
views/admin/client-detail.ejs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<%#
|
||||||
|
OAuth2 client detail page (todo §6): the client-detail body (info · one-time secret · delete) in the
|
||||||
|
shell. Doubles as the post-register page when `created`/`secret` are set.
|
||||||
|
%><%
|
||||||
|
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||||
|
const body = include("partials/client-detail-body", { client: model.client, created: model.created, csrfToken: model.shell.csrfToken, del: model.delete, secret: model.secret });
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
body,
|
||||||
|
brand: model.shell.brand,
|
||||||
|
breadcrumbs: model.shell.breadcrumbs,
|
||||||
|
csrfToken: model.shell.csrfToken,
|
||||||
|
nav,
|
||||||
|
theme: model.shell.theme,
|
||||||
|
title: model.shell.title,
|
||||||
|
user: model.shell.user,
|
||||||
|
}) %>
|
||||||
16
views/admin/client-form.ejs
Normal file
16
views/admin/client-form.ejs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<%#
|
||||||
|
OAuth2 client register page (todo §6): the client-form body captured into the app shell.
|
||||||
|
%><%
|
||||||
|
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||||
|
const body = include("partials/client-form-body", { error: model.error, form: model.form });
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
body,
|
||||||
|
brand: model.shell.brand,
|
||||||
|
breadcrumbs: model.shell.breadcrumbs,
|
||||||
|
csrfToken: model.shell.csrfToken,
|
||||||
|
nav,
|
||||||
|
theme: model.shell.theme,
|
||||||
|
title: model.shell.title,
|
||||||
|
user: model.shell.user,
|
||||||
|
}) %>
|
||||||
21
views/admin/clients.ejs
Normal file
21
views/admin/clients.ejs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<%#
|
||||||
|
OAuth2 clients admin list (todo §6): apps that log in *through* us (Hydra). Same building blocks as
|
||||||
|
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (src/admin-clients.ts).
|
||||||
|
%><%
|
||||||
|
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||||
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
|
const table = include("partials/data-table", model.table);
|
||||||
|
const pager = include("partials/pagination", model.pagination);
|
||||||
|
const actions = '<a class="btn btn-primary" href="/admin/clients/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Register client</a>';
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
actions,
|
||||||
|
body: filters + table + pager,
|
||||||
|
brand: model.shell.brand,
|
||||||
|
breadcrumbs: model.shell.breadcrumbs,
|
||||||
|
csrfToken: model.shell.csrfToken,
|
||||||
|
nav,
|
||||||
|
theme: model.shell.theme,
|
||||||
|
title: model.shell.title,
|
||||||
|
user: model.shell.user,
|
||||||
|
}) %>
|
||||||
37
views/partials/client-detail-body.ejs
Normal file
37
views/partials/client-detail-body.ejs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<%#
|
||||||
|
Admin OAuth2 client detail body (todo §6), captured into the shell content slot. Config:
|
||||||
|
client { firstParty, id, name, public, redirectUris[], scopes[] }
|
||||||
|
created bool just registered → success banner
|
||||||
|
secret? string one-time client secret (confidential clients), shown once right after create
|
||||||
|
del { action } delete the client
|
||||||
|
csrfToken
|
||||||
|
%><%
|
||||||
|
const c = locals.client;
|
||||||
|
const del = locals.del;
|
||||||
|
-%>
|
||||||
|
<div class="form-page">
|
||||||
|
<% if (locals.created) { -%>
|
||||||
|
<%- include("alert", { text: "Client registered.", tone: "pos" }) %>
|
||||||
|
<% } -%>
|
||||||
|
<% if (locals.secret) { -%>
|
||||||
|
<section class="form-card" aria-labelledby="secret-h">
|
||||||
|
<h2 class="card-title" id="secret-h">Client secret</h2>
|
||||||
|
<p class="field-hint">Copy these now — the secret can't be shown again. Store them where the app reads its credentials.</p>
|
||||||
|
<div class="field"><label for="cid">Client ID</label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
|
||||||
|
<div class="field"><label for="csecret">Client secret</label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
|
||||||
|
</section>
|
||||||
|
<% } -%>
|
||||||
|
<section class="form-card" aria-labelledby="client-h">
|
||||||
|
<h2 class="card-title" id="client-h"><%= c.name %></h2>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<dt>Client ID</dt><dd><%= c.id %></dd>
|
||||||
|
<dt>Type</dt><dd><%= c.public ? "Public (PKCE)" : "Confidential" %></dd>
|
||||||
|
<dt>Consent</dt><dd><%= c.firstParty ? "First-party (auto-granted)" : "Shows the consent screen" %></dd>
|
||||||
|
<dt>Scopes</dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
|
||||||
|
<dt>Redirect URIs</dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section class="form-card admin-actions" aria-label="Client actions">
|
||||||
|
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete client</a>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
29
views/partials/client-form-body.ejs
Normal file
29
views/partials/client-form-body.ejs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<%#
|
||||||
|
Admin OAuth2 client register form body (todo §6), captured into the shell content slot. Config:
|
||||||
|
form { action, csrfToken, submitLabel, cancelHref, nameField, scopeField (field.ejs configs),
|
||||||
|
redirectUris: string (newline-separated), public: bool, firstParty: bool }
|
||||||
|
error? string shown when a write was rejected
|
||||||
|
%><%
|
||||||
|
const form = locals.form;
|
||||||
|
-%>
|
||||||
|
<div class="form-page">
|
||||||
|
<% if (locals.error) { -%>
|
||||||
|
<%- include("alert", { text: locals.error, tone: "neg" }) %>
|
||||||
|
<% } -%>
|
||||||
|
<form class="form-card" method="post" action="<%= form.action %>">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
|
<%- include("field", form.nameField) %>
|
||||||
|
<div class="field">
|
||||||
|
<label for="redirectUris">Redirect URIs</label>
|
||||||
|
<textarea class="input" id="redirectUris" name="redirectUris" rows="3" placeholder="https://app.example.com/callback"><%= form.redirectUris %></textarea>
|
||||||
|
<span class="field-hint">One per line — where the app is sent back after sign-in.</span>
|
||||||
|
</div>
|
||||||
|
<%- include("field", form.scopeField) %>
|
||||||
|
<label class="check"><input type="checkbox" name="public"<% if (form.public) { %> checked<% } %>> Public client (SPA / native app, PKCE — no secret)</label>
|
||||||
|
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||||
|
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user