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
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,28 @@
# OAUTH_MICROSOFT_CLIENT_ID=
# OAUTH_MICROSOFT_CLIENT_SECRET=

# --- OIDC Login / SSO (optional) --------------------------------------------
# Let users sign in with an external identity provider (Keycloak, Authentik,
# Authelia, Google Workspace, Azure AD...) next to the local login form. It is
# enabled only when the client id/secret and the OP endpoints below are all set;
# the local username/password login keeps working either way.
# Tip: run `python manage.py oidc_discover https://<issuer>` to print the four
# OIDC_OP_* endpoints. Register {your-origin}/oidc/callback/ as the redirect URI.
# First login auto-creates the account; the provider then owns the display name
# (synced on each login) and the local password change is disabled for it.
# 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=OpenID # Label shown on the login button
# OIDC_RP_SIGN_ALGO=RS256 # ID token signing algorithm
# OIDC_RP_SCOPES=openid email profile # 'profile' is needed for the name
# OIDC_ALLOWED_DOMAINS= # Comma-separated email-domain allowlist
# OIDC_REQUIRE_EMAIL_VERIFIED=false # Require the email_verified claim
# OIDC_USERNAME_CLAIM=preferred_username # Claim used for the Django username

# --- Web Push Notifications (optional) --------------------------------------
# Generate VAPID keys: python -c "from pywebpush import webpush; from cryptography.hazmat.primitives.asymmetric import ec; ..."
# WEBPUSH_VAPID_PRIVATE_KEY=
Expand Down
12 changes: 12 additions & 0 deletions docs/deployments/docker-compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ All settings are configurable via environment variables or a `.env` file next to
| `OAUTH_GENERIC_SCOPES` | *(empty)* | Space-separated OAuth2 scopes |
| `OAUTH_GENERIC_IMAP_HOST` | *(empty)* | IMAP server hostname |
| `OAUTH_GENERIC_SMTP_HOST` | *(empty)* | SMTP server hostname |
| `OIDC_RP_CLIENT_ID` | *(empty)* | OIDC client ID. Set this plus the secret and the four endpoints to enable SSO login |
| `OIDC_RP_CLIENT_SECRET` | *(empty)* | OIDC client secret |
| `OIDC_OP_AUTHORIZATION_ENDPOINT` | *(empty)* | Provider authorization endpoint |
| `OIDC_OP_TOKEN_ENDPOINT` | *(empty)* | Provider token endpoint |
| `OIDC_OP_USER_ENDPOINT` | *(empty)* | Provider userinfo endpoint |
| `OIDC_OP_JWKS_ENDPOINT` | *(empty)* | Provider JWKS endpoint (required for RS256) |
| `OIDC_PROVIDER_NAME` | `OpenID` | Label shown on the login button |
| `OIDC_RP_SIGN_ALGO` | `RS256` | ID token signing algorithm |
| `OIDC_RP_SCOPES` | `openid email profile` | Requested scopes (`profile` provides the display name) |
| `OIDC_ALLOWED_DOMAINS` | *(empty)* | Comma-separated email-domain allowlist for sign-up |
| `OIDC_REQUIRE_EMAIL_VERIFIED` | *(empty)* | Set to `1` to require the `email_verified` claim |
| `OIDC_USERNAME_CLAIM` | `preferred_username` | Claim used for the Django username |
| `AI_API_KEY` | *(empty)* | OpenAI API key (or compatible provider). AI disabled if empty |
| `AI_BASE_URL` | *(empty)* | Custom base URL for the LLM API (Ollama, LM Studio, etc.) |
| `AI_MODEL` | `gpt-5` | Default LLM model for chat and tasks |
Expand Down
28 changes: 28 additions & 0 deletions docs/deployments/docker-compose/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ services:
- OAUTH_GENERIC_SCOPES=${OAUTH_GENERIC_SCOPES:-}
- OAUTH_GENERIC_IMAP_HOST=${OAUTH_GENERIC_IMAP_HOST:-}
- OAUTH_GENERIC_SMTP_HOST=${OAUTH_GENERIC_SMTP_HOST:-}
# OIDC login / SSO (optional). Run `manage.py oidc_discover <issuer>`
# to get the four OIDC_OP_* endpoints. Redirect URI: <origin>/oidc/callback/
- OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID:-}
- OIDC_RP_CLIENT_SECRET=${OIDC_RP_CLIENT_SECRET:-}
- OIDC_OP_AUTHORIZATION_ENDPOINT=${OIDC_OP_AUTHORIZATION_ENDPOINT:-}
- OIDC_OP_TOKEN_ENDPOINT=${OIDC_OP_TOKEN_ENDPOINT:-}
- OIDC_OP_USER_ENDPOINT=${OIDC_OP_USER_ENDPOINT:-}
- OIDC_OP_JWKS_ENDPOINT=${OIDC_OP_JWKS_ENDPOINT:-}
- OIDC_PROVIDER_NAME=${OIDC_PROVIDER_NAME:-OpenID}
- OIDC_RP_SIGN_ALGO=${OIDC_RP_SIGN_ALGO:-RS256}
- OIDC_RP_SCOPES=${OIDC_RP_SCOPES:-openid email profile}
- OIDC_ALLOWED_DOMAINS=${OIDC_ALLOWED_DOMAINS:-}
- OIDC_REQUIRE_EMAIL_VERIFIED=${OIDC_REQUIRE_EMAIL_VERIFIED:-}
- OIDC_USERNAME_CLAIM=${OIDC_USERNAME_CLAIM:-preferred_username}
# AI (optional)
- AI_API_KEY=${AI_API_KEY:-}
- AI_BASE_URL=${AI_BASE_URL:-}
Expand Down Expand Up @@ -68,6 +82,20 @@ services:
- OAUTH_GENERIC_SCOPES=${OAUTH_GENERIC_SCOPES:-}
- OAUTH_GENERIC_IMAP_HOST=${OAUTH_GENERIC_IMAP_HOST:-}
- OAUTH_GENERIC_SMTP_HOST=${OAUTH_GENERIC_SMTP_HOST:-}
# OIDC login / SSO (optional). Run `manage.py oidc_discover <issuer>`
# to get the four OIDC_OP_* endpoints. Redirect URI: <origin>/oidc/callback/
- OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID:-}
- OIDC_RP_CLIENT_SECRET=${OIDC_RP_CLIENT_SECRET:-}
- OIDC_OP_AUTHORIZATION_ENDPOINT=${OIDC_OP_AUTHORIZATION_ENDPOINT:-}
- OIDC_OP_TOKEN_ENDPOINT=${OIDC_OP_TOKEN_ENDPOINT:-}
- OIDC_OP_USER_ENDPOINT=${OIDC_OP_USER_ENDPOINT:-}
- OIDC_OP_JWKS_ENDPOINT=${OIDC_OP_JWKS_ENDPOINT:-}
- OIDC_PROVIDER_NAME=${OIDC_PROVIDER_NAME:-OpenID}
- OIDC_RP_SIGN_ALGO=${OIDC_RP_SIGN_ALGO:-RS256}
- OIDC_RP_SCOPES=${OIDC_RP_SCOPES:-openid email profile}
- OIDC_ALLOWED_DOMAINS=${OIDC_ALLOWED_DOMAINS:-}
- OIDC_REQUIRE_EMAIL_VERIFIED=${OIDC_REQUIRE_EMAIL_VERIFIED:-}
- OIDC_USERNAME_CLAIM=${OIDC_USERNAME_CLAIM:-preferred_username}
# AI (optional)
- AI_API_KEY=${AI_API_KEY:-}
- AI_BASE_URL=${AI_BASE_URL:-}
Expand Down
13 changes: 13 additions & 0 deletions docs/deployments/kubernetes/secrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ stringData:
OAUTH_GENERIC_SCOPES: ""
OAUTH_GENERIC_IMAP_HOST: ""
OAUTH_GENERIC_SMTP_HOST: ""
# OIDC login / SSO (optional). Leave OIDC_RP_CLIENT_ID empty to disable.
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: "OpenID"
OIDC_RP_SIGN_ALGO: "RS256"
OIDC_RP_SCOPES: "openid email profile"
OIDC_ALLOWED_DOMAINS: ""
OIDC_REQUIRE_EMAIL_VERIFIED: ""
OIDC_USERNAME_CLAIM: "preferred_username"
AI_API_KEY: ""
AI_BASE_URL: ""
AI_IMAGE_BASE_URL: ""
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dependencies = [
"trafilatura>=2.0.0",
# streaming ZIP generation for bulk downloads (constant RAM)
"zipstream-ng>=1.9.2",
"mozilla-django-oidc>=5.0.2",
]

