Skip to content

feat(auth): native OIDC group → role mapping + SSO docs fixes (#3485)#3489

Merged
Yeraze merged 1 commit into
mainfrom
feature/oidc-groups
Jun 16, 2026
Merged

feat(auth): native OIDC group → role mapping + SSO docs fixes (#3485)#3489
Yeraze merged 1 commit into
mainfrom
feature/oidc-groups

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Implements the feature requested in #3485 (native OIDC group→role mapping) and fixes the SSO documentation gaps the reporter flagged.

Background — verification of the existing claims

Before building, I verified the maintainer's reply on #3485 against the code:

  • DISABLE_LOCAL_AUTH=true exists and hard-blocks /api/auth/login (403), frontend hides the form.
  • ✅ Proxy-auth group vars (PROXY_AUTH_ENABLED, PROXY_AUTH_ADMIN_GROUPS, PROXY_AUTH_NORMAL_USER_GROUPS with "empty = all", PROXY_AUTH_JWT_GROUPS_CLAIM) all exist and re-evaluate on every request.
  • ✅ Native OIDC had no group mapping — the genuine gap.
  • ⚠️ The docs were wrong: sso.md didn't name DISABLE_LOCAL_AUTH and falsely claimed local auth "remains available via API" when disabled (it 403s with no bypass). Both fixed here.

Feature

Three new env vars (parsed in environment.ts, same conventions as PROXY_AUTH_*):

Variable Default Purpose
OIDC_GROUPS_CLAIM groups ID-token claim with the user's groups; dot notation for nested (e.g. realm_access.roles).
OIDC_ADMIN_GROUPS (empty) Groups granted admin.
OIDC_ALLOWED_GROUPS (empty) Groups allowed to log in; empty = all.

handleOIDCCallback now resolves the groups claim and:

  • Admin tracks groups on every login (create / migrate / existing-user update) when OIDC_ADMIN_GROUPS is set — promote and demote; the IdP becomes authoritative and the first-login bootstrap no longer applies. When unset, the existing bootstrap + manual-promotion behaviour is unchanged.
  • OIDC_ALLOWED_GROUPS gates login for all users (admins always pass), applied before user create/update so revoking a group blocks the next login.
  • Changes apply on next login (OIDC is session-based; documented vs proxy auth's per-request).

The dot-notation traversal + group normalization are factored into src/server/auth/claims.ts and shared by proxyAuth and oidcAuth (the maintainer's plan called for reuse). proxyAuth re-exports normalizeGroups for back-compat. No schema/migrationisAdmin is an existing boolean column already updated per-login by proxy auth.

Design decisions (flagged in the issue scoping)

  • Demotion / lockout: when OIDC_ADMIN_GROUPS is set, a user not in an admin group is demoted (mirrors proxy auth). Docs warn to keep your account in the admin group or a break-glass local admin.
  • Bootstrap interaction: group-admin overrides the first-login bootstrap only when admin groups are configured.
  • Migration branch: a local→OIDC migrated user keeps its existing admin status unless admin groups are configured.

Docs

  • sso.md: documented DISABLE_LOCAL_AUTH, corrected the false "available via API" line, added a Group → Role Mapping section (Keycloak realm_access.roles, Authentik groups) and a pointer to the proxy-auth group vars.
  • index.md: added the three new vars to the config reference.

Tests

src/server/auth/claims.test.ts covers getNestedValue/normalizeGroups/groupsContainAny and the pure resolveGroupRole (admin match, demotion, allowed-gate, admins-bypass-gate, fail-closed). Existing proxyAuth.test.ts passes unchanged after the refactor.

Validation

  • New + proxyAuth tests: 61 pass
  • tsc --noEmit: clean
  • Full Vitest suite: 6496 pass, 0 fail, 0 suite failures

Closes #3485.

🤖 Generated with Claude Code

Adds direct-OIDC group mapping (no reverse proxy required), plus corrects
and expands the SSO docs.

Feature:
- New env vars OIDC_GROUPS_CLAIM (default "groups", dot-notation for nested
  claims like realm_access.roles), OIDC_ADMIN_GROUPS, OIDC_ALLOWED_GROUPS.
- handleOIDCCallback resolves the groups claim and derives admin/allowed.
  When OIDC_ADMIN_GROUPS is set, admin status tracks group membership on every
  login (promote AND demote) on create/migrate/update paths and the IdP is
  authoritative; when unset, the first-login bootstrap + manual promotion is
  preserved. OIDC_ALLOWED_GROUPS gates login (admins always pass). Changes
  apply on next login (OIDC is session-based, unlike per-request proxy auth).
- Extract the shared claim helpers (getNestedValue, normalizeGroups,
  groupsContainAny) into src/server/auth/claims.ts and reuse them in both
  proxyAuth and oidcAuth; add a pure, unit-tested resolveGroupRole(). proxyAuth
  re-exports normalizeGroups for back-compat. No schema/migration changes.

Docs:
- Document DISABLE_LOCAL_AUTH in the SSO guide (was missing) and fix the
  inaccurate "still available via API" claim — /api/auth/login returns 403
  unconditionally when disabled, no bypass.
- Add a Group → Role Mapping section with Keycloak/Authentik examples and a
  pointer to the proxy-auth group vars; list the new vars in the config index.

Tests: src/server/auth/claims.test.ts (helpers + resolveGroupRole incl.
allowed-gate, demotion, fail-closed). Full suite green (6496).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(auth): native OIDC group → role mapping + SSO docs fixes

  • Gather context
  • Read changed files
  • Review code quality and security
  • Post review feedback

This is a well-structured PR that fills a genuine gap in the auth stack. The core logic is clean and the factoring into claims.ts is the right call. A few observations worth addressing:


Security

getNestedValue accepts arbitrary paths from env config (low risk, but worth noting)

src/server/auth/claims.ts:17-28 — the obj[path] lookup for URL-style claims passes path directly as an object key. Since path comes from OIDC_GROUPS_CLAIM (operator-controlled env var), this isn't a user-controlled injection vector, but it's worth a code comment clarifying the trust boundary to prevent future confusion.

Username derived from sub with no sanitization (oidcAuth.ts:190)

const username = preferredUsername || email?.split('@')[0] || sub.substring(0, 20);

The sub.substring(0, 20) fallback could produce usernames with characters that are problematic depending on other parts of the system (e.g. /, \, or null bytes if sub is exotic). A light sanitization or character allowlist on this path would be defensive. This is pre-existing code, but the new OIDC paths make it more reachable.

OIDC_CLIENT_SECRET not logged (good), but OIDC_ADMIN_GROUPS is logged in plaintext

environment.ts:737 logs the configured admin group names. This is intentional and matches PROXY_AUTH_ADMIN_GROUPS behavior. Just confirming the parity is deliberate.


Code Quality

Unnecessary null initialization and re-assignment (oidcAuth.ts:211-212)

let user: User | null = null;
user = await databaseService.auth.getUserByOidcSubject(sub) as User | null;

This is a pre-existing pattern, but the new code around line 239-245 repeats it for existingUser:

let existingUser: User | null = null;
existingUser = await databaseService.findUserByUsernameAsync(username) as User | null;

These can be const with a direct await — no functional impact but cleaner.

databaseService.auditLogAsync is fire-and-forget without error handling (oidcAuth.ts:269-275, 346-352)

databaseService.auditLogAsync(
  user!.id,
  'user_migrated_to_oidc',
  ...
);

This is a pre-existing pattern in the file, but it means audit log failures are silent. A void prefix or .catch(logger.error) would make the intentionality explicit.

getEnvironmentConfig() called at callback time, not once at module load

oidcAuth.ts:195 — env config is re-fetched on every OIDC callback. This is fine for production (the config is a cached singleton), but it differs from the pattern in proxyAuth.ts where getEnvironmentConfig() is called inside each function rather than cached at the top. Consistent, so no issue — just noting it.


Test Coverage

The claims.test.ts suite (src/server/auth/claims.test.ts) covers the pure functions well. Two gaps worth considering:

  1. Integration: no test for the oidcAuth.handleOIDCCallback path with group restrictions. The OIDC callback logic (oidcAuth.ts:196-208) — particularly the !groupRole.allowed rejection — is only exercised by the pure resolveGroupRole test. An integration-level test for handleOIDCCallback with a mock databaseService and mock token response would catch regressions in the wiring (e.g. if someone reorders the allowed-check and user-lookup).

  2. Missing: test for normalizeGroups with a non-string, non-array, non-{name} object (e.g. { value: 'admin' }). Currently normalizeGroups({value:'admin'}) silently returns [] — that's the right fail-closed behavior, but an explicit test makes it a contract rather than an accident.


Minor / Nits

  • oidcAuth.ts:224: The condition groupRole.adminGroupsConfigured && groupRole.isAdmin !== user.isAdmin only updates isAdmin on change. This avoids a no-op DB write, which is good, but means a demotion from true→false requires isAdmin !== user.isAdmin to be truthy — which it is. Logic is correct but a comment would help readers.

  • claims.ts:48: The { name: unknown } object detection is Keycloak/SCIM specific. If other IdPs use { value: string } (SCIM complexObjects), this would silently drop those groups. Not a blocker, but worth a doc comment.

  • environment.ts:576-583: oidcAdminGroups and oidcAllowedGroups don't have a *Provided boolean in the interface, unlike most other array configs. This is inconsistent with the pattern (compare proxyAuthAdminGroups — also no Provided bool). Consistent omission, acceptable.


Summary

The PR is solid: the abstraction layer in claims.ts is clean, the fail-closed behavior (allowed=false when groups list is configured but user isn't in it) is correct, and the backward-compatibility guarantee (no change when OIDC_ADMIN_GROUPS is unset) is well-tested. The main actionable items are:

  1. Consider adding an integration test for handleOIDCCallback with the allowed-groups gate to catch wiring regressions.
  2. Sanitize or validate the sub.substring(0,20) username fallback.
  3. Add void or .catch() to the fire-and-forget auditLogAsync calls to make intentionality explicit.

Items 2 and 3 are pre-existing patterns — whether to fix them in this PR is up to the maintainer.

@Yeraze Yeraze merged commit 37214a3 into main Jun 16, 2026
19 checks passed
@Yeraze Yeraze deleted the feature/oidc-groups branch June 16, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] SSO groups and disable local users by env variable

1 participant