Sync - #626
Conversation
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.
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.
There was a problem hiding this comment.
Caution
The restriction enforcement reads the wrong config source. The callback checks context.config.oidc (Headplane's own config), but the Authentication Restrictions UI reads and writes context.hs.c.oidc (Headscale's config) — and this PR's own commit message says "from Headscale config". As written, restrictions configured through the settings page are silently not enforced during SSO login.
Reviewed changes
This PR adds OIDC login restriction enforcement, plumbs a groups claim through the OIDC identity, adds an error_restricted_access message, and stops the login page from auto-redirecting when an error state is present.
- OIDC restriction enforcement — the callback rejects users who don't match the configured
allowed_users/allowed_groups/allowed_domainsallowlists before creating a session. groupsclaim —OidcIdentityandOidcClaimsgain agroupsfield populated from the ID token or the userinfo response.- Config schema —
allowed_users/allowed_groups/allowed_domainsare added to Headplane's OIDC config schema and threaded intocreateOidcService. - Login redirect fix — the auto-redirect to
/oidc/startnow skips when anerror_*state is present. - Test fix —
nodes.test.tsadds a null guard before readingreassignedNode.user.name.
📘 Documentation and scope gap
The new oidc.allowed_* fields and the allowed_groups behavior are not documented anywhere — not in config.example.yaml, not in docs/, and there is no UI for them. Two concrete gaps:
allowed_groupsdepends on thegroupsclaim, but the defaultoidc.scopeis"openid email profile"—groupsis never requested, so group-based restrictions will silently never match unless the admin also extends the scope.- Because the settings UI edits Headscale's config while the enforcement (as written) reads Headplane's config, the values that are actually enforced cannot be viewed or set from the interface.
Technical details
# Document and align the new restriction config
## Affected sites
- app/routes/auth/oidc-callback.ts:52 — enforcement reads Headplane config, not the source the UI manages
- config.example.yaml — no entry for allowed_users / allowed_groups / allowed_domains
- app/server/config/config-schema.ts:131 — new fields undocumented
## Required outcome
- Decide which config is authoritative and make the UI, the schema, the example, and the docs agree.
- Document the groups scope requirement for allowed_groups to work.ℹ️ Nitpicks
app/routes/auth/login/page.tsx:37— the!hasErrorStateclause is redundant:!urlStatealready excludes every non-empty state (includingerror_*), so the second condition never changes the result.app/routes/auth/oidc-callback.ts:80— the domain comparison is case-sensitive; email domains are case-insensitive, soExample.cominallowed_domainswon't matchuser@example.com.app/routes/auth/oidc-callback.ts:109— the finalelse if (!userEmail && !userName)branch is unreachable: reaching it would require all three lists to be empty, buthasAnyRestrictionalready guards that, andidentity.usernamealways falls back to a non-empty string.
DeepSeek Pro (free via Pullfrog for OSS) | 𝕏
|
|
||
| const identity = result.value; | ||
|
|
||
| const oidcConfig = context.config.oidc; |
There was a problem hiding this comment.
Enforcement reads Headplane's own config (context.config.oidc), but the Authentication Restrictions UI reads/writes Headscale's config (context.hs.c.oidc) via context.hs.patch. As written, restrictions set through the UI are not enforced during SSO login, which contradicts the commit message ("from Headscale config") and the overview page's promise that "Headplane will also respect these settings." The first commit correctly read context.hs.c?.oidc; the second commit regressed it. Switch back to context.hs.c?.oidc (then the config-schema.ts additions and the context.ts wiring become unnecessary), or, if Headplane's own config is the intended source, update the settings UI and docs to match.
| const oidcConfig = context.config.oidc; | |
| const oidcConfig = context.hs.c?.oidc; |
| allowed_users: config.oidc.allowed_users, | ||
| allowed_groups: config.oidc.allowed_groups, | ||
| allowed_domains: config.oidc.allowed_domains, |
There was a problem hiding this comment.
These three properties are passed to createOidcService, but the OidcConfig interface (app/server/oidc/provider.ts:9) does not declare allowed_users / allowed_groups / allowed_domains — this object literal will fail excess-property checking under pnpm run typecheck. They are also dead: the OIDC service never reads them; enforcement happens in oidc-callback.ts directly against context.config.oidc. Delete these three lines.
| 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), |
There was a problem hiding this comment.
userInfo.groups as string[] is an unchecked cast: several IdPs return the groups claim as a single string (space- or comma-separated) rather than an array. If that happens, identity.groups is a string and the userGroups.some(...) call in oidc-callback.ts:68 throws TypeError: userGroups.some is not a function, 500ing every restricted login. Normalize the claim to an array before storing it.
| groups: claims.groups ?? (userInfo.groups as string[] | undefined), | |
| groups: normalizeGroups(claims.groups) ?? normalizeGroups(userInfo.groups), |

No description provided.