[build-system]
Expand Down
26 changes: 26 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions workspace/common/static/css/app.css

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions workspace/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
'rest_framework',
'drf_spectacular',
'knox',
'mozilla_django_oidc',
'django_filters',
'simple_history',
'django_prometheus',
Expand Down Expand Up @@ -701,6 +702,63 @@ def _redis_db_url(base_url, db_number):
OAUTH_GENERIC_IMAP_HOST = os.getenv('OAUTH_GENERIC_IMAP_HOST', '')
OAUTH_GENERIC_SMTP_HOST = os.getenv('OAUTH_GENERIC_SMTP_HOST', '')

# --------------------------------------------------
# OIDC login (SSO)
# --------------------------------------------------
# Enabled only when the core RP credentials + endpoints are all set. All values
# default to '' so the mozilla-django-oidc views never raise ImproperlyConfigured
# at import time when OIDC is disabled.
OIDC_RP_CLIENT_ID = os.getenv('OIDC_RP_CLIENT_ID', '')
OIDC_RP_CLIENT_SECRET = os.getenv('OIDC_RP_CLIENT_SECRET', '')
OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv('OIDC_OP_AUTHORIZATION_ENDPOINT', '')
OIDC_OP_TOKEN_ENDPOINT = os.getenv('OIDC_OP_TOKEN_ENDPOINT', '')
OIDC_OP_USER_ENDPOINT = os.getenv('OIDC_OP_USER_ENDPOINT', '')
OIDC_OP_JWKS_ENDPOINT = os.getenv('OIDC_OP_JWKS_ENDPOINT', '')

