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 = () => {

)} -
- setUsername(event.target.value)} - /> - -
- - setPassword(event.target.value)} - /> - -
- -

- Remember me:{' '} + {oidcLoaded && showLocalLogin && ( + { - 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)} /> -

- +
- {waitingForBackend && ( - <> -

- Waiting for backend -

- - )} + setPassword(event.target.value)} + /> - {!waitingForBackend &&