Skip to content

feat(auth): add OpenID Connect (OIDC) SSO login#238

Open
pchopinet wants to merge 6 commits into
mainfrom
feat/openid
Open

feat(auth): add OpenID Connect (OIDC) SSO login#238
pchopinet wants to merge 6 commits into
mainfrom
feat/openid

Conversation

@pchopinet

@pchopinet pchopinet commented May 30, 2026

Copy link
Copy Markdown
Member

What

Adds OpenID Connect (OIDC) single sign-on as a second login option next to the existing username/password form, for self-hosted instances that want to plug into an external identity provider (Keycloak, Authentik, Authelia, Google Workspace, Azure AD, ...).

How it works

  • WorkspaceOIDCBackend (subclass of mozilla-django-oidc's backend) holds all the custom rules:
    • JIT provisioning on first login; matched on later logins by email (the library default).
    • Optional email-domain allowlist (OIDC_ALLOWED_DOMAINS).
    • Optional email_verified enforcement (OIDC_REQUIRE_EMAIL_VERIFIED, off by default).
    • Readable Django username from a configurable claim (OIDC_USERNAME_CLAIM, default preferred_username), falling back to the email local-part then sub, sanitized with de-duplication.
  • Local login stays intact. The OIDC backend is added to AUTHENTICATION_BACKENDS only when OIDC is fully configured. The library backend raises ImproperlyConfigured on missing OP endpoints (and on an RS/ES sign algo without a JWKS endpoint), and Django instantiates every backend on each authenticate() call, so wiring it unconditionally would break username/password login on any instance that has not configured OIDC. OIDC_ENABLED gates this (and also requires the JWKS endpoint when the sign algo is RS/ES).
  • Login page: a conditional "Sign in with <provider>" button, shown only when OIDC is enabled.
  • oidc_discover management command: prints the OIDC_OP_* endpoints from an issuer's .well-known/openid-configuration, so admins do not have to hand-copy them.

No new database model, no migration.

Configuration (self-hosted)

python manage.py oidc_discover https://<idp>/realms/<realm>   # prints the OIDC_OP_* lines

then set in the environment:

OIDC_RP_CLIENT_ID, OIDC_RP_CLIENT_SECRET
OIDC_OP_AUTHORIZATION_ENDPOINT, OIDC_OP_TOKEN_ENDPOINT, OIDC_OP_USER_ENDPOINT, OIDC_OP_JWKS_ENDPOINT
OIDC_PROVIDER_NAME=Keycloak
# optional: OIDC_ALLOWED_DOMAINS, OIDC_REQUIRE_EMAIL_VERIFIED=true, OIDC_USERNAME_CLAIM

IdP redirect URI to register: {origin}/oidc/callback/.

Tests

  • 24 new tests (test_oidc.py, test_oidc_discover.py) covering claim verification, the domain allowlist, email_verified handling, username generation / dedup / sanitization, the conditional login button, the settings wiring (OIDC backend absent when disabled so local login cannot break), and the discovery command.
  • Full users module: 276 tests pass, coverage 92.3% (floor 85%); the two new code files are at 100%.

Add OIDC single sign-on alongside the existing username/password login,
for self-hosted instances that plug into an external identity provider
(Keycloak, Authentik, Google Workspace, Azure AD...).

- WorkspaceOIDCBackend: JIT provisioning, optional email-domain allowlist,
  optional email_verified enforcement, and a configurable username claim
  with fallback and de-duplication.
- Wired as a second auth backend only when OIDC is fully configured, so
  local username/password login keeps working when it is not.
- Conditional "Sign in with <provider>" button on the login page.
- oidc_discover management command prints the OIDC_OP_* endpoints from an
  issuer discovery document.
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements OpenID Connect (OIDC) single sign-on (SSO) authentication, allowing users to log in via external identity providers while preserving local username/password authentication. The implementation includes the OIDC backend, identity tracking, login page integration, password protection for OIDC-managed users, and comprehensive deployment guidance.

Changes

OIDC/SSO Authentication

Layer / File(s) Summary
OIDC Configuration & Dependency Wiring
pyproject.toml, workspace/settings.py, workspace/urls.py
Adds mozilla-django-oidc>=5.0.2 dependency; registers app; reads OIDC endpoints, credentials, signing algorithm, scopes, domain allowlist, and email verification flags from environment; computes OIDC_ENABLED by gating on required RP + JWKS/signing conditions; conditionally prepends WorkspaceOIDCBackend to AUTHENTICATION_BACKENDS; adds /oidc/ URL route for mozilla_django_oidc.urls callbacks.
OIDC Identity Model & Migration
workspace/users/models.py, workspace/users/migrations/0008_oidcidentity.py
Introduces OIDCIdentity model with UUID primary key, unique OIDC sub identifier, one-to-one user link with cascade deletion, and auto-timestamp; creates migration to persist this link.
OIDC Authentication Backend & Logic
workspace/users/services/oidc.py, workspace/users/tests/test_oidc.py
Implements WorkspaceOIDCBackend extending OIDCAuthenticationBackend to verify claims (email required, optional email_verified, optional domain allowlist), provision users with human-readable sanitized usernames from configurable claim with fallbacks, sync profile fields (first_name/last_name) on each login, and link identity via OIDCIdentity.get_or_create; adds is_oidc_managed(user) helper; comprehensive tests cover claim verification, username generation, user creation, settings wiring, identity sync, and OIDC backend presence.
Login Page Integration & OIDC Button
workspace/users/ui/views.py, workspace/urls.py, workspace/users/ui/templates/users/ui/auth/login.html
Creates WorkspaceLoginView subclass injecting oidc_enabled and oidc_provider_name from settings into login template context; updates URL routing to use new view; adds conditional OIDC login button with provider-specific text and oidc_authentication_init link when enabled; tests verify button visibility based on OIDC_ENABLED.
Settings Page Security & Password Protection
workspace/users/ui/views.py, workspace/users/views.py, workspace/users/ui/templates/users/ui/partials/settings_security.html
Extends settings_view with oidc_managed (per-user) and oidc_provider_name context; conditionally initializes password form Alpine component and hides form markup for OIDC-managed users, showing SSO indicator instead; guards ChangePasswordView.post with 403 early authorization check for OIDC-managed users; tests confirm password operations blocked and form hidden for IdP-managed users.
OIDC Discovery Management Command
workspace/users/management/commands/oidc_discover.py, workspace/users/tests/test_oidc_discover.py
Implements oidc_discover command to fetch OIDC issuer discovery document, validate required endpoints, and output environment variable assignments; tests verify URL normalization, endpoint extraction, and error handling for missing fields and fetch failures.
Deployment Configuration & Documentation
.env.example, docs/deployments/docker-compose/README.md, docs/deployments/docker-compose/docker-compose.yml, docs/deployments/kubernetes/secrets.yaml
Documents all OIDC environment variables across environment template, Docker Compose configuration table, docker-compose.yml web and celery-worker services, and Kubernetes secrets manifest.

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(auth): add OpenID Connect (OIDC) SSO login' is concise, clear, and accurately summarizes the main feature addition across all changes—implementing OIDC single sign-on as an alternative authentication method.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description clearly relates to the changeset: it explains the addition of OpenID Connect SSO as a login option, detailing the WorkspaceOIDCBackend implementation, configuration approach, management command, and test coverage. All major changes across configuration files, backend logic, models, UI templates, and tests are accounted for in the description.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

pchopinet added 3 commits May 30, 2026 15:16
Make the identity provider authoritative for OIDC-provisioned accounts:

- OIDCIdentity model marks a user as managed by the provider (created on
  first login, JIT or email match).
- update_user refreshes first_name / last_name from the claims on each
  login, only when the claim is present so a missing claim never wipes a
  value.
- ChangePasswordView returns 403 and the Security tab shows a managed-by-
  provider note instead of the password form for managed users.
@pchopinet

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🧹 Nitpick comments (1)
workspace/users/management/commands/oidc_discover.py (1)

40-42: 💤 Low value

Chain the re-raised CommandError to preserve the original cause.

Ruff flags this (B904). Chaining keeps the underlying httpx/JSON error in the traceback for easier diagnosis.

♻️ Proposed change
         except (httpx.HTTPError, ValueError) as exc:
             raise CommandError(
-                f'Failed to fetch discovery document from {scrub(url)}: {exc}')
+                f'Failed to fetch discovery document from {scrub(url)}: {exc}'
+            ) from exc
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workspace/users/management/commands/oidc_discover.py` around lines 40 - 42,
The current except block in oidc_discover.py re-raises a CommandError without
chaining the original exception; update the except handler that catches
(httpx.HTTPError, ValueError) as exc to re-raise the CommandError using
exception chaining (i.e., raise CommandError(f'Failed to fetch discovery
document from {scrub(url)}: {exc}') from exc) so the original httpx/JSON
exception is preserved in the traceback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@workspace/urls.py`:
- Around line 62-64: The OIDC routes are always mounted; change the URL config
so the include('mozilla_django_oidc.urls') is only added when
settings.OIDC_ENABLED is truthy: check settings.OIDC_ENABLED in
workspace/urls.py and wrap or conditionally add the path('oidc/',
include('mozilla_django_oidc.urls')) entry (the same gating used for the login
button) so that when settings.OIDC_ENABLED is False the OIDC init/callback
endpoints are not registered and return 404.

