A module that logs people in through Keycloak had to be given a client by
hand, with its secret copied into the consumer's environment. As a provision
the mesh derives the client id (the consumer's identity, mesh_<node>_<module>)
and mints its secret, and delivers both ends: keycloak creates exactly that
confidential client, the consumer names it through ${bound:oidc-client:as}.
The consumer says where its browser comes back to (`callback`) and which
endpoint it is reached on (`label`/`endpoint`), so the redirect is built from
the same names the mesh composes for its route. keycloak serves the issuer and
the endpoint paths under it; the issuer is the one value an assignment sets,
and the realm is read out of it, so consumer and client cannot disagree.
Only what the mesh made is touched: its clients carry mesh.provisioned=true;
a client of the same id without the mark is refused, never adopted, updated
or deleted. The runtime now gets the admin password as a file, which its
tools also needed and never had.
186 lines
8.3 KiB
TypeScript
186 lines
8.3 KiB
TypeScript
// 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<Record<string, unknown>>;
|
|
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/<realm>`);
|
|
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<Record<string, unknown>>): { 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<void> {
|
|
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<boolean> {
|
|
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";
|
|
}
|
|
}
|