mirror of
https://github.com/tale/headplane.git
synced 2026-08-07 21:03:13 +00:00
feat(auth): support deriving roles from the IDP
Closes HP-352.
This commit is contained in:
@@ -30,6 +30,26 @@ describe("findOrCreateUser", () => {
|
||||
expect(role).toBe("member");
|
||||
});
|
||||
|
||||
test("second distinct user can receive a configured initial role", async () => {
|
||||
await auth.findOrCreateUser("sub-owner", { name: "Owner" });
|
||||
await auth.findOrCreateUser("sub-admin", { name: "Admin" }, { initialRole: "admin" });
|
||||
const role = await auth.roleForSubject("sub-admin");
|
||||
expect(role).toBe("admin");
|
||||
});
|
||||
|
||||
test("first created user becomes owner even with a configured initial role", async () => {
|
||||
await auth.findOrCreateUser("sub-owner", { name: "Owner" }, { initialRole: "viewer" });
|
||||
const role = await auth.roleForSubject("sub-owner");
|
||||
expect(role).toBe("owner");
|
||||
});
|
||||
|
||||
test("configured initial roles cannot grant owner", async () => {
|
||||
await auth.findOrCreateUser("sub-owner", { name: "Owner" });
|
||||
await auth.findOrCreateUser("sub-other", { name: "Other" }, { initialRole: "owner" });
|
||||
const role = await auth.roleForSubject("sub-other");
|
||||
expect(role).toBe("member");
|
||||
});
|
||||
|
||||
test("existing subject returns same id (idempotent)", async () => {
|
||||
const id1 = await auth.findOrCreateUser("sub-1", { name: "Alice" });
|
||||
const id2 = await auth.findOrCreateUser("sub-1", { name: "Alice" });
|
||||
|
||||
@@ -148,6 +148,26 @@ describe("Configuration YAML file loading", () => {
|
||||
expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]);
|
||||
});
|
||||
|
||||
test("oidc.default_role and oidc.role_claim can be configured from YAML", async () => {
|
||||
const filePath = "/config/oidc-role-assignment.yaml";
|
||||
writeYaml(filePath, {
|
||||
headscale: { url: "http://localhost:8080" },
|
||||
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
|
||||
oidc: {
|
||||
issuer: "https://accounts.google.com",
|
||||
client_id: "my-client-id",
|
||||
client_secret: "my-client-secret",
|
||||
headscale_api_key: "my-api-key",
|
||||
default_role: "viewer",
|
||||
role_claim: "headplane_role",
|
||||
},
|
||||
});
|
||||
|
||||
const config = await loadConfig(filePath);
|
||||
expect(config.oidc?.default_role).toBe("viewer");
|
||||
expect(config.oidc?.role_claim).toBe("headplane_role");
|
||||
});
|
||||
|
||||
test("oidc.allow_weak_rsa_keys defaults to false", async () => {
|
||||
const filePath = "/config/oidc-weak-rsa-default.yaml";
|
||||
writeYaml(filePath, {
|
||||
|
||||
@@ -897,6 +897,53 @@ describe("identity resolution", () => {
|
||||
expect(result.ok && result.value.name).toBe("Alice Smith");
|
||||
});
|
||||
|
||||
test("resolves role from configured ID token claim", async () => {
|
||||
const result = await flowWithClaims(
|
||||
{ sub: "u1", headplane_role: "network_admin" },
|
||||
{ roleClaim: "headplane_role" },
|
||||
);
|
||||
|
||||
expect(result.ok && result.value.role).toBe("network_admin");
|
||||
});
|
||||
|
||||
test("resolves strongest assignable role from configured array claim", async () => {
|
||||
const result = await flowWithClaims(
|
||||
{ sub: "u1", groups: ["member", "admin"] },
|
||||
{ roleClaim: "groups" },
|
||||
);
|
||||
|
||||
expect(result.ok && result.value.role).toBe("admin");
|
||||
});
|
||||
|
||||
test("fetches userinfo when configured role claim is missing from ID token", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false, roleClaim: "headplane_role" }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken(
|
||||
{ sub: "u1", name: "Alice Smith", email: "alice@example.com" },
|
||||
flowState.nonce,
|
||||
);
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
|
||||
};
|
||||
|
||||
userinfoHandler = (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ headplane_role: "viewer" }));
|
||||
};
|
||||
|
||||
const params = new URLSearchParams({ code: "c", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok && result.value.role).toBe("viewer");
|
||||
});
|
||||
|
||||
test("falls back to given_name + family_name", async () => {
|
||||
const result = await flowWithClaims({
|
||||
sub: "u1",
|
||||
|
||||
Reference in New Issue
Block a user