keycloak: provide oidc-client, one mesh-made client per consumer
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.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user