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
+1
View File
@@ -9,6 +9,7 @@
- Fixed machine rename submission by validating names before sending the rename request (closes [#564](https://github.com/tale/headplane/issues/564)). - Fixed machine rename submission by validating names before sending the rename request (closes [#564](https://github.com/tale/headplane/issues/564)).
- Fixed OIDC token exchange fallback when retrying with `client_secret_basic` (closes [#493](https://github.com/tale/headplane/issues/493)). - Fixed OIDC token exchange fallback when retrying with `client_secret_basic` (closes [#493](https://github.com/tale/headplane/issues/493)).
- Added support for proxy authentication via `server.proxy_auth` (closes [#353](https://github.com/tale/headplane/issues/353)). - Added support for proxy authentication via `server.proxy_auth` (closes [#353](https://github.com/tale/headplane/issues/353)).
- Added automatic role assignment for new OIDC users via `oidc.default_role` and IdP-provided role claims via `oidc.role_claim` (closes [#352](https://github.com/tale/headplane/issues/352)).
--- ---
+16 -5
View File
@@ -1,6 +1,7 @@
import { data, redirect } from "react-router"; import { data, redirect } from "react-router";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log"; import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state"; import { createOidcStateCookie } from "~/utils/oidc-state";
@@ -48,12 +49,22 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} }
const identity = result.value; const identity = result.value;
const claimedRole =
identity.role && identity.role !== "owner" && identity.role in Roles
? identity.role
: undefined;
const userId = await context.auth.findOrCreateUser(identity.subject, { const userId = await context.auth.findOrCreateUser(
name: identity.name, identity.subject,
email: identity.email, {
picture: identity.picture, name: identity.name,
}); email: identity.email,
picture: identity.picture,
},
{
initialRole: claimedRole ?? context.config.oidc?.default_role,
},
);
try { try {
// Looks up the Headscale user that matches this OIDC identity. We use // Looks up the Headscale user that matches this OIDC identity. We use
+6
View File
@@ -114,6 +114,8 @@ const partialHeadscaleConfig = type({
tls_cert_path: "string.lower?", tls_cert_path: "string.lower?",
}); });
const assignableRole = '"admin" | "network_admin" | "it_admin" | "auditor" | "viewer" | "member"';
const oidcConfig = type({ const oidcConfig = type({
enabled: "boolean = true", enabled: "boolean = true",
issuer: "string.url", issuer: "string.url",
@@ -147,6 +149,8 @@ const oidcConfig = type({
disable_api_key_login: "boolean = false", disable_api_key_login: "boolean = false",
scope: 'string = "openid email profile"', scope: 'string = "openid email profile"',
subject_claims: type("string[]").pipe(normalizeStringArray).optional(), subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
default_role: `${assignableRole} = "member"`,
role_claim: "string?",
allow_weak_rsa_keys: "boolean = false", allow_weak_rsa_keys: "boolean = false",
profile_picture_source: '"oidc" | "gravatar" = "oidc"', profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: "Record<string, string>?", extra_params: "Record<string, string>?",
@@ -174,6 +178,8 @@ const partialOidcConfig = type({
disable_api_key_login: "boolean?", disable_api_key_login: "boolean?",
scope: "string?", scope: "string?",
subject_claims: type("string[]").pipe(normalizeStringArray).optional(), subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
default_role: `${assignableRole}?`,
role_claim: "string?",
allow_weak_rsa_keys: "boolean?", allow_weak_rsa_keys: "boolean?",
extra_params: "Record<string, string>?", extra_params: "Record<string, string>?",
profile_picture_source: '"oidc" | "gravatar"?', profile_picture_source: '"oidc" | "gravatar"?',
+1
View File
@@ -151,6 +151,7 @@ function buildOidc(
usePkce: config.oidc.use_pkce, usePkce: config.oidc.use_pkce,
scope: config.oidc.scope, scope: config.oidc.scope,
subjectClaims: config.oidc.subject_claims, subjectClaims: config.oidc.subject_claims,
roleClaim: config.oidc.role_claim,
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys, allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
extraParams: config.oidc.extra_params, extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source, profilePictureSource: config.oidc.profile_picture_source,
+33 -1
View File
@@ -23,6 +23,7 @@ export interface OidcConfig {
usePkce?: boolean; usePkce?: boolean;
scope?: string; scope?: string;
subjectClaims?: string[]; subjectClaims?: string[];
roleClaim?: string;
allowWeakRsaKeys?: boolean; allowWeakRsaKeys?: boolean;
extraParams?: Record<string, string>; extraParams?: Record<string, string>;
profilePictureSource?: "oidc" | "gravatar"; profilePictureSource?: "oidc" | "gravatar";
@@ -51,6 +52,7 @@ export interface OidcIdentity {
username: string; username: string;
email?: string; email?: string;
picture?: string; picture?: string;
role?: string;
idToken?: string; idToken?: string;
} }
@@ -106,6 +108,7 @@ interface OidcClaims extends JWTPayload {
preferred_username?: string; preferred_username?: string;
email?: string; email?: string;
picture?: string; picture?: string;
[claim: string]: unknown;
} }
interface TokenResponse { interface TokenResponse {
@@ -696,7 +699,11 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
const needsEnrichment = const needsEnrichment =
!claims.name && !claims.email && !claims.picture && !!resolveSubject(claims); !claims.name && !claims.email && !claims.picture && !!resolveSubject(claims);
const needsSubjectEnrichment = !resolveSubject(claims); const needsSubjectEnrichment = !resolveSubject(claims);
if ((!needsEnrichment && !needsSubjectEnrichment) || !ep.userinfoEndpoint) { const needsRoleEnrichment = !!config.roleClaim && claims[config.roleClaim] === undefined;
if (
(!needsEnrichment && !needsSubjectEnrichment && !needsRoleEnrichment) ||
!ep.userinfoEndpoint
) {
return claims; return claims;
} }
@@ -715,6 +722,9 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
} }
const userInfo = (await response.json()) as Record<string, unknown>; const userInfo = (await response.json()) as Record<string, unknown>;
const roleClaimValue = config.roleClaim
? (claims[config.roleClaim] ?? userInfo[config.roleClaim])
: undefined;
const subjectClaimValues = Object.fromEntries( const subjectClaimValues = Object.fromEntries(
getSubjectClaimOrder() getSubjectClaimOrder()
.filter((claim) => claim !== "sub") .filter((claim) => claim !== "sub")
@@ -726,6 +736,9 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return { return {
...claims, ...claims,
...subjectClaimValues, ...subjectClaimValues,
...(config.roleClaim && roleClaimValue !== undefined
? { [config.roleClaim]: roleClaimValue }
: {}),
name: claims.name ?? (userInfo.name as string | undefined), name: claims.name ?? (userInfo.name as string | undefined),
given_name: claims.given_name ?? (userInfo.given_name as string | undefined), given_name: claims.given_name ?? (userInfo.given_name as string | undefined),
family_name: claims.family_name ?? (userInfo.family_name as string | undefined), family_name: claims.family_name ?? (userInfo.family_name as string | undefined),
@@ -777,6 +790,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
username, username,
email: claims.email, email: claims.email,
picture, picture,
role: config.roleClaim ? resolveRoleClaim(claims, config.roleClaim) : undefined,
idToken, idToken,
}; };
} }
@@ -831,6 +845,24 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return undefined; return undefined;
} }
function resolveRoleClaim(claims: OidcClaims, claimName: string): string | undefined {
const value = claims[claimName];
if (typeof value === "string") {
return value.trim() || undefined;
}
if (Array.isArray(value)) {
const roles = new Set(value.filter((v): v is string => typeof v === "string"));
for (const role of ["admin", "network_admin", "it_admin", "auditor", "viewer", "member"]) {
if (roles.has(role)) {
return role;
}
}
}
return undefined;
}
return { return {
status, status,
discover, discover,
+13 -2
View File
@@ -89,6 +89,7 @@ export interface AuthService {
findOrCreateUser( findOrCreateUser(
subject: string, subject: string,
profile?: { name?: string; email?: string; picture?: string }, profile?: { name?: string; email?: string; picture?: string },
options?: { initialRole?: string },
): Promise<string>; ): Promise<string>;
linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean>; linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean>;
@@ -582,6 +583,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
async function findOrCreateUser( async function findOrCreateUser(
subject: string, subject: string,
profile?: { name?: string; email?: string; picture?: string }, profile?: { name?: string; email?: string; picture?: string },
options?: { initialRole?: string },
): Promise<string> { ): Promise<string> {
const [existing] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1); const [existing] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
@@ -599,6 +601,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return existing.id; return existing.id;
} }
const initialRole = normalizeInitialRole(options?.initialRole) ?? "member";
const id = ulid(); const id = ulid();
await opts.db.insert(users).values({ await opts.db.insert(users).values({
id, id,
@@ -606,8 +609,8 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
name: profile?.name, name: profile?.name,
email: profile?.email, email: profile?.email,
picture: profile?.picture, picture: profile?.picture,
role: "member", role: initialRole,
caps: capsForRole("member"), caps: capsForRole(initialRole),
}); });
const [{ count }] = await opts.db.select({ count: sql<number>`count(*)` }).from(users); const [{ count }] = await opts.db.select({ count: sql<number>`count(*)` }).from(users);
@@ -622,6 +625,14 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return id; return id;
} }
function normalizeInitialRole(role: string | undefined): Exclude<Role, "owner"> | undefined {
if (role && role !== "owner" && role in Roles) {
return role as Exclude<Role, "owner">;
}
return undefined;
}
async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> { async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
const [existing] = await opts.db const [existing] = await opts.db
.select({ id: users.id }) .select({ id: users.id })
+13
View File
@@ -290,6 +290,19 @@ integration:
# - "open_id" # - "open_id"
# - "email" # - "email"
# #
# # Role assigned to new OIDC users after the first owner is bootstrapped.
# # This is useful with Headscale's OIDC allowed_domains / allowed_groups
# # restrictions when every permitted SSO user should receive the same
# # Headplane permissions. Valid values: admin, network_admin, it_admin,
# # auditor, viewer, member. The owner role is only granted to the first user.
# default_role: "member"
#
# # Optional OIDC claim to read a Headplane role from when creating a new user.
# # This can be a string claim, or an array claim containing one of the valid
# # roles above. If present and valid, it takes precedence over default_role.
# # Configure your IdP to map groups or client roles into this claim.
# role_claim: "headplane_role"
#
# # Allow ID token verification with legacy RSA keys smaller than 2048 bits. # # Allow ID token verification with legacy RSA keys smaller than 2048 bits.
# # This is disabled by default because it lowers token verification security and # # This is disabled by default because it lowers token verification security and
# # should only be used as a temporary compatibility workaround. # # should only be used as a temporary compatibility workaround.
+20
View File
@@ -252,6 +252,15 @@ _Type:_ boolean
_Default:_ `false` _Default:_ `false`
## settings.oidc.default_role
_Description:_ Role assigned to newly created OIDC users after the first owner is bootstrapped.
The owner role is reserved for the first-login bootstrap.
_Type:_ one of "admin", "network_admin", "it_admin", "auditor", "viewer", "member"
_Default:_ `"member"`
## settings.oidc.headscale_api_key_path ## settings.oidc.headscale_api_key_path
_Description:_ DEPRECATED: Use `headscale.api_key_path` instead. _Description:_ DEPRECATED: Use `headscale.api_key_path` instead.
@@ -284,6 +293,17 @@ _Default:_ `""`
_Example:_ `"https://headscale.example.com/admin/oidc/callback"` _Example:_ `"https://headscale.example.com/admin/oidc/callback"`
## settings.oidc.role_claim
_Description:_ Optional OIDC claim containing the Headplane role to assign to newly created users.
A valid role claim takes precedence over default_role.
_Type:_ null or string
_Default:_ `null`
_Example:_ `"headplane_role"`
## settings.oidc.token_endpoint_auth_method ## settings.oidc.token_endpoint_auth_method
_Description:_ The token endpoint authentication method. _Description:_ The token endpoint authentication method.
+36
View File
@@ -74,6 +74,8 @@ oidc:
# userinfo_endpoint: "" # userinfo_endpoint: ""
# scope: "openid email profile" # scope: "openid email profile"
# subject_claims: ["open_id", "email"] # subject_claims: ["open_id", "email"]
# default_role: "member"
# role_claim: "headplane_role"
# allow_weak_rsa_keys: false # allow_weak_rsa_keys: false
# extra_params: # extra_params:
# foo: "bar" # foo: "bar"
@@ -226,6 +228,40 @@ role. All subsequent users are assigned the **Member** role (no access) by
default. An owner or admin must then assign them an appropriate role through default. An owner or admin must then assign them an appropriate role through
the Users page. the Users page.
### Automatic Role Assignment
You can change the role assigned to newly created OIDC users with
`oidc.default_role`:
```yaml
oidc:
# Valid values: admin, network_admin, it_admin, auditor, viewer, member
default_role: "viewer"
```
This is useful when Headscale already restricts who can authenticate by domain,
group, or user. For example, if Headscale only allows `@example.com` users to
sign in and all of those users should be able to view Headplane, set
`default_role: "viewer"`.
For per-user roles from your IdP, configure `oidc.role_claim` with the OIDC
claim that contains a Headplane role:
```yaml
oidc:
role_claim: "headplane_role"
```
The claim may be a string, such as `"admin"`, or an array containing one of the
valid roles. This lets providers such as Keycloak map groups or client roles to
a final Headplane role before login. When both `role_claim` and `default_role`
are configured, a valid role claim takes precedence for new users.
Automatic role assignment only applies when Headplane creates a user for the
first time. It does not overwrite roles that were already assigned in Headplane.
The **Owner** role is reserved for the first-login bootstrap and cannot be
granted by `default_role` or `role_claim`.
### API Key Sessions ### API Key Sessions
Users who sign in with a Headscale API key (instead of OIDC) are treated as Users who sign in with a Headscale API key (instead of OIDC) are treated as
+26
View File
@@ -423,6 +423,32 @@ in {
default = false; default = false;
description = "Whether to use PKCE when authenticating users."; description = "Whether to use PKCE when authenticating users.";
}; };
default_role = mkOption {
type = types.enum [
"admin"
"network_admin"
"it_admin"
"auditor"
"viewer"
"member"
];
default = "member";
description = ''
Role assigned to newly created OIDC users after the first owner is bootstrapped.
The owner role is reserved for the first-login bootstrap.
'';
};
role_claim = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Optional OIDC claim containing the Headplane role to assign to newly created users.
A valid role claim takes precedence over default_role.
'';
example = "headplane_role";
};
}; };
}; };
default = {}; default = {};
+20
View File
@@ -30,6 +30,26 @@ describe("findOrCreateUser", () => {
expect(role).toBe("member"); 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 () => { test("existing subject returns same id (idempotent)", async () => {
const id1 = await auth.findOrCreateUser("sub-1", { name: "Alice" }); const id1 = await auth.findOrCreateUser("sub-1", { name: "Alice" });
const id2 = 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"]); 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 () => { test("oidc.allow_weak_rsa_keys defaults to false", async () => {
const filePath = "/config/oidc-weak-rsa-default.yaml"; const filePath = "/config/oidc-weak-rsa-default.yaml";
writeYaml(filePath, { writeYaml(filePath, {
+47
View File
@@ -897,6 +897,53 @@ describe("identity resolution", () => {
expect(result.ok && result.value.name).toBe("Alice Smith"); 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 () => { test("falls back to given_name + family_name", async () => {
const result = await flowWithClaims({ const result = await flowWithClaims({
sub: "u1", sub: "u1",