feat(auth): support deriving roles from the IDP

Closes HP-352.
This commit is contained in:
Aarnav Tale
2026-06-20 11:53:09 -04:00
parent 96f2721272
commit 0c4d175eb7
13 changed files with 252 additions and 8 deletions
+20
View File
@@ -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" });
+20
View File
@@ -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, {
+47
View File
@@ -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",