Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/routes/auth/login/oidc-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ function getErrorMessage(code: string) {
</Card.Text>
);

case "error_restricted_access":
return (
<Card.Text>
Your account does not meet the authentication restrictions configured by your
administrator. Contact your administrator to request access.
</Card.Text>
);

case "error_auth_failed":
return (
<Card.Text>
Expand Down
5 changes: 4 additions & 1 deletion app/routes/auth/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down
68 changes: 68 additions & 0 deletions app/routes/auth/oidc-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions app/server/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
3 changes: 3 additions & 0 deletions app/server/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions app/server/oidc/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface OidcIdentity {
username: string;
email?: string;
picture?: string;
groups?: string[];
idToken?: string;
}

Expand Down Expand Up @@ -106,6 +107,7 @@ interface OidcClaims extends JWTPayload {
preferred_username?: string;
email?: string;
picture?: string;
groups?: string[];
}

interface TokenResponse {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -776,6 +779,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
username,
email: claims.email,
picture,
groups: claims.groups,
idToken,
};
}
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/api/nodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading