From c2594b89b987e149a03889c04683813d07239c38 Mon Sep 17 00:00:00 2001
From: SystemZ
Date: Sat, 20 Jun 2026 19:29:24 +0200
Subject: [PATCH] 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)
---
.../config/management/commands/ta_envcheck.py | 16 +++
backend/config/oidc_settings.py | 69 ++++++++++
backend/config/settings.py | 17 +++
backend/config/urls.py | 5 +
backend/requirements.txt | 1 +
backend/user/serializers.py | 9 ++
backend/user/src/oidc_auth.py | 98 ++++++++++++++
backend/user/src/oidc_claims.py | 35 +++++
backend/user/src/oidc_urls.py | 24 ++++
backend/user/src/oidc_views.py | 51 ++++++++
backend/user/tests/__init__.py | 0
backend/user/tests/test_src/__init__.py | 0
backend/user/tests/test_src/test_oidc.py | 47 +++++++
backend/user/urls.py | 1 +
backend/user/views.py | 24 ++++
frontend/src/api/loader/loadOidcInfo.ts | 27 ++++
frontend/src/pages/Login.tsx | 122 +++++++++++-------
frontend/src/style.css | 6 +
18 files changed, 504 insertions(+), 48 deletions(-)
create mode 100644 backend/config/oidc_settings.py
create mode 100644 backend/user/src/oidc_auth.py
create mode 100644 backend/user/src/oidc_claims.py
create mode 100644 backend/user/src/oidc_urls.py
create mode 100644 backend/user/src/oidc_views.py
create mode 100644 backend/user/tests/__init__.py
create mode 100644 backend/user/tests/test_src/__init__.py
create mode 100644 backend/user/tests/test_src/test_oidc.py
create mode 100644 frontend/src/api/loader/loadOidcInfo.ts
diff --git a/backend/config/management/commands/ta_envcheck.py b/backend/config/management/commands/ta_envcheck.py
index 245bb9c1..2ed7da25 100644
--- a/backend/config/management/commands/ta_envcheck.py
+++ b/backend/config/management/commands/ta_envcheck.py
@@ -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)
diff --git a/backend/config/oidc_settings.py b/backend/config/oidc_settings.py
new file mode 100644
index 00000000..fb6ff08f
--- /dev/null
+++ b/backend/config/oidc_settings.py
@@ -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"
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 1707bdfb..59d3c2be 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -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/
diff --git a/backend/config/urls.py b/backend/config/urls.py
index cc56d743..675320d4 100644
--- a/backend/config/urls.py
+++ b/backend/config/urls.py
@@ -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")))
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 05c66658..4f9f3142 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -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
diff --git a/backend/user/serializers.py b/backend/user/serializers.py
index 4a54aa4c..d498fd95 100644
--- a/backend/user/serializers.py
+++ b/backend/user/serializers.py
@@ -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()
diff --git a/backend/user/src/oidc_auth.py b/backend/user/src/oidc_auth.py
new file mode 100644
index 00000000..64252ebe
--- /dev/null
+++ b/backend/user/src/oidc_auth.py
@@ -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
diff --git a/backend/user/src/oidc_claims.py b/backend/user/src/oidc_claims.py
new file mode 100644
index 00000000..ab49c4b9
--- /dev/null
+++ b/backend/user/src/oidc_claims.py
@@ -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
diff --git a/backend/user/src/oidc_urls.py b/backend/user/src/oidc_urls.py
new file mode 100644
index 00000000..dac0827f
--- /dev/null
+++ b/backend/user/src/oidc_urls.py
@@ -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",
+ ),
+]
diff --git a/backend/user/src/oidc_views.py b/backend/user/src/oidc_views.py
new file mode 100644
index 00000000..2c008a02
--- /dev/null
+++ b/backend/user/src/oidc_views.py
@@ -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"""
diff --git a/backend/user/tests/__init__.py b/backend/user/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/user/tests/test_src/__init__.py b/backend/user/tests/test_src/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/user/tests/test_src/test_oidc.py b/backend/user/tests/test_src/test_oidc.py
new file mode 100644
index 00000000..05ac91e4
--- /dev/null
+++ b/backend/user/tests/test_src/test_oidc.py
@@ -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
+ )
diff --git a/backend/user/urls.py b/backend/user/urls.py
index 319a5ccb..941bd9ce 100644
--- a/backend/user/urls.py
+++ b/backend/user/urls.py
@@ -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"),
diff --git a/backend/user/views.py b/backend/user/views.py
index 8bac0877..c1bafa23 100644
--- a/backend/user/views.py
+++ b/backend/user/views.py
@@ -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
diff --git a/frontend/src/api/loader/loadOidcInfo.ts b/frontend/src/api/loader/loadOidcInfo.ts
new file mode 100644
index 00000000..b7f0609c
--- /dev/null
+++ b/frontend/src/api/loader/loadOidcInfo.ts
@@ -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 => {
+ 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;
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index d07c392e..1d2f660c 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -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(null);
+ const [oidcInfo, setOidcInfo] = useState(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 (
<>
TA | Welcome
@@ -89,59 +102,72 @@ const Login = () => {
)}
-
+
+
+
+ Remember me:{' '}
+ {
+ setSaveLogin(!saveLogin);
+ }}
+ />
+
+
+
+
+ {waitingForBackend && (
+ <>
+
+ Waiting for backend
+
+ >
+ )}
+
+ {!waitingForBackend && }
+
+ )}
+
+ {oidcInfo?.enabled && (
+