Add OIDC SSO login via mozilla-django-oidc

Adds native OpenID Connect login alongside the existing LDAP and
forward-auth modes. Two new TA_LOGIN_AUTH_MODE values: oidc (SSO only)
and oidc_local (SSO + local break-glass), mirroring ldap/ldap_local.

TAOIDCBackend maps OIDC claims onto the Account model (USERNAME_FIELD is
name) and promotes staff/superuser from a configurable group claim, like
the LDAP backend. The redirect_uri is anchored to TA_HOST so it stays
correct behind nginx and a TLS-terminating proxy; PKCE and audience
verification are on. The login page gains a "Log in with SSO" button
driven by a public /api/user/oidc/ endpoint, hidden in oidc-only mode.
API token auth is unaffected.

Configured entirely via TA_OIDC_* env vars. Tested against Authentik.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
SystemZ 2026-06-20 19:29:24 +02:00
parent 5477ca97f1
commit c2594b89b9
18 changed files with 504 additions and 48 deletions

View File

@ -103,6 +103,14 @@ class Command(BaseCommand):
"TA_LDAP_USER_BASE",
"TA_LDAP_USER_FILTER",
]
oidc_required_env = [
"TA_OIDC_CLIENT_ID",
"TA_OIDC_CLIENT_SECRET",
"TA_OIDC_AUTHORIZATION_ENDPOINT",
"TA_OIDC_TOKEN_ENDPOINT",
"TA_OIDC_USER_ENDPOINT",
"TA_OIDC_JWKS_ENDPOINT",
]
_login_auth_mode = (
os.environ.get("TA_LOGIN_AUTH_MODE") or "single"
).casefold()
@ -127,6 +135,14 @@ class Command(BaseCommand):
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
)
elif _login_auth_mode in ("oidc", "oidc_local"):
EXPECTED_ENV_VARS.extend(oidc_required_env)
UNEXPECTED_ENV_VARS["TA_LDAP"] = (
"TA_LDAP is not valid with current auth mode"
)
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
)
else:
if bool(os.environ.get("TA_LDAP")):
EXPECTED_ENV_VARS.extend(ldap_required_env)

View File

@ -0,0 +1,69 @@
"""OIDC settings for mozilla-django-oidc
Imported into config.settings when TA_LOGIN_AUTH_MODE is `oidc` or
`oidc_local`. All values are driven from TA_OIDC_* environment variables so no
provider details are baked into the image.
"""
from os import environ
from urllib.parse import urlparse
def _public_url():
"""first TA_HOST entry as a scheme://host[:port] base for redirect_uri"""
hosts = (environ.get("TA_HOST") or "").split()
if not hosts:
return ""
host = hosts[0].strip()
if not host.startswith("http"):
host = f"http://{host}"
parsed = urlparse(host)
netloc = parsed.netloc or parsed.path
return f"{parsed.scheme}://{netloc}".rstrip("/")
def _env_bool(name, default):
"""parse a boolean-ish env var, falling back to default when unset"""
value = environ.get(name)
if value is None:
return default
return value.strip().lower() in ("1", "true", "yes", "on")
# Relying party (this app) credentials
OIDC_RP_CLIENT_ID = environ.get("TA_OIDC_CLIENT_ID")
OIDC_RP_CLIENT_SECRET = environ.get("TA_OIDC_CLIENT_SECRET")
# OpenID provider endpoints (Authentik etc.)
OIDC_OP_AUTHORIZATION_ENDPOINT = environ.get("TA_OIDC_AUTHORIZATION_ENDPOINT")
OIDC_OP_TOKEN_ENDPOINT = environ.get("TA_OIDC_TOKEN_ENDPOINT")
OIDC_OP_USER_ENDPOINT = environ.get("TA_OIDC_USER_ENDPOINT")
OIDC_OP_JWKS_ENDPOINT = environ.get("TA_OIDC_JWKS_ENDPOINT")
OIDC_RP_SIGN_ALGO = environ.get("TA_OIDC_SIGN_ALGO") or "RS256"
OIDC_RP_SCOPES = environ.get("TA_OIDC_SCOPES") or "openid profile email groups"
# auto-provision unknown users on first login (mozilla honours this)
OIDC_CREATE_USER = _env_bool("TA_OIDC_CREATE_USER", True)
# PKCE hardens the auth code in transit; TA sits behind proxies
OIDC_USE_PKCE = True
OIDC_PKCE_CODE_CHALLENGE_METHOD = "S256"
# where mozilla-django-oidc sends the browser after login
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/login/"
# public base URL used to build the OIDC redirect_uri (see oidc_views)
TA_OIDC_PUBLIC_URL = _public_url()
# read by user.src.oidc_auth and the public OIDC info endpoint
TA_OIDC_USERNAME_CLAIM = (
environ.get("TA_OIDC_USERNAME_CLAIM") or "preferred_username"
)
TA_OIDC_GROUPS_CLAIM = environ.get("TA_OIDC_GROUPS_CLAIM") or "groups"
TA_OIDC_ADMIN_GROUP = environ.get("TA_OIDC_ADMIN_GROUP") or ""
TA_OIDC_STAFF_GROUP = environ.get("TA_OIDC_STAFF_GROUP") or ""
TA_OIDC_BUTTON_LABEL = environ.get("TA_OIDC_BUTTON_LABEL") or "Log in with SSO"

