// What the `oidc-client` provision means in Keycloak: one confidential OpenID Connect client per // consumer, in the realm this module serves, under the name and secret the mesh gave both ends. // The provisioner (provisioner/index.ts) is the sdk harness calling these; they are here, apart from // it, so they can be exercised against a fake admin API without a broker or a contributions file. // // **The client id and the secret are the mesh's, not Keycloak's (novox/hq ADR 0048).** The mesh // derives the consumer's identity (`as`, e.g. `mesh_ace_grafana`) and hands it to both ends — the // consumer names it as its client id through `${bound:oidc-client:as}` — and mints the secret, which // this sets as the client's secret. Keycloak generates neither. // // **Where the consumer's browser comes back to is the consumer's to say.** Its contribution carries // `callback` (a path, e.g. `/login/generic_oauth`) and the `label`/`endpoint` of the endpoint it is // reached on; the mesh composes that endpoint's names into `name` (public) and `internal-name` // (private network) exactly as it does for a route (novox/hq ADR 0056, 0138), so the redirect URI // registered here is built from the same names the proxy serves the consumer under. // // **Only what the mesh made is touched.** A client this module creates carries the attribute // `mesh.provisioned=true`, and its id starts with the mesh's own prefix. A client with the same id // that lacks the mark is somebody else's: it is refused, never adopted, never updated, never deleted. import type { ClientRepresentation, KeycloakClient, ProtocolMapperRepresentation } from "./client.js"; /** The attribute marking a client as the mesh's own work. */ export const MARK = "mesh.provisioned"; /** The mapper every mesh client carries: realm roles as a flat `roles` claim in the id token, the * access token and userinfo — what a consumer maps its own roles from (grafana's role path reads * `roles[*]`), and what the predecessor added to its hand-made clients by hand. */ export const ROLES_MAPPER: ProtocolMapperRepresentation = { name: "realm roles", protocol: "openid-connect", protocolMapper: "oidc-usermodel-realm-role-mapper", config: { "claim.name": "roles", "jsonType.label": "String", multivalued: "true", "id.token.claim": "true", "access.token.claim": "true", "userinfo.token.claim": "true", }, }; /** One consumer, as the harness hands it over. */ export interface OidcGrant { readonly as: string; readonly password: string; readonly values: Readonly>; readonly consumer?: string; } /** The realm named by an issuer URL — `https://id.example/realms/Novox` is realm `Novox`. The issuer is * the one value an assignment sets (it is also what consumers are served), so the realm is read * out of it rather than set a second time where the two could disagree. */ export function realmOf(issuer: string): string { let path: string; try { path = new URL(issuer).pathname; } catch { throw new Error(`the issuer ${JSON.stringify(issuer)} is not a URL`); } const m = /\/realms\/([^/]+)\/?$/.exec(path); if (!m) throw new Error(`the issuer ${JSON.stringify(issuer)} does not end in /realms/`); return decodeURIComponent(m[1]); } /** The redirect URIs a consumer's contribution asks for: its callback under each name the mesh * composed for its endpoint. Refused when there is nothing to register — a client that accepts no * redirect is a client nobody can log in through, and one that accepts any is worse. */ export function redirectsOf(values: Readonly>): { root: string; redirects: string[] } { const callback = values.callback; if (typeof callback !== "string" || !callback.startsWith("/")) { throw new Error(`contributes no callback path (\`callback\`, starting with "/"): ${JSON.stringify(callback)}`); } const names: string[] = []; for (const key of ["name", "internal-name"]) { const n = values[key]; if (typeof n === "string" && n.trim() !== "" && !names.includes(n.trim())) names.push(n.trim()); } if (names.length === 0) { throw new Error("has no name the mesh composed (`name` / `internal-name`) — contribute a `label` and the `endpoint` it is reached on"); } return { root: `https://${names[0]}`, redirects: names.map((n) => `https://${n}${callback}`) }; } /** The fields the mesh owns on a client it made. Everything else on the client is left as found. */ function wanted(g: OidcGrant): ClientRepresentation { const { root, redirects } = redirectsOf(g.values); return { clientId: g.as, name: g.as, description: `made by the mesh for ${g.consumer ? `a module on ${g.consumer}` : "a consumer"} — do not edit; it is reset`, enabled: true, protocol: "openid-connect", publicClient: false, clientAuthenticatorType: "client-secret", secret: g.password, rootUrl: root, baseUrl: root, redirectUris: redirects, standardFlowEnabled: true, implicitFlowEnabled: false, directAccessGrantsEnabled: false, serviceAccountsEnabled: false, }; } function sameSet(a: readonly string[] | undefined, b: readonly string[]): boolean { const x = [...(a ?? [])].sort(); const y = [...b].sort(); return x.length === y.length && x.every((v, i) => v === y[i]); } function marked(c: ClientRepresentation): boolean { return c.attributes?.[MARK] === "true"; } export class OidcClients { constructor(private readonly kc: KeycloakClient, readonly realm: string) {} /** Create the consumer's client, or bring the mesh's existing one back to what the grant says. * Returns whether it was newly created. Idempotent: applying the same grant twice changes nothing * the second time beyond re-asserting it. */ async ensure(g: OidcGrant): Promise<"created" | "updated"> { const want = wanted(g); const found = await this.kc.findClient(this.realm, g.as); if (found && !marked(found)) { throw new Error( `realm ${this.realm} already has a client ${g.as} the mesh did not make — left alone; ` + `delete or rename it if the mesh should own that id`); } if (!found) { await this.kc.createClientFrom(this.realm, { ...want, attributes: { [MARK]: "true" }, protocolMappers: [ROLES_MAPPER], }); return "created"; } // Overlay what the mesh owns on what is there, so a field Keycloak added or an operator set on a // field the mesh does not own survives the update. await this.kc.updateClient(this.realm, found.id!, { ...found, ...want, attributes: { ...(found.attributes ?? {}), [MARK]: "true" }, }); await this.ensureMapper(found.id!); return "updated"; } private async ensureMapper(id: string): Promise { const mappers = await this.kc.listClientMappers(this.realm, id); const have = mappers.find((m) => m.name === ROLES_MAPPER.name); if (!have) { await this.kc.addClientMapper(this.realm, id, ROLES_MAPPER); return; } const drifted = have.protocolMapper !== ROLES_MAPPER.protocolMapper || Object.entries(ROLES_MAPPER.config).some(([k, v]) => have.config?.[k] !== v); if (drifted) { await this.kc.updateClientMapper(this.realm, id, { ...ROLES_MAPPER, id: have.id }); } } /** Whether Keycloak still holds this consumer's client exactly as the grant says: present, the * mesh's, enabled, confidential, with the mesh's secret and the redirects asked for. Reads only. */ async holds(g: OidcGrant): Promise { const want = wanted(g); const found = await this.kc.findClient(this.realm, g.as); if (!found || !marked(found) || found.enabled === false || found.publicClient) return false; if (!sameSet(found.redirectUris, want.redirectUris!)) return false; const mappers = await this.kc.listClientMappers(this.realm, found.id!); if (!mappers.some((m) => m.name === ROLES_MAPPER.name)) return false; return (await this.kc.clientSecretById(this.realm, found.id!)) === g.password; } /** Withdraw a consumer's client — only one the mesh made. Returns what happened, for the log. */ async remove(as: string): Promise<"removed" | "absent" | "not ours"> { const found = await this.kc.findClient(this.realm, as); if (!found) return "absent"; if (!marked(found)) return "not ours"; await this.kc.deleteClientById(this.realm, found.id!); return "removed"; } }