208 lines
8.7 KiB
Markdown
208 lines
8.7 KiB
Markdown
# Keycloak SSO Integration
|
|
|
|
This document describes how Keycloak SSO was integrated into labhelper, replacing the built-in Django username/password authentication.
|
|
|
|
## Overview
|
|
|
|
Authentication is handled via OpenID Connect (OIDC) using the `mozilla-django-oidc` library. When a user visits any protected page, they are redirected to Keycloak to authenticate. On return, Keycloak group memberships are synced to Django groups, controlling what the user can do in the app.
|
|
|
|
---
|
|
|
|
## Keycloak Setup
|
|
|
|
### 1. Create a client
|
|
|
|
In the Keycloak admin console, go to **Clients → Create client**.
|
|
|
|
| Field | Value | Why |
|
|
|---|---|---|
|
|
| Client type | OpenID Connect | The protocol mozilla-django-oidc speaks |
|
|
| Client ID | `labhelper` | Must match `OIDC_RP_CLIENT_ID` in the app |
|
|
| Client authentication | On (confidential) | Server-side apps use a client secret; this is more secure than a public client |
|
|
| Authentication flow | Standard flow only | labhelper uses the standard authorisation code flow |
|
|
|
|
After saving, go to the **Credentials** tab and copy the **Client secret** — this is `OIDC_RP_CLIENT_SECRET`.
|
|
|
|
### 2. Set the redirect URI
|
|
|
|
In the client **Settings** tab:
|
|
|
|
| Field | Value | Why |
|
|
|---|---|---|
|
|
| Valid redirect URIs | `https://your-app/oidc/callback/` | Keycloak will only redirect back to whitelisted URIs after authentication. The trailing slash is required — without it Keycloak rejects the request. For local dev also add `http://127.0.0.1:8000/oidc/callback/` |
|
|
|
|
### 3. Set the PKCE code challenge method
|
|
|
|
In the client **Advanced** tab, find **"Proof Key for Code Exchange Code Challenge Method"** and set it to **S256**.
|
|
|
|
Why: Keycloak 26 configures new clients with PKCE enforced. `mozilla-django-oidc` sends `S256` as the challenge method (the more secure option). If Keycloak is set to `plain`, the two sides don't agree and authentication fails with `code challenge method is not matching the configured one`.
|
|
|
|
### 4. Add a Groups mapper
|
|
|
|
This makes Keycloak include the user's group memberships in the token so the app can sync them to Django groups.
|
|
|
|
Go to **Clients → labhelper → Client scopes** tab → click the dedicated scope (named `labhelper-dedicated`) → **Add mapper → By configuration → Group Membership**.
|
|
|
|
| Field | Value | Why |
|
|
|---|---|---|
|
|
| Name | `groups` | Label for this mapper |
|
|
| Token Claim Name | `groups` | The claim name the app reads from the token |
|
|
| Full group path | Off | Sends `Lab Administrators` instead of `/Lab Administrators`. The app strips leading slashes anyway, but this is cleaner |
|
|
| Add to ID token | On | |
|
|
| Add to access token | On | |
|
|
| Add to userinfo | On | The app fetches userinfo after the token exchange |
|
|
|
|
### 5. Create groups
|
|
|
|
Go to **Groups** (left sidebar) and create these three groups with exactly these names — they map to the existing Django groups:
|
|
|
|
- `Lab Administrators` — gets `is_staff=True` in Django (admin access)
|
|
- `Lab Staff`
|
|
- `Lab Viewers`
|
|
|
|
### 6. Ensure users have an email address
|
|
|
|
`mozilla-django-oidc` requires the `email` claim to be present in the token. Every Keycloak user who will log into labhelper must have:
|
|
|
|
- An **Email** address set (Users → select user → Details tab)
|
|
- **Email verified** ticked
|
|
|
|
Without an email, authentication fails silently with `Claims verification failed` in the Django logs.
|
|
|
|
---
|
|
|
|
## App Configuration
|
|
|
|
The app is configured entirely via environment variables.
|
|
|
|
### Required variables
|
|
|
|
```bash
|
|
OIDC_OP_BASE_URL=https://keycloak.example.com/realms/your-realm
|
|
OIDC_RP_CLIENT_ID=labhelper
|
|
OIDC_RP_CLIENT_SECRET=<client-secret-from-keycloak-credentials-tab>
|
|
```
|
|
|
|
`OIDC_OP_BASE_URL` is the realm URL. All OIDC endpoints (authorisation, token, userinfo, JWKS, logout) are derived from it automatically in `settings.py`.
|
|
|
|
### Other relevant variables
|
|
|
|
```bash
|
|
ALLOWED_HOSTS=your-app-hostname
|
|
CSRF_TRUSTED_ORIGINS=https://your-app
|
|
```
|
|
|
|
`CSRF_TRUSTED_ORIGINS` must include the app's origin. The OIDC callback goes through Django's CSRF middleware, and requests from untrusted origins are rejected.
|
|
|
|
---
|
|
|
|
## How the App Side Works
|
|
|
|
### Library
|
|
|
|
`mozilla-django-oidc` handles the full OIDC flow: redirecting to Keycloak, validating the returned token (RS256 signature verified against Keycloak's JWKS endpoint), exchanging the authorisation code, and fetching userinfo.
|
|
|
|
### Key settings
|
|
|
|
| Setting | Value | Why |
|
|
|---|---|---|
|
|
| `OIDC_RP_SIGN_ALGO` | `RS256` | Keycloak signs tokens with RS256 by default |
|
|
| `OIDC_RP_SCOPES` | `openid email profile` | `profile` is needed to get `preferred_username`, `given_name`, `family_name` from Keycloak |
|
|
| `OIDC_USE_PKCE` | `True` | Required because Keycloak enforces PKCE on this client |
|
|
| `OIDC_STORE_ID_TOKEN` | `True` | The ID token is stored in the session and passed as `id_token_hint` when logging out, so Keycloak also ends its own session |
|
|
| `OIDC_EXEMPT_URLS` | `['search_api']` | The search endpoint is called via AJAX. The `SessionRefresh` middleware would return a redirect instead of JSON for unauthenticated AJAX calls, breaking the UI |
|
|
| `LOGIN_URL` | `oidc_authentication_init` | When `@login_required` intercepts an unauthenticated request, it redirects directly to the OIDC flow rather than a local login form |
|
|
|
|
### Authentication backend (`labhelper/auth_backend.py`)
|
|
|
|
Overrides `OIDCAuthenticationBackend` to:
|
|
|
|
- Use `preferred_username` from Keycloak as the Django username
|
|
- Set `first_name` and `last_name` from `given_name` / `family_name` claims
|
|
- Sync group memberships on every login — if a user is added to or removed from a Keycloak group, it takes effect at their next login
|
|
- Set `is_staff=True` for members of `Lab Administrators` (grants Django admin access)
|
|
|
|
`django.contrib.auth.backends.ModelBackend` is kept as a fallback so the Django admin login form still works with a local username/password (useful for emergency superuser access without Keycloak).
|
|
|
|
### Session refresh middleware
|
|
|
|
`mozilla_django_oidc.middleware.SessionRefresh` is added after `AuthenticationMiddleware`. It periodically checks whether the user's OIDC session is still valid and forces re-authentication if the token has expired, rather than keeping a stale Django session alive indefinitely.
|
|
|
|
### URLs
|
|
|
|
All OIDC routes are mounted under `/oidc/`:
|
|
|
|
| URL | Purpose |
|
|
|---|---|
|
|
| `/oidc/authenticate/` | Initiates the OIDC flow, redirects to Keycloak |
|
|
| `/oidc/callback/` | Keycloak redirects here after authentication |
|
|
| `/oidc/logout/` | Logs out of Django and ends the Keycloak session |
|
|
|
|
`/login/` is kept as a static landing page with a "Login with SSO" button, used when users navigate to it manually or are redirected there after logout.
|
|
|
|
---
|
|
|
|
## Auth Flow
|
|
|
|
```
|
|
User visits protected page
|
|
↓
|
|
@login_required → redirect to /oidc/authenticate/?next=/original/url/
|
|
↓
|
|
Redirect to Keycloak (with code_challenge for PKCE)
|
|
↓
|
|
User authenticates in Keycloak
|
|
↓
|
|
Keycloak redirects to /oidc/callback/?code=...
|
|
↓
|
|
App exchanges code for tokens, verifies RS256 signature
|
|
↓
|
|
App fetches userinfo, syncs groups and attributes
|
|
↓
|
|
User redirected to original URL
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
| Error | Cause | Fix |
|
|
|---|---|---|
|
|
| `Invalid parameter: redirect_uri` | Redirect URI not in Keycloak whitelist, or trailing slash missing | Add exact URI including trailing slash to Keycloak client Valid Redirect URIs |
|
|
| `Missing parameter: code_challenge_method` | Keycloak requires PKCE but app wasn't sending it | Set `OIDC_USE_PKCE = True` in settings |
|
|
| `code challenge method is not matching the configured one` | Keycloak client set to `plain`, app sends `S256` | Set PKCE method to `S256` in Keycloak client Advanced settings |
|
|
| `Claims verification failed` | User has no email set in Keycloak | Set email address and tick Email Verified on the Keycloak user |
|
|
| `NoReverseMatch` for `OIDC_EXEMPT_URLS` | Regex pattern used instead of URL name | Use the Django URL name (`'search_api'`), not a regex |
|
|
| Login loops without showing Keycloak | Existing Keycloak session auto-authenticates | Expected behaviour — Keycloak reuses its session. Log out of Keycloak admin console to test a clean login |
|
|
|
|
---
|
|
|
|
## Kubernetes Deployment
|
|
|
|
Split the configuration across a ConfigMap and a Secret. The client secret must not go in a ConfigMap as the contents are visible in plain text to anyone with cluster access.
|
|
|
|
**ConfigMap**
|
|
```yaml
|
|
data:
|
|
OIDC_OP_BASE_URL: https://keycloak.example.com/realms/your-realm
|
|
OIDC_RP_CLIENT_ID: labhelper
|
|
CSRF_TRUSTED_ORIGINS: https://labhelper.adebaumann.com
|
|
ALLOWED_HOSTS: labhelper.adebaumann.com
|
|
```
|
|
|
|
**Secret**
|
|
```yaml
|
|
stringData:
|
|
OIDC_RP_CLIENT_SECRET: <client-secret-from-keycloak-credentials-tab>
|
|
DJANGO_SECRET_KEY: <random-secret-key>
|
|
```
|
|
|
|
Reference both in the deployment:
|
|
```yaml
|
|
envFrom:
|
|
- configMapRef:
|
|
name: labhelper-config
|
|
- secretRef:
|
|
name: labhelper-secret
|
|
```
|