In `@workspace/users/services/oidc.py`:
- Around line 96-106: In _link_identity, stop using
OIDCIdentity.objects.get_or_create blindly; instead first read the incoming sub
(as now) and return if empty, then (1) check for a different existing
OIDCIdentity for this user: if one exists verify its sub equals the incoming sub
and if not raise/return an error (i.e. fail closed) instead of silently keeping
the old link; (2) check for any OIDCIdentity with the incoming sub that is
linked to a different user
(OIDCIdentity.objects.filter(sub=sub).exclude(user=user).exists()) and fail with
an error if found; (3) only if no conflicts create the new OIDCIdentity for this
user (or use get_or_create after the collision checks) and handle any
IntegrityError to be safe; use the function name _link_identity and model
OIDCIdentity to locate where to implement these checks.
- Around line 122-129: The username-generation loop can never terminate when
base is already 150 chars because adding a numeric suffix then truncating with
f'{base}{suffix}'[:150] produces the same string; change the loop to reserve
space for the numeric suffix before truncating: compute suffix_str =
str(suffix), determine allowed_base_len = max(0, 150 - len(suffix_str)), build
username as base[:allowed_base_len] + suffix_str, and use that for the existence
check in the while loop (symbols to edit: base, username, suffix, and the
self.UserModel.objects.filter(...).exists() loop). Ensure suffix increments and
allowed_base_len is recomputed each iteration so the loop can progress and
terminate.

