Skip to content

Sync - #626

Closed
toyorg wants to merge 3 commits into
tale:mainfrom
toyorg:main
Closed

Sync#626
toyorg wants to merge 3 commits into
tale:mainfrom
toyorg:main

Conversation

@toyorg

@toyorg toyorg commented Sep 1, 2026

Copy link
Copy Markdown

No description provided.

toyorg and others added 3 commits May 14, 2026 14:51
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.
@toyorg
toyorg requested a review from tale as a code owner September 1, 2026 07:40
@github-actions github-actions Bot added Authentication Authentication & Permissions Config Related to Headplane specific configuration labels Sep 1, 2026
@toyorg toyorg closed this Sep 1, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_domains allowlists before creating a session.
  • groups claimOidcIdentity and OidcClaims gain a groups field populated from the ID token or the userinfo response.
  • Config schemaallowed_users / allowed_groups / allowed_domains are added to Headplane's OIDC config schema and threaded into createOidcService.
  • Login redirect fix — the auto-redirect to /oidc/start now skips when an error_* state is present.
  • Test fixnodes.test.ts adds a null guard before reading reassignedNode.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_groups depends on the groups claim, but the default oidc.scope is "openid email profile"groups is 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 !hasErrorState clause is redundant: !urlState already excludes every non-empty state (including error_*), 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, so Example.com in allowed_domains won't match user@example.com.
  • app/routes/auth/oidc-callback.ts:109 — the final else if (!userEmail && !userName) branch is unreachable: reaching it would require all three lists to be empty, but hasAnyRestriction already guards that, and identity.username always falls back to a non-empty string.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏


const identity = result.value;

const oidcConfig = context.config.oidc;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const oidcConfig = context.config.oidc;
const oidcConfig = context.hs.c?.oidc;

Comment thread app/server/context.ts
Comment on lines +73 to +75
allowed_users: config.oidc.allowed_users,
allowed_groups: config.oidc.allowed_groups,
allowed_domains: config.oidc.allowed_domains,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
groups: claims.groups ?? (userInfo.groups as string[] | undefined),
groups: normalizeGroups(claims.groups) ?? normalizeGroups(userInfo.groups),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Authentication Authentication & Permissions Config Related to Headplane specific configuration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant