From 4b7bfe67e4650dfa34dc00c7dd747eb50241906c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bartos?= Date: Thu, 14 May 2026 14:51:20 +0200 Subject: [PATCH 1/2] Enforce OIDC login restrictions from Headscale config Add handling for `allowed_domains` and `allowed_users` in the OIDC callback, redirecting with `error_restricted_access` if access is denied. Include a corresponding error message on the login page. Also fix a test that accessed `user` without a null check. --- app/routes/auth/login/oidc-error.tsx | 8 ++++ app/routes/auth/oidc-callback.ts | 56 ++++++++++++++++++++++++++++ tests/integration/api/nodes.test.ts | 3 +- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/app/routes/auth/login/oidc-error.tsx b/app/routes/auth/login/oidc-error.tsx index 4aba5304..cc928df8 100644 --- a/app/routes/auth/login/oidc-error.tsx +++ b/app/routes/auth/login/oidc-error.tsx @@ -42,6 +42,14 @@ function getErrorMessage(code: string) { ); + case "error_restricted_access": + return ( + + Your account does not meet the authentication restrictions configured by your + administrator. Contact your administrator to request access. + + ); + case "error_auth_failed": return ( diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index fad9a620..dad699f1 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -49,6 +49,62 @@ export async function loader({ request, context }: Route.LoaderArgs) { const identity = result.value; + // Enforce OIDC login restrictions: allowed_domains and allowed_users + // from the Headscale configuration. When these are set, only users + // matching the criteria are permitted to authenticate. + const oidcConfig = context.hs.c?.oidc; + if (oidcConfig) { + const allowedDomains = oidcConfig.allowed_domains ?? []; + const allowedUsers = oidcConfig.allowed_users ?? []; + + if (allowedDomains.length > 0 || allowedUsers.length > 0) { + const userEmail = identity.email; + const userName = identity.username; + + if (!userEmail && !userName) { + log.warn( + "auth", + "OIDC login restricted but identity has no email or username for subject %s", + identity.subject, + ); + return redirect("/login?s=error_restricted_access"); + } + + if (allowedUsers.length > 0 && (allowedUsers.includes(userName) || (userEmail != null && allowedUsers.includes(userEmail)))) { + // User is explicitly allowed, skip domain check + } else if (allowedDomains.length > 0) { + if (!userEmail) { + log.warn( + "auth", + "OIDC login restricted but identity has no email claim for subject %s", + identity.subject, + ); + return redirect("/login?s=error_restricted_access"); + } + + const emailDomain = userEmail.split("@")[1]; + if (!emailDomain || !allowedDomains.includes(emailDomain)) { + log.warn( + "auth", + "OIDC login rejected for %s — domain %s is not in allowed_domains: %s", + userEmail, + emailDomain ?? "(none)", + allowedDomains.join(", "), + ); + return redirect("/login?s=error_restricted_access"); + } + } else { + // allowed_users is set but user is not in it + log.warn( + "auth", + "OIDC login rejected for %s — username not in allowed_users", + userName, + ); + return redirect("/login?s=error_restricted_access"); + } + } + } + const userId = await context.auth.findOrCreateUser(identity.subject, { name: identity.name, email: identity.email, diff --git a/tests/integration/api/nodes.test.ts b/tests/integration/api/nodes.test.ts index 7565dd90..ea48f1ee 100644 --- a/tests/integration/api/nodes.test.ts +++ b/tests/integration/api/nodes.test.ts @@ -53,7 +53,8 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { await client.setNodeUser(workingNodeId, user.id); const reassignedNode = await client.getNode(workingNodeId); expect(reassignedNode).toBeDefined(); - expect(reassignedNode.user.name).toBe(user.name); + expect(reassignedNode.user).toBeDefined(); + expect(reassignedNode.user!.name).toBe(user.name); }); test("nodes can be expired", async () => { From ee8d4a96328f901e0843b286530441c76d4551ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bartos?= Date: Mon, 18 May 2026 11:12:38 +0200 Subject: [PATCH 2/2] Add OIDC group restriction and fix auto-redirect Implement OIDC allowed_groups check in addition to existing allowed_domains and allowed_users restrictions. Also prevent auto-redirect to OIDC when the login page has an error state, so users can see the error message before being redirected. --- app/routes/auth/login/page.tsx | 5 +++- app/routes/auth/oidc-callback.ts | 44 +++++++++++++++++++----------- app/server/config/config-schema.ts | 4 +++ app/server/context.ts | 3 ++ app/server/oidc/provider.ts | 4 +++ 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx index 166ebdc2..eff96d34 100644 --- a/app/routes/auth/login/page.tsx +++ b/app/routes/auth/login/page.tsx @@ -32,7 +32,10 @@ export async function loader({ request, context }: Route.LoaderArgs) { ) : undefined; - if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") { + // Don't auto-redirect to OIDC when there's an error state — the user needs + // to see the error message first. + const hasErrorState = urlState?.startsWith("error_"); + if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && !urlState && !hasErrorState) { return redirect("/oidc/start"); } diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index dad699f1..d96c0def 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -49,29 +49,24 @@ export async function loader({ request, context }: Route.LoaderArgs) { const identity = result.value; - // Enforce OIDC login restrictions: allowed_domains and allowed_users - // from the Headscale configuration. When these are set, only users - // matching the criteria are permitted to authenticate. - const oidcConfig = context.hs.c?.oidc; + const oidcConfig = context.config.oidc; if (oidcConfig) { const allowedDomains = oidcConfig.allowed_domains ?? []; + const allowedGroups = oidcConfig.allowed_groups ?? []; const allowedUsers = oidcConfig.allowed_users ?? []; + const hasAnyRestriction = + allowedDomains.length > 0 || allowedGroups.length > 0 || allowedUsers.length > 0; - if (allowedDomains.length > 0 || allowedUsers.length > 0) { + if (hasAnyRestriction) { const userEmail = identity.email; const userName = identity.username; + const userGroups = identity.groups ?? []; - if (!userEmail && !userName) { - log.warn( - "auth", - "OIDC login restricted but identity has no email or username for subject %s", - identity.subject, - ); - return redirect("/login?s=error_restricted_access"); - } - + // Check if user is explicitly allowed by username or email if (allowedUsers.length > 0 && (allowedUsers.includes(userName) || (userEmail != null && allowedUsers.includes(userEmail)))) { - // User is explicitly allowed, skip domain check + // User is explicitly allowed, skip other checks + } else if (allowedGroups.length > 0 && userGroups.some((g) => allowedGroups.includes(g))) { + // User is in an allowed group, skip other checks } else if (allowedDomains.length > 0) { if (!userEmail) { log.warn( @@ -93,7 +88,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { ); return redirect("/login?s=error_restricted_access"); } - } else { + } else if (allowedUsers.length > 0) { // allowed_users is set but user is not in it log.warn( "auth", @@ -101,6 +96,23 @@ export async function loader({ request, context }: Route.LoaderArgs) { userName, ); return redirect("/login?s=error_restricted_access"); + } else if (allowedGroups.length > 0) { + // allowed_groups is set but user is not in any allowed group + log.warn( + "auth", + "OIDC login rejected for %s — groups [%s] not in allowed_groups: %s", + userName, + userGroups.join(", "), + allowedGroups.join(", "), + ); + return redirect("/login?s=error_restricted_access"); + } else if (!userEmail && !userName) { + log.warn( + "auth", + "OIDC login restricted but identity has no email or username for subject %s", + identity.subject, + ); + return redirect("/login?s=error_restricted_access"); } } } diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index 63485e9e..ec89cc47 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -128,6 +128,10 @@ const oidcConfig = type({ use_end_session: "boolean = false", token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', + allowed_users: type("string[]").pipe(normalizeStringArray).optional(), + allowed_groups: type("string[]").pipe(normalizeStringArray).optional(), + allowed_domains: type("string[]").pipe(normalizeStringArray).optional(), + // Old/deprecated options strict_validation: type("unknown").narrow(deprecatedField()).optional(), }); diff --git a/app/server/context.ts b/app/server/context.ts index 7f524c94..f6093392 100644 --- a/app/server/context.ts +++ b/app/server/context.ts @@ -70,6 +70,9 @@ export async function createAppContext(config: HeadplaneConfig) { config.oidc.token_endpoint_auth_method === "client_secret_jwt" ? undefined : config.oidc.token_endpoint_auth_method, + allowed_users: config.oidc.allowed_users, + allowed_groups: config.oidc.allowed_groups, + allowed_domains: config.oidc.allowed_domains, usePkce: config.oidc.use_pkce, scope: config.oidc.scope, subjectClaims: config.oidc.subject_claims, diff --git a/app/server/oidc/provider.ts b/app/server/oidc/provider.ts index 6cf3beee..745b8714 100644 --- a/app/server/oidc/provider.ts +++ b/app/server/oidc/provider.ts @@ -51,6 +51,7 @@ export interface OidcIdentity { username: string; email?: string; picture?: string; + groups?: string[]; idToken?: string; } @@ -106,6 +107,7 @@ interface OidcClaims extends JWTPayload { preferred_username?: string; email?: string; picture?: string; + groups?: string[]; } interface TokenResponse { @@ -732,6 +734,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { claims.preferred_username ?? (userInfo.preferred_username as string | undefined), email: claims.email ?? (userInfo.email as string | undefined), picture: claims.picture ?? (userInfo.picture as string | undefined), + groups: claims.groups ?? (userInfo.groups as string[] | undefined), sub: claims.sub ?? readClaimAsString(userInfo, "sub"), }; } catch (cause) { @@ -776,6 +779,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService { username, email: claims.email, picture, + groups: claims.groups, idToken, }; }