# Override two library defaults on purpose:
# - RP_SIGN_ALGO: lib default 'HS256'; real IdPs sign with RS256 (uses JWKS).
# - RP_SCOPES: lib default 'openid email'; 'profile' is required to receive
# preferred_username / given_name / family_name.
OIDC_RP_SIGN_ALGO = os.getenv('OIDC_RP_SIGN_ALGO', 'RS256')
OIDC_RP_SCOPES = os.getenv('OIDC_RP_SCOPES', 'openid email profile')

# Project-specific knobs (not part of mozilla-django-oidc):
OIDC_PROVIDER_NAME = os.getenv('OIDC_PROVIDER_NAME', 'OpenID')
OIDC_ALLOWED_DOMAINS = [
d.strip().lower()
for d in os.getenv('OIDC_ALLOWED_DOMAINS', '').split(',')
if d.strip()
]
OIDC_REQUIRE_EMAIL_VERIFIED = os.getenv(
'OIDC_REQUIRE_EMAIL_VERIFIED', ''
).lower() in {'1', 'true', 'yes', 'on'}
OIDC_USERNAME_CLAIM = os.getenv('OIDC_USERNAME_CLAIM', 'preferred_username')

# RS*/ES* signing (our default RS256) requires a JWKS endpoint or the backend's
# __init__ raises ImproperlyConfigured (verified against mozilla-django-oidc
# 5.0.2). Gate OIDC_ENABLED on it too, so a half-configured IdP (everything but
# the JWKS) never enables the backend and breaks local login.
_oidc_core_configured = all([
OIDC_RP_CLIENT_ID,
OIDC_RP_CLIENT_SECRET,
OIDC_OP_AUTHORIZATION_ENDPOINT,
OIDC_OP_TOKEN_ENDPOINT,
OIDC_OP_USER_ENDPOINT,
])
_oidc_sign_key_ok = (
not OIDC_RP_SIGN_ALGO.startswith(('RS', 'ES'))
or bool(OIDC_OP_JWKS_ENDPOINT)
)
OIDC_ENABLED = _oidc_core_configured and _oidc_sign_key_ok

# Keep local username/password login working; add the OIDC backend ONLY when
# configured (its __init__ raises ImproperlyConfigured on missing endpoints, and
# Django instantiates every backend on each authenticate()).
AUTHENTICATION_BACKENDS = ['django.contrib.auth.backends.ModelBackend']
if OIDC_ENABLED:
AUTHENTICATION_BACKENDS.insert(
0, 'workspace.users.services.oidc.WorkspaceOIDCBackend')

# --------------------------------------------------
# Storage
# --------------------------------------------------
Expand Down
6 changes: 5 additions & 1 deletion workspace/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView

from workspace.core.views_health import LiveView, ReadyView, StartupView
from workspace.users.ui.views import WorkspaceLoginView

