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 () => {