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.
240 lines
11 KiB
TypeScript
240 lines
11 KiB
TypeScript
// What holds keycloak to the `oidc-client` provision (oidc.ts): one confidential client per consumer,
|
|
// under the id and secret the mesh gave, redirecting only to the consumer's own callback under the
|
|
// names the mesh composed; made once and brought back on every apply; and a client the mesh did not
|
|
// make — same id or not — never adopted, changed or deleted.
|
|
//
|
|
// Keycloak is a fake: the admin routes the module touches, answering with the status codes and the
|
|
// shapes Keycloak gives. Run against the compiled module (npm test builds first), the way the runtime
|
|
// loads it.
|
|
|
|
import { test, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
import { KeycloakClient } from "../dist/client.js";
|
|
import { MARK, OidcClients, ROLES_MAPPER, realmOf, redirectsOf } from "../dist/oidc.js";
|
|
|
|
type Client = Record<string, any>;
|
|
|
|
/** The realm's clients, by internal id, and what the fake was asked. */
|
|
const realm = "Novox";
|
|
const clients = new Map<string, Client>();
|
|
const calls: string[] = [];
|
|
|
|
function body(req: IncomingMessage): Promise<any> {
|
|
return new Promise((resolve) => {
|
|
let raw = "";
|
|
req.on("data", (c) => (raw += c));
|
|
req.on("end", () => resolve(raw ? JSON.parse(raw) : undefined));
|
|
});
|
|
}
|
|
|
|
function send(res: ServerResponse, status: number, value?: unknown): void {
|
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
res.end(value === undefined ? "" : JSON.stringify(value));
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
const url = new URL(req.url!, "http://fake");
|
|
calls.push(`${req.method} ${url.pathname}`);
|
|
if (url.pathname === "/realms/master/protocol/openid-connect/token") {
|
|
return send(res, 200, { access_token: "t", expires_in: 300 });
|
|
}
|
|
const base = `/admin/realms/${realm}/clients`;
|
|
if (!url.pathname.startsWith(base)) return send(res, 404, { error: "Realm not found." });
|
|
const rest = url.pathname.slice(base.length).split("/").filter(Boolean);
|
|
if (rest.length === 0 && req.method === "GET") {
|
|
const want = url.searchParams.get("clientId");
|
|
return send(res, 200, [...clients.values()].filter((c) => !want || c.clientId === want));
|
|
}
|
|
if (rest.length === 0 && req.method === "POST") {
|
|
const rep = await body(req);
|
|
if ([...clients.values()].some((c) => c.clientId === rep.clientId)) {
|
|
return send(res, 409, { errorMessage: `Client ${rep.clientId} already exists` });
|
|
}
|
|
const id = randomUUID();
|
|
const mappers = (rep.protocolMappers ?? []).map((m: Client) => ({ ...m, id: randomUUID() }));
|
|
clients.set(id, { ...rep, id, protocolMappers: mappers });
|
|
return send(res, 201);
|
|
}
|
|
const c = clients.get(rest[0]);
|
|
if (!c) return send(res, 404, { error: "Could not find client" });
|
|
if (rest.length === 1 && req.method === "PUT") {
|
|
// Keycloak ignores protocolMappers on a client update: they have their own endpoints.
|
|
const rep = await body(req);
|
|
clients.set(c.id, { ...rep, id: c.id, protocolMappers: c.protocolMappers });
|
|
return send(res, 204);
|
|
}
|
|
if (rest.length === 1 && req.method === "DELETE") {
|
|
clients.delete(c.id);
|
|
return send(res, 204);
|
|
}
|
|
if (rest[1] === "client-secret" && req.method === "GET") {
|
|
return send(res, 200, { type: "secret", value: c.secret });
|
|
}
|
|
if (rest[1] === "protocol-mappers") {
|
|
if (req.method === "GET") return send(res, 200, c.protocolMappers ?? []);
|
|
if (req.method === "POST") {
|
|
c.protocolMappers = [...(c.protocolMappers ?? []), { ...(await body(req)), id: randomUUID() }];
|
|
return send(res, 201);
|
|
}
|
|
if (req.method === "PUT") {
|
|
const m = await body(req);
|
|
c.protocolMappers = c.protocolMappers.map((x: Client) => (x.id === rest[4] ? m : x));
|
|
return send(res, 204);
|
|
}
|
|
}
|
|
send(res, 405);
|
|
});
|
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
after(() => server.close());
|
|
const port = (server.address() as { port: number }).port;
|
|
|
|
const oidc = new OidcClients(new KeycloakClient(`http://127.0.0.1:${port}`, "admin", "pw"), realm);
|
|
|
|
/** Grafana on ace, as the mesh hands it to the provisioner. */
|
|
function grafana(secret = "s3cret", values: Record<string, unknown> = {}) {
|
|
return {
|
|
as: "mesh_ace_grafana",
|
|
password: secret,
|
|
consumer: "ace",
|
|
values: {
|
|
label: "grafana", endpoint: "web", port: 20010, callback: "/login/generic_oauth",
|
|
name: "grafana.zurag.be", "internal-name": "grafana.ace.internal", ...values,
|
|
},
|
|
};
|
|
}
|
|
|
|
function only(clientId: string): Client {
|
|
const found = [...clients.values()].filter((c) => c.clientId === clientId);
|
|
assert.equal(found.length, 1, `exactly one client ${clientId}, found ${found.length}`);
|
|
return found[0];
|
|
}
|
|
|
|
test("the realm is read out of the issuer, and an issuer that names none is refused", () => {
|
|
assert.equal(realmOf("https://keycloak.novox.be/realms/Novox"), "Novox");
|
|
assert.equal(realmOf("https://keycloak.novox.be/realms/Novox/"), "Novox");
|
|
assert.equal(realmOf("http://127.0.0.1:18500/realms/master"), "master");
|
|
assert.throws(() => realmOf("https://keycloak.novox.be"), /realms/);
|
|
assert.throws(() => realmOf("keycloak"), /not a URL/);
|
|
});
|
|
|
|
test("the redirect is the consumer's callback under every name the mesh composed for it", () => {
|
|
assert.deepEqual(redirectsOf(grafana().values), {
|
|
root: "https://grafana.zurag.be",
|
|
redirects: ["https://grafana.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"],
|
|
});
|
|
// A route reaching only the private network has only the internal name, and that is enough.
|
|
assert.deepEqual(redirectsOf({ callback: "/cb", "internal-name": "x.ace.internal" }).redirects,
|
|
["https://x.ace.internal/cb"]);
|
|
assert.throws(() => redirectsOf({ name: "grafana.zurag.be" }), /callback/);
|
|
assert.throws(() => redirectsOf({ name: "grafana.zurag.be", callback: "login" }), /callback/);
|
|
assert.throws(() => redirectsOf({ callback: "/cb" }), /label/);
|
|
});
|
|
|
|
test("a consumer is given one confidential client, under its id and the mesh's secret", async () => {
|
|
clients.clear();
|
|
assert.equal(await oidc.ensure(grafana()), "created");
|
|
const c = only("mesh_ace_grafana");
|
|
assert.equal(c.publicClient, false);
|
|
assert.equal(c.clientAuthenticatorType, "client-secret");
|
|
assert.equal(c.secret, "s3cret");
|
|
assert.equal(c.enabled, true);
|
|
assert.equal(c.standardFlowEnabled, true);
|
|
assert.equal(c.directAccessGrantsEnabled, false);
|
|
assert.equal(c.implicitFlowEnabled, false);
|
|
assert.deepEqual(c.redirectUris, [
|
|
"https://grafana.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"]);
|
|
assert.equal(c.attributes[MARK], "true");
|
|
assert.deepEqual(c.protocolMappers.map((m: Client) => m.name), [ROLES_MAPPER.name]);
|
|
assert.equal(await oidc.holds(grafana()), true);
|
|
});
|
|
|
|
test("applying the same grant again makes no second client", async () => {
|
|
clients.clear();
|
|
await oidc.ensure(grafana());
|
|
assert.equal(await oidc.ensure(grafana()), "updated");
|
|
assert.equal(await oidc.ensure(grafana()), "updated");
|
|
only("mesh_ace_grafana");
|
|
assert.equal(only("mesh_ace_grafana").protocolMappers.length, 1, "the roles mapper is not added twice");
|
|
});
|
|
|
|
test("a new secret or a moved name is applied in place, and what the mesh does not own survives", async () => {
|
|
clients.clear();
|
|
await oidc.ensure(grafana());
|
|
const id = only("mesh_ace_grafana").id;
|
|
// Something the mesh does not own, set on the client after it was made.
|
|
clients.get(id)!.consentRequired = true;
|
|
clients.get(id)!.attributes["post.logout.redirect.uris"] = "+";
|
|
|
|
assert.equal(await oidc.holds(grafana("rotated")), false, "a rotated secret is not held until applied");
|
|
await oidc.ensure(grafana("rotated", { name: "dash.zurag.be" }));
|
|
const c = only("mesh_ace_grafana");
|
|
assert.equal(c.id, id, "updated, not replaced");
|
|
assert.equal(c.secret, "rotated");
|
|
assert.deepEqual(c.redirectUris, [
|
|
"https://dash.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"]);
|
|
assert.equal(c.rootUrl, "https://dash.zurag.be");
|
|
assert.equal(c.consentRequired, true);
|
|
assert.equal(c.attributes["post.logout.redirect.uris"], "+");
|
|
assert.equal(c.attributes[MARK], "true");
|
|
assert.equal(await oidc.holds(grafana("rotated", { name: "dash.zurag.be" })), true);
|
|
});
|
|
|
|
test("a client lost or edited behind the mesh's back is not held, and is made whole again", async () => {
|
|
clients.clear();
|
|
await oidc.ensure(grafana());
|
|
const c = only("mesh_ace_grafana");
|
|
c.redirectUris = ["*"];
|
|
assert.equal(await oidc.holds(grafana()), false, "a widened redirect is not what the mesh gave");
|
|
await oidc.ensure(grafana());
|
|
assert.equal(await oidc.holds(grafana()), true);
|
|
|
|
only("mesh_ace_grafana").protocolMappers = [];
|
|
assert.equal(await oidc.holds(grafana()), false, "a client without its roles mapper is not held");
|
|
await oidc.ensure(grafana());
|
|
assert.equal(await oidc.holds(grafana()), true);
|
|
|
|
clients.clear();
|
|
assert.equal(await oidc.holds(grafana()), false);
|
|
});
|
|
|
|
test("a client of the same id the mesh did not make is refused, and left exactly as it was", async () => {
|
|
clients.clear();
|
|
clients.set("theirs", { id: "theirs", clientId: "mesh_ace_grafana", secret: "their-secret", redirectUris: ["*"] });
|
|
const before = JSON.stringify(clients.get("theirs"));
|
|
const writes = calls.length;
|
|
await assert.rejects(oidc.ensure(grafana()), /did not make/);
|
|
assert.equal(JSON.stringify(clients.get("theirs")), before);
|
|
assert.ok(calls.slice(writes).every((c) => c.startsWith("GET") || c.startsWith("POST /realms/master")),
|
|
`only reads were made: ${calls.slice(writes).join(", ")}`);
|
|
assert.equal(await oidc.holds(grafana()), false);
|
|
assert.equal(await oidc.remove("mesh_ace_grafana"), "not ours");
|
|
assert.ok(clients.has("theirs"), "a client the mesh did not make is never deleted");
|
|
});
|
|
|
|
test("the predecessor's hand-made client is never touched: the mesh's has its own id", async () => {
|
|
clients.clear();
|
|
clients.set("hal", { id: "hal", clientId: "grafana", secret: "old", redirectUris: ["https://grafana.zurag.be/*"] });
|
|
await oidc.ensure(grafana());
|
|
assert.equal(clients.get("hal")!.secret, "old");
|
|
only("mesh_ace_grafana");
|
|
assert.equal(await oidc.remove("grafana"), "not ours");
|
|
assert.ok(clients.has("hal"));
|
|
});
|
|
|
|
test("a withdrawn consumer's client is removed, and an absent one is not an error", async () => {
|
|
clients.clear();
|
|
await oidc.ensure(grafana());
|
|
assert.equal(await oidc.remove("mesh_ace_grafana"), "removed");
|
|
assert.equal([...clients.values()].length, 0);
|
|
assert.equal(await oidc.remove("mesh_ace_grafana"), "absent");
|
|
});
|
|
|
|
test("a contribution with no callback makes no client at all", async () => {
|
|
clients.clear();
|
|
await assert.rejects(oidc.ensure({ ...grafana(), values: { name: "grafana.zurag.be" } }), /callback/);
|
|
assert.equal(clients.size, 0);
|
|
});
|