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
+16 -5
View File
@@ -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
+6
View File
@@ -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"?',
+1
View File
@@ -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,
+33 -1
View File
@@ -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
View File
@@ -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 })