mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
feat(auth): support deriving roles from the IDP
Closes HP-352.
This commit is contained in:
@@ -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)).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>?",
|
||||
@@ -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<string, string>?",
|
||||
profile_picture_source: '"oidc" | "gravatar"?',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface OidcConfig {
|
||||
usePkce?: boolean;
|
||||
scope?: string;
|
||||
subjectClaims?: string[];
|
||||
roleClaim?: string;
|
||||
allowWeakRsaKeys?: boolean;
|
||||
extraParams?: Record<string, string>;
|
||||
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<string, unknown>;
|
||||
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,
|
||||
|
||||
+13
-2
@@ -89,6 +89,7 @@ export interface AuthService {
|
||||
findOrCreateUser(
|
||||
subject: string,
|
||||
profile?: { name?: string; email?: string; picture?: string },
|
||||
options?: { initialRole?: string },
|
||||
): Promise<string>;
|
||||
|
||||
linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean>;
|
||||
@@ -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<string> {
|
||||
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<number>`count(*)` }).from(users);
|
||||
@@ -622,6 +625,14 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
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> {
|
||||
const [existing] = await opts.db
|
||||
.select({ id: users.id })
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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