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/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 fad9a620..d96c0def 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -49,6 +49,74 @@ export async function loader({ request, context }: Route.LoaderArgs) { const identity = result.value; + 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 (hasAnyRestriction) { + const userEmail = identity.email; + const userName = identity.username; + const userGroups = identity.groups ?? []; + + // 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 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( + "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 if (allowedUsers.length > 0) { + // 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"); + } 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"); + } + } + } + const userId = await context.auth.findOrCreateUser(identity.subject, { name: identity.name, email: identity.email, 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, }; } 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 () => {