From 0c4d175eb7f94b41ee2bb597660b7ea22f2f3272 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 20 Jun 2026 11:53:09 -0400 Subject: [PATCH] feat(auth): support deriving roles from the IDP Closes HP-352. --- CHANGELOG.md | 1 + app/routes/auth/oidc-callback.ts | 21 +++++++++--- app/server/config/config-schema.ts | 6 ++++ app/server/context.ts | 1 + app/server/oidc/provider.ts | 34 ++++++++++++++++++- app/server/web/auth.ts | 15 +++++++-- config.example.yaml | 13 ++++++++ docs/NixOS-options.md | 20 ++++++++++++ docs/features/sso.md | 36 ++++++++++++++++++++ nix/options.nix | 26 +++++++++++++++ tests/unit/auth/auth-service.test.ts | 20 ++++++++++++ tests/unit/config/config-file.test.ts | 20 ++++++++++++ tests/unit/oidc/provider.test.ts | 47 +++++++++++++++++++++++++++ 13 files changed, 252 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db3f6ac..56a3c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 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)). --- diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 7fa6471..53a0b4a 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -1,6 +1,7 @@ import { data, redirect } from "react-router"; import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; +import { Roles } from "~/server/web/roles"; import log from "~/utils/log"; import { createOidcStateCookie } from "~/utils/oidc-state"; @@ -48,12 +49,22 @@ export async function loader({ request, context }: Route.LoaderArgs) { } 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, { - name: identity.name, - email: identity.email, - picture: identity.picture, - }); + const userId = await context.auth.findOrCreateUser( + identity.subject, + { + name: identity.name, + email: identity.email, + picture: identity.picture, + }, + { + initialRole: claimedRole ?? context.config.oidc?.default_role, + }, + ); try { // Looks up the Headscale user that matches this OIDC identity. We use diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index a646ebf..ef9c1c9 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -114,6 +114,8 @@ const partialHeadscaleConfig = type({ tls_cert_path: "string.lower?", }); +const assignableRole = '"admin" | "network_admin" | "it_admin" | "auditor" | "viewer" | "member"'; + const oidcConfig = type({ enabled: "boolean = true", issuer: "string.url", @@ -147,6 +149,8 @@ const oidcConfig = type({ disable_api_key_login: "boolean = false", scope: 'string = "openid email profile"', subject_claims: type("string[]").pipe(normalizeStringArray).optional(), + default_role: `${assignableRole} = "member"`, + role_claim: "string?", allow_weak_rsa_keys: "boolean = false", profile_picture_source: '"oidc" | "gravatar" = "oidc"', extra_params: "Record?", @@ -174,6 +178,8 @@ const partialOidcConfig = type({ disable_api_key_login: "boolean?", scope: "string?", subject_claims: type("string[]").pipe(normalizeStringArray).optional(), + default_role: `${assignableRole}?`, + role_claim: "string?", allow_weak_rsa_keys: "boolean?", extra_params: "Record?", profile_picture_source: '"oidc" | "gravatar"?', diff --git a/app/server/context.ts b/app/server/context.ts index f1e3696..ebbbdae 100644 --- a/app/server/context.ts +++ b/app/server/context.ts @@ -151,6 +151,7 @@ function buildOidc( usePkce: config.oidc.use_pkce, scope: config.oidc.scope, subjectClaims: config.oidc.subject_claims, + roleClaim: config.oidc.role_claim, allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys, extraParams: config.oidc.extra_params, profilePictureSource: config.oidc.profile_picture_source, diff --git a/app/server/oidc/provider.ts b/app/server/oidc/provider.ts index 58ae065..1f3dd43 100644 --- a/app/server/oidc/provider.ts +++ b/app/server/oidc/provider.ts @@ -23,6 +23,7 @@ export interface OidcConfig { usePkce?: boolean; scope?: string; subjectClaims?: string[]; + roleClaim?: string; allowWeakRsaKeys?: boolean; extraParams?: Record; profilePictureSource?: "oidc" | "gravatar"; @@ -51,6 +52,7 @@ export interface OidcIdentity { username: string; email?: string; picture?: string; + role?: string; idToken?: string; } @@ -106,6 +108,7 @@ interface OidcClaims extends JWTPayload { preferred_username?: string; email?: string; picture?: string; + [claim: string]: unknown; } interface TokenResponse { @@ -696,7 +699,11 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { const needsEnrichment = !claims.name && !claims.email && !claims.picture && !!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; } @@ -715,6 +722,9 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { } const userInfo = (await response.json()) as Record; + const roleClaimValue = config.roleClaim + ? (claims[config.roleClaim] ?? userInfo[config.roleClaim]) + : undefined; const subjectClaimValues = Object.fromEntries( getSubjectClaimOrder() .filter((claim) => claim !== "sub") @@ -726,6 +736,9 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { return { ...claims, ...subjectClaimValues, + ...(config.roleClaim && roleClaimValue !== undefined + ? { [config.roleClaim]: roleClaimValue } + : {}), name: claims.name ?? (userInfo.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), @@ -777,6 +790,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { username, email: claims.email, picture, + role: config.roleClaim ? resolveRoleClaim(claims, config.roleClaim) : undefined, idToken, }; } @@ -831,6 +845,24 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { 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 { status, discover, diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index 758fc95..fa48565 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -89,6 +89,7 @@ export interface AuthService { findOrCreateUser( subject: string, profile?: { name?: string; email?: string; picture?: string }, + options?: { initialRole?: string }, ): Promise; linkHeadscaleUser(userId: string, headscaleUserId: string): Promise; @@ -582,6 +583,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService { async function findOrCreateUser( subject: string, profile?: { name?: string; email?: string; picture?: string }, + options?: { initialRole?: string }, ): Promise { 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; } + const initialRole = normalizeInitialRole(options?.initialRole) ?? "member"; const id = ulid(); await opts.db.insert(users).values({ id, @@ -606,8 +609,8 @@ export function createAuthService(opts: AuthServiceOptions): AuthService { name: profile?.name, email: profile?.email, picture: profile?.picture, - role: "member", - caps: capsForRole("member"), + role: initialRole, + caps: capsForRole(initialRole), }); const [{ count }] = await opts.db.select({ count: sql`count(*)` }).from(users); @@ -622,6 +625,14 @@ export function createAuthService(opts: AuthServiceOptions): AuthService { return id; } + function normalizeInitialRole(role: string | undefined): Exclude | undefined { + if (role && role !== "owner" && role in Roles) { + return role as Exclude; + } + + return undefined; + } + async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise { const [existing] = await opts.db .select({ id: users.id }) diff --git a/config.example.yaml b/config.example.yaml index 9e5176b..4b42d50 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -290,6 +290,19 @@ integration: # - "open_id" # - "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. # # This is disabled by default because it lowers token verification security and # # should only be used as a temporary compatibility workaround. diff --git a/docs/NixOS-options.md b/docs/NixOS-options.md index ea9316b..1b3662b 100644 --- a/docs/NixOS-options.md +++ b/docs/NixOS-options.md @@ -252,6 +252,15 @@ _Type:_ boolean _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 _Description:_ DEPRECATED: Use `headscale.api_key_path` instead. @@ -284,6 +293,17 @@ _Default:_ `""` _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 _Description:_ The token endpoint authentication method. diff --git a/docs/features/sso.md b/docs/features/sso.md index 41a5b81..374039a 100644 --- a/docs/features/sso.md +++ b/docs/features/sso.md @@ -74,6 +74,8 @@ oidc: # userinfo_endpoint: "" # scope: "openid email profile" # subject_claims: ["open_id", "email"] + # default_role: "member" + # role_claim: "headplane_role" # allow_weak_rsa_keys: false # extra_params: # 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 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 Users who sign in with a Headscale API key (instead of OIDC) are treated as diff --git a/nix/options.nix b/nix/options.nix index 33581d1..f789d5b 100644 --- a/nix/options.nix +++ b/nix/options.nix @@ -423,6 +423,32 @@ in { default = false; 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 = {}; diff --git a/tests/unit/auth/auth-service.test.ts b/tests/unit/auth/auth-service.test.ts index 246bcf8..27240b7 100644 --- a/tests/unit/auth/auth-service.test.ts +++ b/tests/unit/auth/auth-service.test.ts @@ -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" }); diff --git a/tests/unit/config/config-file.test.ts b/tests/unit/config/config-file.test.ts index 913c2c7..9b763af 100644 --- a/tests/unit/config/config-file.test.ts +++ b/tests/unit/config/config-file.test.ts @@ -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, { diff --git a/tests/unit/oidc/provider.test.ts b/tests/unit/oidc/provider.test.ts index efd1ad8..4fcfe27 100644 --- a/tests/unit/oidc/provider.test.ts +++ b/tests/unit/oidc/provider.test.ts @@ -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",