In `@workspace/users/tests/test_oidc.py`:
- Around line 94-97: Add two regression tests to users/tests/test_oidc.py: one
that verifies _generate_username handles a 150-character preferred_username
collision by returning a truncated/unique variant (reference
backend._generate_username and the existing test_dedupes_on_collision for
pattern), and another that verifies linking behavior when a user already exists
without a bound sub or with a different/changed sub/email (create an existing
user, simulate OIDC payloads with missing/changed 'sub' and changed 'email',
call the backend.link_user/associate method or whatever path triggers
sub-binding, and assert the correct refusal or re-linking behavior). Ensure each
test fails against the current buggy implementation before applying the fix.

---

Nitpick comments:
In `@workspace/users/management/commands/oidc_discover.py`:
- Around line 40-42: The current except block in oidc_discover.py re-raises a
CommandError without chaining the original exception; update the except handler
that catches (httpx.HTTPError, ValueError) as exc to re-raise the CommandError
using exception chaining (i.e., raise CommandError(f'Failed to fetch discovery
document from {scrub(url)}: {exc}') from exc) so the original httpx/JSON
exception is preserved in the traceback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a732d629-a2fc-4dd9-b394-7affdbbaae75

📥 Commits

Reviewing files that changed from the base of the PR and between 9ca86fa and ac507a7.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • .env.example
  • docs/deployments/docker-compose/README.md
  • docs/deployments/docker-compose/docker-compose.yml
  • docs/deployments/kubernetes/secrets.yaml
  • pyproject.toml
  • workspace/settings.py
  • workspace/urls.py
  • workspace/users/management/__init__.py
  • workspace/users/management/commands/__init__.py
  • workspace/users/management/commands/oidc_discover.py
  • workspace/users/migrations/0008_oidcidentity.py
  • workspace/users/models.py
  • workspace/users/services/oidc.py
  • workspace/users/tests/test_oidc.py
  • workspace/users/tests/test_oidc_discover.py
  • workspace/users/ui/templates/users/ui/auth/login.html
  • workspace/users/ui/templates/users/ui/partials/settings_security.html
  • workspace/users/ui/views.py
  • workspace/users/views.py

Comment thread workspace/urls.py
Comment thread workspace/users/services/oidc.py Outdated
Comment thread workspace/users/services/oidc.py
Comment thread workspace/users/tests/test_oidc.py
pchopinet added 2 commits May 30, 2026 21:11
Two issues found in code review:

- _generate_username looped forever when a 150-char username already
  existed: f'{base}{suffix}'[:150] truncated back to base, so the dedup
  loop never advanced and the login request hung. Reserve room for the
  numeric suffix before truncating.
- _link_identity treated the stored OIDC subject as a write-once marker:
  a login whose sub disagreed with the stored one was silently accepted,
  and a sub already bound to another account raised IntegrityError (500)
  after the JIT user had already been created. It now fails closed on both
  mismatch directions, and create_user wraps creation and linking in one
  transaction so a conflict rolls the new user back instead of orphaning it.

Both ship with regression tests verified to fail against the previous code.
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.

1 participant