api_urlpatterns = [
# OpenAPI schema and documentation
Expand Down Expand Up @@ -56,8 +57,11 @@
urlpatterns = [
path('admin/', admin.site.urls),
# Authentication
path('login', auth_views.LoginView.as_view(template_name='users/ui/auth/login.html'), name='login'),
path('login', WorkspaceLoginView.as_view(), name='login'),
path('logout', auth_views.LogoutView.as_view(), name='logout'),
# OIDC (SSO) login - init/callback views from mozilla-django-oidc.
# Configure the IdP redirect_uri to {origin}/oidc/callback/.
path('oidc/', include('mozilla_django_oidc.urls')),
Comment thread
pchopinet marked this conversation as resolved.
# Service Worker (must be at root scope for push notifications)
path('sw.js', serve, {'path': 'sw.js', 'document_root': Path(__file__).resolve().parent / 'common' / 'static'}, name='service-worker'),
# Web App Manifest (must be at root for PWA install)
Expand Down
Empty file.
Empty file.
56 changes: 56 additions & 0 deletions workspace/users/management/commands/oidc_discover.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Print mozilla-django-oidc endpoint settings discovered from an OIDC issuer.

Usage:
manage.py oidc_discover https://idp.example.com/realms/myrealm

Fetches the issuer's `.well-known/openid-configuration` and prints the
`OIDC_OP_*` environment lines to copy into the deployment config. This is a
config-time helper only - the running app uses the explicit endpoints from the
environment, so the boot never depends on the IdP being reachable.
"""

import httpx
from django.core.management.base import BaseCommand, CommandError

from workspace.common.logging import scrub

WELL_KNOWN = '/.well-known/openid-configuration'


class Command(BaseCommand):
help = (
"Fetch an OIDC issuer's discovery document and print the OIDC_OP_* "
"environment variables to configure."
)

def add_arguments(self, parser):
parser.add_argument(
'issuer',
help='OIDC issuer URL (with or without the .well-known suffix).',
)

def handle(self, *args, **options):
issuer = options['issuer'].rstrip('/')
url = issuer if issuer.endswith(WELL_KNOWN) else issuer + WELL_KNOWN

try:
resp = httpx.get(url, timeout=10, follow_redirects=True)
resp.raise_for_status()
doc = resp.json()
except (httpx.HTTPError, ValueError) as exc:
raise CommandError(
f'Failed to fetch discovery document from {scrub(url)}: {exc}')

mapping = {
'OIDC_OP_AUTHORIZATION_ENDPOINT': doc.get('authorization_endpoint'),
'OIDC_OP_TOKEN_ENDPOINT': doc.get('token_endpoint'),
'OIDC_OP_USER_ENDPOINT': doc.get('userinfo_endpoint'),
'OIDC_OP_JWKS_ENDPOINT': doc.get('jwks_uri'),
}
missing = [key for key, value in mapping.items() if not value]
if missing:
raise CommandError(
'Discovery document is missing: ' + ', '.join(missing))

for key, value in mapping.items():
self.stdout.write(f'{key}={value}')
30 changes: 30 additions & 0 deletions workspace/users/migrations/0008_oidcidentity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 6.0.5 on 2026-05-30 13:02

import django.db.models.deletion
import workspace.common.uuids
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0007_drop_redundant_indexes'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='OIDCIdentity',
fields=[
('uuid', models.UUIDField(default=workspace.common.uuids.uuid_v7_or_v4, editable=False, primary_key=True, serialize=False)),
('sub', models.CharField(max_length=255, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='oidc_identity', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'OIDC identity',
'verbose_name_plural': 'OIDC identities',
},
),
]
25 changes: 25 additions & 0 deletions workspace/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,28 @@ class Meta:

def __str__(self):
return self.name or self.auth_token.token_key


class OIDCIdentity(models.Model):
"""Links a Django user to an external OIDC identity (single provider).

The presence of a row marks the user as managed by the identity provider:
the profile name is synced from the IdP on each login and local password
changes are disabled. ``sub`` is the provider's stable subject identifier.
"""

uuid = models.UUIDField(primary_key=True, default=uuid_v7_or_v4, editable=False)
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='oidc_identity',
)
sub = models.CharField(max_length=255, unique=True)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
verbose_name = 'OIDC identity'
verbose_name_plural = 'OIDC identities'

def __str__(self):
return f'{self.user} <oidc:{self.sub}>'
Loading
Loading