View File

@ -159,6 +159,19 @@ elif _login_auth_mode == "ldap_local":
"django.contrib.auth.backends.ModelBackend",
)
from .ldap_settings import * # noqa: F403 F401
elif _login_auth_mode == "oidc":
from .oidc_settings import * # noqa: F403 F401
AUTHENTICATION_BACKENDS = ("user.src.oidc_auth.TAOIDCBackend",)
INSTALLED_APPS.append("mozilla_django_oidc")
elif _login_auth_mode == "oidc_local":
from .oidc_settings import * # noqa: F403 F401
AUTHENTICATION_BACKENDS = (
"user.src.oidc_auth.TAOIDCBackend",
"django.contrib.auth.backends.ModelBackend",
)
INSTALLED_APPS.append("mozilla_django_oidc")
else:
# If none of these cases match, AUTHENTICATION_BACKENDS is unset, which
# means the ModelBackend should be used by default
@ -173,6 +186,10 @@ else:
)
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
# auth capabilities exposed to the login UI via /api/user/oidc/
TA_SSO_ENABLED = _login_auth_mode in ("oidc", "oidc_local")
TA_LOCAL_LOGIN_ENABLED = _login_auth_mode != "oidc"
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/

View File

@ -14,6 +14,7 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
@ -36,3 +37,7 @@ urlpatterns = [
),
path("admin/", admin.site.urls),
]
# OIDC login/callback routes, only when an SSO auth mode is active
if "mozilla_django_oidc" in settings.INSTALLED_APPS:
urlpatterns.append(path("api/oidc/", include("user.src.oidc_urls")))

View File

@ -8,6 +8,7 @@ django-cors-headers==4.9.0
Django==6.0.6
djangorestframework==3.17.1
drf-spectacular==0.28.0 # rc:ignore
mozilla-django-oidc==5.0.2
Pillow==12.2.0
redis==7.4.0
requests==2.34.2

View File

