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.
74 lines
3.4 KiB
TypeScript
74 lines
3.4 KiB
TypeScript
// keycloak's provisioner — the adapter that makes keycloak a provider of the mesh `oidc-client`
|
|
// interface. The reconcile loop, the contributions file and reading the mesh's minted secret are the
|
|
// sdk harness's; this writes only the per-service half: how Keycloak creates, checks and removes a
|
|
// consumer's client (novox/hq ADR 0039/0040/0048). What a client is, and which ones are the mesh's,
|
|
// is in ../oidc.ts.
|
|
//
|
|
// The `oidc-client` interface: a consumer logs people in through the realm this module serves, as
|
|
// the confidential client `as` with the secret the mesh minted, and is redirected back to the
|
|
// callback it contributed under the names the mesh composed for its endpoint. What it is served —
|
|
// the issuer and the endpoint paths under it — is in the manifest's `serves`, settled with the
|
|
// assignment's settings.
|
|
//
|
|
// **The realm is read out of the issuer**, the one value an assignment sets (settings reach both the
|
|
// served facts and this module's config.json): a realm set in one place and an issuer in another
|
|
// would let the consumer be told one realm while its client is made in another.
|
|
|
|
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
|
import { emit } from "@novox/mesh-sdk/events";
|
|
import { readFileSync } from "node:fs";
|
|
import { KeycloakClient } from "../client.js";
|
|
import { OidcClients, realmOf } from "../oidc.js";
|
|
|
|
/** The issuer this assignment serves, from the settings-merged config the mesh delivers. */
|
|
function issuer(): string {
|
|
const file = process.env.MESH_KEYCLOAK_CONFIG_FILE;
|
|
let cfg: Record<string, unknown> = {};
|
|
if (file) {
|
|
try {
|
|
cfg = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
} catch {
|
|
// Absent or unreadable: fall through to the environment, and refuse below if that is empty too.
|
|
}
|
|
}
|
|
const said = typeof cfg.issuer === "string" ? cfg.issuer : process.env.MESH_KEYCLOAK_ISSUER;
|
|
if (!said) throw new Error("no issuer — the module's config.json carries none and MESH_KEYCLOAK_ISSUER is unset");
|
|
return said;
|
|
}
|
|
|
|
const clients = new OidcClients(KeycloakClient.fromEnv(), realmOf(issuer()));
|
|
|
|
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
|
|
async function announce(type: string, body: Record<string, string>): Promise<void> {
|
|
try {
|
|
await emit(type, body);
|
|
} catch (err) {
|
|
console.error(`[provisioner:oidc-client] emit ${type} failed: ${err}`);
|
|
}
|
|
}
|
|
|
|
runProvisioner("oidc-client", {
|
|
async create(p: Provision): Promise<void> {
|
|
const done = await clients.ensure(p);
|
|
if (done === "created") {
|
|
console.log(`[provisioner:oidc-client] created client ${p.as} in realm ${clients.realm}`);
|
|
await announce("client.created", { realm: clients.realm, clientId: p.as, consumer: p.consumer ?? "" });
|
|
}
|
|
},
|
|
|
|
async remove(p: { as: string }): Promise<void> {
|
|
const done = await clients.remove(p.as);
|
|
if (done === "not ours") {
|
|
console.error(`[provisioner:oidc-client] ${p.as}: a client of that id exists that the mesh did not make — left alone`);
|
|
} else if (done === "removed") {
|
|
console.log(`[provisioner:oidc-client] removed client ${p.as} from realm ${clients.realm}`);
|
|
}
|
|
},
|
|
|
|
// Asked every minute by the harness: whether Keycloak still holds this consumer's client exactly as
|
|
// the mesh gave it, so a client deleted or edited behind the mesh's back is made again (hq issue 120).
|
|
async holds(p: Provision): Promise<boolean> {
|
|
return clients.holds(p);
|
|
},
|
|
});
|