@ -57,3 +57,12 @@ class LoginSerializer(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField()
remember_me = serializers.ChoiceField(choices=["on", "off"], default="off")
class OidcInfoSerializer(serializers.Serializer):
"""serialize public SSO login capability for the login page"""
enabled = serializers.BooleanField()
local_login = serializers.BooleanField()
label = serializers.CharField()
login_url = serializers.CharField()

View File

@ -0,0 +1,98 @@
"""OIDC authentication backend
Resolves OpenID Connect logins against the custom Account model
(USERNAME_FIELD = "name"), which the stock mozilla-django-oidc backend cannot
do on its own. Claim-to-value logic lives in user.src.oidc_claims so it stays
unit testable without Django.
"""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from user.src.oidc_claims import (
admin_flags_from_groups,
username_from_claims,
)
class TAOIDCBackend(OIDCAuthenticationBackend):
"""map OIDC claims onto TubeArchivist Account rows"""
def _username_claim(self):
return getattr(
settings, "TA_OIDC_USERNAME_CLAIM", "preferred_username"
)
def verify_token(self, token, **kwargs):
"""pin the audience to our client id
mozilla-django-oidc does not enforce `aud` by default; for a flow that
can grant superuser, reject tokens minted for a different client.
"""
payload = super().verify_token(token, **kwargs)
audience = payload.get("aud")
allowed = audience if isinstance(audience, list) else [audience]
if not audience or self.OIDC_RP_CLIENT_ID not in allowed:
raise SuspiciousOperation("OIDC token audience mismatch")
return payload
def verify_claims(self, claims):
"""a resolvable account name is the only hard requirement"""
return bool(username_from_claims(claims, self._username_claim()))
def filter_users_by_claims(self, claims):
"""match an existing account by exact name (the unique column)"""
name = username_from_claims(claims, self._username_claim())
if not name:
return self.UserModel.objects.none()
return self.UserModel.objects.filter(name=name)
def create_user(self, claims):
"""provision a new account with an unusable password
Instantiates the model directly to bypass AccountManager (which
requires a password); set_unusable_password keeps the local
ModelBackend from ever authenticating this SSO account.
"""
name = username_from_claims(claims, self._username_claim())
user = self.UserModel(name=name)
user.set_unusable_password()
self._apply_group_promotion(user, claims)
user.save()
return user
def update_user(self, user, claims):
"""re-apply group based promotion on each login"""
if self._apply_group_promotion(user, claims):
user.save()
return user
def _apply_group_promotion(self, user, claims):
"""promote-only staff/superuser from the group claim
Returns True when a flag changed so callers can avoid a needless save.
Mirrors the LDAP backend: never demotes, so a manually granted local
admin keeps access.
"""
groups = claims.get(
getattr(settings, "TA_OIDC_GROUPS_CLAIM", "groups"), []
)
is_staff, is_superuser = admin_flags_from_groups(
groups,
getattr(settings, "TA_OIDC_ADMIN_GROUP", ""),
getattr(settings, "TA_OIDC_STAFF_GROUP", ""),
)
changed = False
if is_staff and not user.is_staff:
user.is_staff = True
changed = True
if is_superuser and not user.is_superuser:
user.is_superuser = True
changed = True
return changed

View File

@ -0,0 +1,35 @@
"""pure helpers for mapping OIDC claims onto TubeArchivist accounts
Kept import-free (no Django, no mozilla_django_oidc) so the mapping logic can
be unit tested without bootstrapping the app. The Django glue lives in
user.src.oidc_auth.
"""
def username_from_claims(claims, claim_name="preferred_username"):
"""resolve the account name from OIDC claims
Uses the configured claim, falling back only to the stable ``sub`` so an
identity never silently resolves to a different, reassignable claim
(e.g. email) between logins.
"""
for key in (claim_name, "sub"):
value = claims.get(key)
if value:
return str(value)
return ""
def admin_flags_from_groups(groups, admin_group, staff_group):
"""map OIDC group membership to (is_staff, is_superuser)
Promote-only, mirroring the LDAP backend: belonging to admin_group grants
superuser (which implies staff); staff_group grants staff. Empty group
names disable that tier. Never demotes here - callers decide that.
"""
groups = groups or []
is_superuser = bool(admin_group) and admin_group in groups
is_staff = is_superuser or (bool(staff_group) and staff_group in groups)
return is_staff, is_superuser

View File

@ -0,0 +1,24 @@
"""OIDC URL routes using TubeArchivist's host-pinned views
Mirrors mozilla_django_oidc.urls (same view names) but swaps in the views that
anchor redirect_uri to TA_HOST.
"""
from django.urls import path
from user.src.oidc_views import (
TAOIDCAuthenticationCallbackView,
TAOIDCAuthenticationRequestView,
)
urlpatterns = [
path(
"callback/",
TAOIDCAuthenticationCallbackView.as_view(),
name="oidc_authentication_callback",
),
path(
"authenticate/",
TAOIDCAuthenticationRequestView.as_view(),
name="oidc_authentication_init",
),
]

View File

@ -0,0 +1,51 @@
"""OIDC views that anchor the redirect_uri to the configured public host
TubeArchivist serves behind its own nginx (which rewrites the Host header to
localhost) and usually an external TLS-terminating proxy (which rewrites the
scheme). Letting mozilla-django-oidc infer the callback URL from the request
therefore produces a wrong redirect_uri such as ``http://localhost/...``. We
pin it to TA_HOST, the documented public URL, so the authorize redirect_uri and
the token-exchange redirect_uri are both correct and identical.
"""
from django.conf import settings
from mozilla_django_oidc.views import (
OIDCAuthenticationCallbackView,
OIDCAuthenticationRequestView,
)
class PublicHostRedirectMixin:
"""build root-relative absolute URIs from TA_OIDC_PUBLIC_URL
mozilla-django-oidc resolves the redirect_uri via
``request.build_absolute_uri(reverse(...))`` in both the request view and
the auth backend's token exchange. Patching it on the request object covers
both, since the backend reuses the same request.
"""
def dispatch(self, request, *args, **kwargs):
base = getattr(settings, "TA_OIDC_PUBLIC_URL", "")
if base:
original = request.build_absolute_uri
def build_absolute_uri(location=None):
if location and location.startswith("/"):
return base.rstrip("/") + location
return original(location)
request.build_absolute_uri = build_absolute_uri
return super().dispatch(request, *args, **kwargs)
class TAOIDCAuthenticationRequestView(
PublicHostRedirectMixin, OIDCAuthenticationRequestView
):
"""authorize redirect with a TA_HOST-anchored redirect_uri"""
class TAOIDCAuthenticationCallbackView(
PublicHostRedirectMixin, OIDCAuthenticationCallbackView
):
"""callback whose token-exchange redirect_uri matches the authorize one"""

View File

View File

View File

@ -0,0 +1,47 @@
"""test OIDC claim and group mapping helpers"""
import pytest
from user.src.oidc_claims import admin_flags_from_groups, username_from_claims
@pytest.mark.parametrize(
"claims,claim_name,expected",
[
({"preferred_username": "alice"}, "preferred_username", "alice"),
# email is NOT a fallback - only the configured claim then sub
({"email": "bob@example.com"}, "preferred_username", ""),
({"sub": "abc-123"}, "preferred_username", "abc-123"),
({"sub": "s-1", "email": "x@y.z"}, "preferred_username", "s-1"),
({"name": "carol"}, "name", "carol"),
({}, "preferred_username", ""),
({"preferred_username": ""}, "preferred_username", ""),
],
)
def test_username_from_claims(claims, claim_name, expected):
"""resolve the account name from claims with sensible fallbacks"""
assert username_from_claims(claims, claim_name) == expected
def test_username_prefers_configured_claim():
"""the configured claim wins over the default chain"""
claims = {"preferred_username": "alice", "email": "alice@example.com"}
assert username_from_claims(claims, "email") == "alice@example.com"
@pytest.mark.parametrize(
"groups,admin_group,staff_group,expected",
[
(["ta-admins"], "ta-admins", "ta-staff", (True, True)),
(["ta-staff"], "ta-admins", "ta-staff", (True, False)),
(["other"], "ta-admins", "ta-staff", (False, False)),
([], "ta-admins", "ta-staff", (False, False)),
(None, "ta-admins", "ta-staff", (False, False)),
(["ta-admins"], "", "", (False, False)),
(["ta-admins", "ta-staff"], "ta-admins", "ta-staff", (True, True)),
],
)
def test_admin_flags_from_groups(groups, admin_group, staff_group, expected):
"""map OIDC groups to (is_staff, is_superuser); admin implies staff"""
assert (
admin_flags_from_groups(groups, admin_group, staff_group) == expected
)

View File

@ -5,6 +5,7 @@ from user import views
urlpatterns = [
path("login/", views.LoginApiView.as_view(), name="api-user-login"),
path("oidc/", views.OidcInfoView.as_view(), name="api-user-oidc"),
path("logout/", views.LogoutApiView.as_view(), name="api-user-logout"),
path("account/", views.UserAccountView.as_view(), name="api-user-account"),
path("me/", views.UserConfigView.as_view(), name="api-user-me"),

View File

@ -2,6 +2,7 @@
from common.serializers import ErrorResponseSerializer
from common.views import ApiBaseView
from django.conf import settings
from django.contrib.auth import authenticate, login, logout
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
@ -13,6 +14,7 @@ from user.models import Account
from user.serializers import (
AccountSerializer,
LoginSerializer,
OidcInfoSerializer,
UserMeConfigSerializer,
)
from user.src.user_config import UserConfig
@ -112,6 +114,28 @@ class LoginApiView(APIView):
return Response(status=204)
class OidcInfoView(APIView):
"""resolves to /api/user/oidc/
GET: public SSO login capability, consumed by the login page
"""
permission_classes = [AllowAny]
authentication_classes = []
@extend_schema(responses=OidcInfoSerializer())
def get(self, request):
"""report whether SSO is offered and how to present it"""
data = {
"enabled": getattr(settings, "TA_SSO_ENABLED", False),
"local_login": getattr(settings, "TA_LOCAL_LOGIN_ENABLED", True),
"label": getattr(
settings, "TA_OIDC_BUTTON_LABEL", "Log in with SSO"
),
"login_url": "/api/oidc/authenticate/",
}
return Response(OidcInfoSerializer(data).data)
class LogoutApiView(ApiBaseView):
"""resolves to /api/user/logout/
POST: handle logout

View File

@ -0,0 +1,27 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
export type OidcInfoType = {
enabled: boolean;
local_login: boolean;
label: string;
login_url: string;
};
const loadOidcInfo = async (): Promise<OidcInfoType> => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/user/oidc/`, {
headers: { ...defaultHeaders },
credentials: getFetchCredentials(),
});
if (!response.ok) {
throw new Error(`OIDC info request failed: ${response.status}`);
}
return response.json();
};
export default loadOidcInfo;

View File

@ -5,6 +5,8 @@ import Colours from '../configuration/colours/Colours';
import Button from '../components/Button';
import signIn from '../api/actions/signIn';
import loadAuth from '../api/loader/loadAuth';
import loadOidcInfo, { OidcInfoType } from '../api/loader/loadOidcInfo';
import getApiUrl from '../configuration/getApiUrl';
import LoadingIndicator from '../components/LoadingIndicator';
const Login = () => {
@ -16,6 +18,8 @@ const Login = () => {
const [waitingForBackend, setWaitingForBackend] = useState(false);
const [waitedCount, setWaitedCount] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [oidcInfo, setOidcInfo] = useState<OidcInfoType | null>(null);
const [oidcLoaded, setOidcLoaded] = useState(false);
const handleSubmit = async (event: { preventDefault: () => void }) => {
event.preventDefault();
@ -39,6 +43,13 @@ const Login = () => {
}
};
useEffect(() => {
loadOidcInfo()
.then(setOidcInfo)
.catch(() => setOidcInfo(null))
.finally(() => setOidcLoaded(true));
}, []);
useEffect(() => {
let retryCount = 0;
@ -72,6 +83,8 @@ const Login = () => {
};
}, [navigate]);
const showLocalLogin = oidcInfo?.local_login ?? true;
return (
<>
<title>TA | Welcome</title>
@ -89,59 +102,72 @@ const Login = () => {
</p>
)}
<form onSubmit={handleSubmit}>
<input
type="text"
name="username"
id="id_username"
placeholder="Username"
autoComplete="username"
maxLength={150}
required={true}
value={username}
onChange={event => setUsername(event.target.value)}
/>
<br />
<input
type="password"
name="password"
id="id_password"
placeholder="Password"
autoComplete="current-password"
required={true}
value={password}
onChange={event => setPassword(event.target.value)}
/>
<br />
<p>
Remember me:{' '}
{oidcLoaded && showLocalLogin && (
<form onSubmit={handleSubmit}>
<input
type="checkbox"
name="remember_me"
id="id_remember_me"
checked={saveLogin}
onChange={() => {
setSaveLogin(!saveLogin);
}}
type="text"
name="username"
id="id_username"
placeholder="Username"
autoComplete="username"
maxLength={150}
required={true}
value={username}
onChange={event => setUsername(event.target.value)}
/>
</p>
<input type="hidden" name="next" value={Routes.Home} />
<br />
{waitingForBackend && (
<>
<p>
Waiting for backend <LoadingIndicator />
</p>
</>
)}
<input
type="password"
name="password"
id="id_password"
placeholder="Password"
autoComplete="current-password"
required={true}
value={password}
onChange={event => setPassword(event.target.value)}
/>
{!waitingForBackend && <Button label="Login" type="submit" />}
</form>
<br />
<p>
Remember me:{' '}
<input
type="checkbox"
name="remember_me"
id="id_remember_me"
checked={saveLogin}
onChange={() => {
setSaveLogin(!saveLogin);
}}
/>
</p>
<input type="hidden" name="next" value={Routes.Home} />
{waitingForBackend && (
<>
<p>
Waiting for backend <LoadingIndicator />
</p>
</>
)}
{!waitingForBackend && <Button label="Login" type="submit" />}
</form>
)}
{oidcInfo?.enabled && (
<Button
label={oidcInfo.label}
type="button"
className="oidc-login-button"
onClick={() => {
window.location.assign(`${getApiUrl()}${oidcInfo.login_url}`);
}}
/>
)}
{waitedCount > 10 && (
<div className="info-box">

View File

@ -865,6 +865,12 @@ video:-webkit-full-screen {
margin-top: 5px;
}
.login-page .oidc-login-button {
position: relative;
z-index: 1;
margin-top: 15px;
}
.login-links a {
text-decoration: underline;
margin: 30px 0;