diff --git a/.env.example b/.env.example
index 700c7c23..264a0c7c 100644
--- a/.env.example
+++ b/.env.example
@@ -69,6 +69,20 @@ LDAP_WRITE_ENABLED=False
# Other Settings
SITE_URL=http://localhost:8000
+PORTAL_FRONTEND_URL=http://localhost:3000
+
+# Corporate OAuth2 / OpenID Connect provider
+# Generate a dedicated RSA key and mount it outside the repository.
+OIDC_PROVIDER_ENABLED=false
+OIDC_RSA_PRIVATE_KEY_PATH=/run/secrets/eusrr_oidc_private_key.pem
+# Comma-separated old signing keys during a planned rotation.
+# OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE=/run/secrets/eusrr_oidc_private_key_old.pem
+OIDC_ISSUER_URL=http://localhost:8000/o
+OIDC_PORTAL_LOGIN_URL=http://localhost:3000/login
+OIDC_BROWSER_SESSION_SECONDS=28800
+OIDC_ACCESS_TOKEN_SECONDS=3600
+OIDC_ID_TOKEN_SECONDS=600
+OIDC_REFRESH_TOKEN_SECONDS=43200
# Web Push (generate a dedicated VAPID key pair for each environment)
# VAPID_PUBLIC_KEY=change-me-vapid-public-key
diff --git a/README.ru.md b/README.ru.md
index a5084437..56f282b2 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -32,6 +32,7 @@ EUSRR — внутренний корпоративный портал для с
- [Индекс документации](docs/README.md)
- [HTTP API](docs/guides/API.md)
- [LDAP и Active Directory](docs/guides/LDAP.md)
+- [Корпоративный OpenID Connect](docs/guides/OIDC.md)
- [WebSocket и realtime](docs/guides/WEBSOCKET.md)
- [Уведомления](docs/guides/NOTIFICATIONS.md)
diff --git a/backend/api/auth/endpoints/__init__.py b/backend/api/auth/endpoints/__init__.py
index 6d79e29b..37f725b1 100644
--- a/backend/api/auth/endpoints/__init__.py
+++ b/backend/api/auth/endpoints/__init__.py
@@ -1,3 +1,4 @@
+from .browser_sessions import BrowserSessionAPIView, BrowserSessionLogoutAPIView
from .email_verification import ResendEmailAPIView, VerifyEmailAPIView
from .passwords import (
ChangePasswordAPIView,
@@ -24,6 +25,8 @@ from .tokens import JWTTokenRefreshView, PhoneOrEmailTokenObtainPairView
from .websocket import WebSocketTicketAPIView
__all__ = [
+ "BrowserSessionAPIView",
+ "BrowserSessionLogoutAPIView",
"ChangePasswordAPIView",
"JWTTokenRefreshView",
"LogoutOtherSessionsAPIView",
diff --git a/backend/api/auth/endpoints/browser_sessions.py b/backend/api/auth/endpoints/browser_sessions.py
new file mode 100644
index 00000000..1319802d
--- /dev/null
+++ b/backend/api/auth/endpoints/browser_sessions.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from urllib.parse import urlsplit
+
+from django.conf import settings
+from django.contrib.auth import login as django_login
+from django.contrib.auth import logout as django_logout
+from django.urls import reverse
+from drf_spectacular.utils import OpenApiResponse, extend_schema
+from rest_framework import serializers, status
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from ..authentication import SessionAwareJWTAuthentication
+from ..serializers import (
+ BrowserSessionRequestSerializer,
+ BrowserSessionResponseSerializer,
+)
+
+SESSION_BACKEND = "django.contrib.auth.backends.ModelBackend"
+
+
+def validate_oidc_return_url(value: str) -> str:
+ """Allow redirects only back to this portal's authorization endpoint."""
+
+ candidate = (value or "").strip()
+ if not candidate:
+ return "/"
+
+ parsed = urlsplit(candidate)
+ expected_path = reverse("oauth2_provider:authorize")
+ if parsed.path.rstrip("/") != expected_path.rstrip("/") or parsed.fragment:
+ raise serializers.ValidationError("invalid_oidc_return_url")
+
+ if not parsed.scheme and not parsed.netloc:
+ return candidate
+
+ issuer = urlsplit(settings.OIDC_ISSUER_URL)
+ if (parsed.scheme, parsed.netloc) != (issuer.scheme, issuer.netloc):
+ raise serializers.ValidationError("invalid_oidc_return_url")
+ return candidate
+
+
+class BrowserSessionAPIView(APIView):
+ """Turn an existing portal JWT into a short-lived server browser session."""
+
+ authentication_classes = [SessionAwareJWTAuthentication]
+ permission_classes = [IsAuthenticated]
+
+ @extend_schema(
+ tags=["Auth"],
+ summary="Создать браузерную сессию для корпоративного SSO",
+ request=BrowserSessionRequestSerializer,
+ responses={200: BrowserSessionResponseSerializer},
+ )
+ def post(self, request):
+ if not settings.OIDC_PROVIDER_ENABLED:
+ return Response(status=status.HTTP_404_NOT_FOUND)
+
+ serializer = BrowserSessionRequestSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ redirect_url = validate_oidc_return_url(
+ serializer.validated_data.get("next", "")
+ )
+
+ django_login(request._request, request.user, backend=SESSION_BACKEND)
+ request._request.session.set_expiry(settings.OIDC_BROWSER_SESSION_SECONDS)
+ return Response({"redirect_url": redirect_url}, status=status.HTTP_200_OK)
+
+
+class BrowserSessionLogoutAPIView(APIView):
+ authentication_classes = [SessionAwareJWTAuthentication]
+ permission_classes = [IsAuthenticated]
+
+ @extend_schema(
+ tags=["Auth"],
+ summary="Завершить браузерную SSO-сессию",
+ responses={204: OpenApiResponse(description="Сессия завершена.")},
+ )
+ def post(self, request):
+ django_logout(request._request)
+ return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py
index 937d65a6..739f5090 100644
--- a/backend/api/auth/serializers.py
+++ b/backend/api/auth/serializers.py
@@ -101,6 +101,19 @@ class SessionBulkActionResponseSerializer(serializers.Serializer):
revoked = serializers.IntegerField(read_only=True)
+class BrowserSessionRequestSerializer(serializers.Serializer):
+ next = serializers.CharField(
+ required=False,
+ allow_blank=True,
+ trim_whitespace=True,
+ max_length=4096,
+ )
+
+
+class BrowserSessionResponseSerializer(serializers.Serializer):
+ redirect_url = serializers.CharField(read_only=True)
+
+
class ChangePasswordRequestSerializer(serializers.Serializer):
current_password = serializers.CharField(
write_only=True,
diff --git a/backend/api/auth/urls.py b/backend/api/auth/urls.py
index 5e70219e..5d7ec316 100644
--- a/backend/api/auth/urls.py
+++ b/backend/api/auth/urls.py
@@ -1,6 +1,8 @@
from django.urls import path
from .views import (
+ BrowserSessionAPIView,
+ BrowserSessionLogoutAPIView,
ChangePasswordAPIView,
JWTTokenRefreshView,
LogoutOtherSessionsAPIView,
@@ -24,6 +26,16 @@ from .views import (
)
urlpatterns = [
+ path(
+ "browser-session/",
+ BrowserSessionAPIView.as_view(),
+ name="browser-session",
+ ),
+ path(
+ "browser-session/logout/",
+ BrowserSessionLogoutAPIView.as_view(),
+ name="browser-session-logout",
+ ),
path("token/", PhoneOrEmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", JWTTokenRefreshView.as_view(), name="token_refresh"),
path("ws-ticket/", WebSocketTicketAPIView.as_view(), name="ws-ticket"),
diff --git a/backend/api/auth/views.py b/backend/api/auth/views.py
index f2bee7b9..32ac79b4 100644
--- a/backend/api/auth/views.py
+++ b/backend/api/auth/views.py
@@ -4,6 +4,8 @@ from django.contrib.auth import authenticate
from .base import AnonymousAPIView, SessionBaseAPIView
from .endpoints import (
+ BrowserSessionAPIView,
+ BrowserSessionLogoutAPIView,
ChangePasswordAPIView,
JWTTokenRefreshView,
LogoutOtherSessionsAPIView,
@@ -28,6 +30,8 @@ from .endpoints import (
__all__ = [
"AnonymousAPIView",
+ "BrowserSessionAPIView",
+ "BrowserSessionLogoutAPIView",
"ChangePasswordAPIView",
"JWTTokenRefreshView",
"LogoutOtherSessionsAPIView",
diff --git a/backend/api/management/__init__.py b/backend/api/management/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/backend/api/management/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/api/management/commands/__init__.py b/backend/api/management/commands/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/backend/api/management/commands/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/api/management/commands/provision_oidc_client.py b/backend/api/management/commands/provision_oidc_client.py
new file mode 100644
index 00000000..f48a65c3
--- /dev/null
+++ b/backend/api/management/commands/provision_oidc_client.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import secrets
+
+from django.core.management.base import BaseCommand, CommandError
+from oauth2_provider.models import get_application_model
+
+
+class Command(BaseCommand):
+ help = "Create or update a trusted corporate OpenID Connect client."
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--name",
+ required=True,
+ help="Human-readable application name",
+ )
+ parser.add_argument(
+ "--redirect-uri",
+ action="append",
+ dest="redirect_uris",
+ required=True,
+ help="Exact HTTPS callback URL; repeat for multiple callbacks",
+ )
+ parser.add_argument(
+ "--client-id",
+ help="Stable client ID. Generated for a new client when omitted.",
+ )
+ parser.add_argument(
+ "--rotate-secret",
+ action="store_true",
+ help=(
+ "Replace the secret of an existing client and print the new "
+ "value once."
+ ),
+ )
+ parser.add_argument(
+ "--require-consent",
+ action="store_true",
+ help=(
+ "Show a consent screen instead of trusting this corporate "
+ "client automatically."
+ ),
+ )
+
+ def handle(self, *args, **options):
+ Application = get_application_model()
+ client_id = (options.get("client_id") or "").strip()
+ name = options["name"].strip()
+ redirect_uris = [uri.strip() for uri in options["redirect_uris"] if uri.strip()]
+
+ if not name:
+ raise CommandError("--name must not be empty")
+ if not redirect_uris:
+ raise CommandError("At least one non-empty --redirect-uri is required")
+
+ application = None
+ if client_id:
+ application = Application.objects.filter(client_id=client_id).first()
+ if application is None:
+ application = Application.objects.filter(name=name).first()
+
+ created = application is None
+ if created:
+ raw_secret = secrets.token_urlsafe(48)
+ create_kwargs = {
+ "name": name,
+ "redirect_uris": " ".join(redirect_uris),
+ "client_type": Application.CLIENT_CONFIDENTIAL,
+ "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE,
+ "algorithm": Application.RS256_ALGORITHM,
+ "skip_authorization": not options["require_consent"],
+ "client_secret": raw_secret,
+ "hash_client_secret": True,
+ }
+ if client_id:
+ create_kwargs["client_id"] = client_id
+ application = Application(**create_kwargs)
+ else:
+ raw_secret = None
+ application.name = name
+ application.redirect_uris = " ".join(redirect_uris)
+ application.client_type = Application.CLIENT_CONFIDENTIAL
+ application.authorization_grant_type = Application.GRANT_AUTHORIZATION_CODE
+ application.algorithm = Application.RS256_ALGORITHM
+ application.skip_authorization = not options["require_consent"]
+ application.hash_client_secret = True
+ if options["rotate_secret"]:
+ raw_secret = secrets.token_urlsafe(48)
+ application.client_secret = raw_secret
+
+ application.full_clean()
+ application.save()
+
+ action = "created" if created else "updated"
+ self.stdout.write(self.style.SUCCESS(f"OIDC client {action}."))
+ self.stdout.write(f"Client ID: {application.client_id}")
+ self.stdout.write(f"Redirect URIs: {application.redirect_uris}")
+ if raw_secret:
+ self.stdout.write(
+ self.style.WARNING(
+ "Store this client secret now; it is shown only once:"
+ )
+ )
+ self.stdout.write(f"Client secret: {raw_secret}")
+ else:
+ self.stdout.write(
+ "Client secret: unchanged (use --rotate-secret to replace it)"
+ )
diff --git a/backend/api/oidc/__init__.py b/backend/api/oidc/__init__.py
new file mode 100644
index 00000000..7b0aa36a
--- /dev/null
+++ b/backend/api/oidc/__init__.py
@@ -0,0 +1 @@
+"""Corporate OAuth2 and OpenID Connect provider integration."""
diff --git a/backend/api/oidc/urls.py b/backend/api/oidc/urls.py
new file mode 100644
index 00000000..05635849
--- /dev/null
+++ b/backend/api/oidc/urls.py
@@ -0,0 +1,35 @@
+from django.urls import path
+from oauth2_provider import views
+
+from .views import (
+ CorporateAuthorizationView,
+ CorporateIntrospectTokenView,
+ CorporateRevokeTokenView,
+ CorporateTokenView,
+)
+
+app_name = "oauth2_provider"
+
+# Client registration is intentionally absent. Administrators register trusted
+# applications in Django Admin, while public OAuth/OIDC endpoints stay standard.
+urlpatterns = [
+ path("authorize/", CorporateAuthorizationView.as_view(), name="authorize"),
+ path("token/", CorporateTokenView.as_view(), name="token"),
+ path(
+ "revoke_token/",
+ CorporateRevokeTokenView.as_view(),
+ name="revoke-token",
+ ),
+ path(
+ "introspect/",
+ CorporateIntrospectTokenView.as_view(),
+ name="introspect",
+ ),
+ path(
+ ".well-known/openid-configuration",
+ views.ConnectDiscoveryInfoView.as_view(),
+ name="oidc-connect-discovery-info",
+ ),
+ path(".well-known/jwks.json", views.JwksInfoView.as_view(), name="jwks-info"),
+ path("userinfo/", views.UserInfoView.as_view(), name="user-info"),
+]
diff --git a/backend/api/oidc/validators.py b/backend/api/oidc/validators.py
new file mode 100644
index 00000000..0e225908
--- /dev/null
+++ b/backend/api/oidc/validators.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+from urllib.parse import urljoin
+
+from django.conf import settings
+from oauth2_provider.oauth2_validators import OAuth2Validator
+
+
+def _preferred_username(user) -> str:
+ username = (getattr(user, "username", "") or "").strip()
+ return username or f"employee-{user.pk}"
+
+
+def _is_active_employee(user) -> bool:
+ if not user or not getattr(user, "is_authenticated", False):
+ return False
+ if not getattr(user, "is_active", False):
+ return False
+ if not getattr(user, "email_verified", False):
+ return False
+ return bool(getattr(user, "is_actually_active", True))
+
+
+def _employee_groups(user) -> list[str]:
+ groups = {"portal-users"}
+ groups.update(user.groups.values_list("name", flat=True))
+ if getattr(user, "is_staff", False):
+ groups.add("portal-staff")
+ if getattr(user, "is_superuser", False):
+ groups.add("portal-admins")
+ return sorted(groups)
+
+
+def _picture_url(user) -> str | None:
+ avatar = getattr(user, "avatar", None)
+ if not avatar:
+ return None
+ try:
+ return urljoin(f"{settings.SITE_URL}/", avatar.url.lstrip("/"))
+ except (ValueError, AttributeError):
+ return None
+
+
+class CorporateOAuth2Validator(OAuth2Validator):
+ """Expose only verified corporate identity claims to OIDC clients."""
+
+ oidc_claim_scope = {
+ **OAuth2Validator.oidc_claim_scope,
+ "groups": "groups",
+ }
+
+ def validate_code(self, client_id, code, client, request, *args, **kwargs):
+ valid = super().validate_code(
+ client_id, code, client, request, *args, **kwargs
+ )
+ return bool(valid and _is_active_employee(getattr(request, "user", None)))
+
+ def validate_bearer_token(self, token, scopes, request):
+ valid = super().validate_bearer_token(token, scopes, request)
+ return bool(valid and _is_active_employee(getattr(request, "user", None)))
+
+ def validate_refresh_token(
+ self, refresh_token, client, request, *args, **kwargs
+ ):
+ valid = super().validate_refresh_token(
+ refresh_token, client, request, *args, **kwargs
+ )
+ return bool(valid and _is_active_employee(getattr(request, "user", None)))
+
+ def get_additional_claims(self, request):
+ user = request.user
+ full_name = user.get_full_name().strip()
+ updated_at = getattr(user, "updated_at", None)
+ claims = {
+ "name": full_name or str(user),
+ "given_name": user.first_name,
+ "family_name": user.last_name,
+ "preferred_username": _preferred_username(user),
+ "nickname": _preferred_username(user),
+ "email": user.email,
+ "email_verified": bool(user.email_verified),
+ "locale": "ru-RU",
+ "zoneinfo": settings.TIME_ZONE,
+ "groups": _employee_groups(user),
+ }
+ picture = _picture_url(user)
+ if picture:
+ claims["picture"] = picture
+ if updated_at:
+ claims["updated_at"] = int(updated_at.timestamp())
+ return claims
diff --git a/backend/api/oidc/views.py b/backend/api/oidc/views.py
new file mode 100644
index 00000000..36fbd7b7
--- /dev/null
+++ b/backend/api/oidc/views.py
@@ -0,0 +1,66 @@
+from django.conf import settings
+from django.contrib.auth.views import redirect_to_login
+from django.http import HttpResponseNotFound
+from django.shortcuts import resolve_url
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_exempt
+from oauth2_provider import views as oauth_views
+
+
+class OidcProviderEnabledMixin:
+ def dispatch(self, request, *args, **kwargs):
+ if not settings.OIDC_PROVIDER_ENABLED:
+ return HttpResponseNotFound()
+ return super().dispatch(request, *args, **kwargs)
+
+
+class CorporateAuthorizationView(
+ OidcProviderEnabledMixin,
+ oauth_views.AuthorizationView,
+):
+ """Send unauthenticated authorization requests through the portal login."""
+
+ def get_login_url(self):
+ return settings.OIDC_PORTAL_LOGIN_URL
+
+ def handle_no_permission(self):
+ # Keep the return target relative. This remains safe behind a reverse proxy
+ # even if Django receives an internal host name and the SPA is public.
+ return redirect_to_login(
+ self.request.get_full_path(),
+ resolve_url(self.get_login_url()),
+ self.get_redirect_field_name(),
+ )
+
+ def handle_prompt_login(self):
+ query = self.request.GET.copy()
+ query.pop("prompt", None)
+ return_path = self.request.path
+ if query:
+ return_path = f"{return_path}?{query.urlencode()}"
+ return redirect_to_login(
+ return_path,
+ resolve_url(self.get_login_url()),
+ self.get_redirect_field_name(),
+ )
+
+
+@method_decorator(csrf_exempt, name="dispatch")
+class CorporateTokenView(OidcProviderEnabledMixin, oauth_views.TokenView):
+ pass
+
+
+@method_decorator(csrf_exempt, name="dispatch")
+class CorporateRevokeTokenView(
+ OidcProviderEnabledMixin,
+ oauth_views.RevokeTokenView,
+):
+ pass
+
+
+@method_decorator(csrf_exempt, name="dispatch")
+class CorporateIntrospectTokenView(
+ OidcProviderEnabledMixin,
+ oauth_views.IntrospectTokenView,
+):
+ pass
diff --git a/backend/eusrr_backend/middleware.py b/backend/eusrr_backend/middleware.py
index c94fb69a..b2f650c9 100644
--- a/backend/eusrr_backend/middleware.py
+++ b/backend/eusrr_backend/middleware.py
@@ -107,6 +107,7 @@ class AuthRequiredMiddleware:
EXEMPT_PREFIXES = (
"/api/", # REST API управляет своей аутентификацией
"/admin/", # Django Admin использует встроенную аутентификацию
+ "/o/", # OAuth2/OIDC endpoints сами проверяют клиента и пользователя
"/static/", # Статика
"/media/", # Медиа
)
@@ -153,6 +154,7 @@ class CacheControlMiddleware:
NO_CACHE_PREFIXES = (
"/api/", # API управляет своим кэшем
"/admin/", # Админка всегда свежая
+ "/o/", # OAuth2/OIDC responses могут содержать одноразовые коды
"/static/", # Обрабатывается веб-сервером
"/media/", # Обрабатывается веб-сервером
)
diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py
index aa0d75f2..f0c35046 100644
--- a/backend/eusrr_backend/settings.py
+++ b/backend/eusrr_backend/settings.py
@@ -3,6 +3,8 @@ import tempfile
from datetime import timedelta
from pathlib import Path
+from django.core.exceptions import ImproperlyConfigured
+
from celery.schedules import crontab
from dotenv import load_dotenv
@@ -49,6 +51,7 @@ INSTALLED_APPS = [
"simple_history",
"rest_framework",
"drf_spectacular",
+ "oauth2_provider",
"rules", # django-rules для декларативных permissions
# django-filer и зависимости
"easy_thumbnails",
@@ -414,10 +417,100 @@ DEFAULT_FROM_EMAIL = os.getenv(
# -----------------------------------------------------------------------------
# Название сайта для email уведомлений и UI
SITE_NAME = os.getenv("SITE_NAME", "CORP - HiRo")
-SITE_URL = os.getenv("SITE_URL", "https://corp.robotail.pro")
+SITE_URL = os.getenv("SITE_URL", "https://corp.robotail.pro").rstrip("/")
+PORTAL_FRONTEND_URL = os.getenv("PORTAL_FRONTEND_URL", SITE_URL).rstrip("/")
QR_LOGIN_TOKEN_TTL_SECONDS = int(os.getenv("QR_LOGIN_TOKEN_TTL_SECONDS", "120"))
QR_LOGIN_REQUEST_TTL_SECONDS = int(os.getenv("QR_LOGIN_REQUEST_TTL_SECONDS", "120"))
+# -----------------------------------------------------------------------------
+# CORPORATE OAUTH2 / OPENID CONNECT PROVIDER
+# -----------------------------------------------------------------------------
+# The signing key is deliberately kept outside Git. A mounted Docker secret is
+# preferred; an inline value is supported for environments without secret files.
+OIDC_PROVIDER_ENABLED = os.getenv("OIDC_PROVIDER_ENABLED", "false").lower() == "true"
+OIDC_RSA_PRIVATE_KEY_PATH = os.getenv("OIDC_RSA_PRIVATE_KEY_PATH", "").strip()
+OIDC_RSA_PRIVATE_KEY = os.getenv("OIDC_RSA_PRIVATE_KEY", "").replace("\\n", "\n").strip()
+if OIDC_RSA_PRIVATE_KEY_PATH:
+ oidc_key_path = Path(OIDC_RSA_PRIVATE_KEY_PATH)
+ if not oidc_key_path.is_file():
+ raise ImproperlyConfigured(
+ f"OIDC_RSA_PRIVATE_KEY_PATH does not exist: {oidc_key_path}"
+ )
+ OIDC_RSA_PRIVATE_KEY = oidc_key_path.read_text(encoding="utf-8").strip()
+OIDC_RSA_PRIVATE_KEYS_INACTIVE = []
+for inactive_key_path_value in _split_env_list(
+ os.getenv("OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE", "")
+):
+ inactive_key_path = Path(inactive_key_path_value)
+ if not inactive_key_path.is_file():
+ raise ImproperlyConfigured(
+ "OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE contains a missing file: "
+ f"{inactive_key_path}"
+ )
+ OIDC_RSA_PRIVATE_KEYS_INACTIVE.append(
+ inactive_key_path.read_text(encoding="utf-8").strip()
+ )
+if OIDC_PROVIDER_ENABLED and not OIDC_RSA_PRIVATE_KEY:
+ raise ImproperlyConfigured(
+ "OIDC_PROVIDER_ENABLED requires OIDC_RSA_PRIVATE_KEY_PATH or "
+ "OIDC_RSA_PRIVATE_KEY"
+ )
+
+OIDC_ISSUER_URL = os.getenv("OIDC_ISSUER_URL", f"{SITE_URL}/o").rstrip("/")
+OIDC_BROWSER_SESSION_SECONDS = int(
+ os.getenv("OIDC_BROWSER_SESSION_SECONDS", str(8 * 60 * 60))
+)
+OIDC_PORTAL_LOGIN_URL = os.getenv(
+ "OIDC_PORTAL_LOGIN_URL", f"{PORTAL_FRONTEND_URL}/login"
+)
+
+OAUTH2_PROVIDER = {
+ "OIDC_ENABLED": OIDC_PROVIDER_ENABLED,
+ "OIDC_ISS_ENDPOINT": OIDC_ISSUER_URL,
+ "OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY,
+ "OIDC_RSA_PRIVATE_KEYS_INACTIVE": OIDC_RSA_PRIVATE_KEYS_INACTIVE,
+ "OIDC_RESPONSE_TYPES_SUPPORTED": ["code"],
+ "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [
+ "client_secret_basic",
+ "client_secret_post",
+ ],
+ "OAUTH2_VALIDATOR_CLASS": "api.oidc.validators.CorporateOAuth2Validator",
+ "SCOPES": {
+ "openid": "Подтвердить личность",
+ "profile": "Имя, логин и фотография профиля",
+ "email": "Корпоративная электронная почта",
+ "groups": "Корпоративные группы доступа",
+ },
+ "DEFAULT_SCOPES": ["openid", "profile", "email"],
+ "ACCESS_TOKEN_EXPIRE_SECONDS": int(
+ os.getenv("OIDC_ACCESS_TOKEN_SECONDS", "3600")
+ ),
+ "ID_TOKEN_EXPIRE_SECONDS": int(os.getenv("OIDC_ID_TOKEN_SECONDS", "600")),
+ "REFRESH_TOKEN_EXPIRE_SECONDS": timedelta(
+ seconds=int(os.getenv("OIDC_REFRESH_TOKEN_SECONDS", str(12 * 60 * 60)))
+ ),
+ "ROTATE_REFRESH_TOKEN": True,
+ "REFRESH_TOKEN_REUSE_PROTECTION": True,
+ "REQUEST_APPROVAL_PROMPT": "auto",
+ # Gitea 1.23.x does not send a PKCE challenge for its OIDC confidential
+ # client. The exact HTTPS redirect URI and client secret remain mandatory.
+ "PKCE_REQUIRED": False,
+ "ALLOWED_REDIRECT_URI_SCHEMES": ["https"] + (["http"] if DEBUG else []),
+ "ALLOW_URI_WILDCARDS": False,
+ "ALLOW_LOCALHOST_LOOPBACK": DEBUG,
+ "COMPLIANT_BCP_RFC9700_IMPLICIT_GRANT": True,
+ "COMPLIANT_BCP_RFC9700_PASSWORD_GRANT": True,
+ "COMPLIANT_BCP_RFC9700_PKCE_METHOD": True,
+ "COMPLIANT_BCP_RFC9700_ACCESS_TOKEN_TRANSPORT": True,
+ "COMPLIANT_BCP_RFC9700_TOKEN_STORAGE": True,
+}
+
+SESSION_COOKIE_HTTPONLY = True
+SESSION_COOKIE_SAMESITE = "Lax"
+SESSION_COOKIE_SECURE = os.getenv(
+ "SESSION_COOKIE_SECURE", "true" if not DEBUG else "false"
+).lower() == "true"
+
# -----------------------------------------------------------------------------
# NOTIFICATIONS
# -----------------------------------------------------------------------------
diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py
index a958e7b8..76bfeadf 100644
--- a/backend/eusrr_backend/urls.py
+++ b/backend/eusrr_backend/urls.py
@@ -6,6 +6,7 @@ from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include(("api.urls", "api"), namespace="api")),
+ path("o/", include("api.oidc.urls", namespace="oauth2_provider")),
# django-filer URLs для обработки приватных файлов
# (с проверкой прав доступа)
path("", include("filer.server.urls")),
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 7ba486f9..b6eed8cf 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -69,6 +69,9 @@ pillow==11.3.0
PyJWT==2.10.1
cryptography==45.0.6
pyOpenSSL==25.1.0
+django-oauth-toolkit==3.4.1
+# 1.5.9+ requires cryptography>=49, while pyOpenSSL 25.1 requires <46.
+jwcrypto==1.5.8
cffi==2.0.0
pycparser==2.23
pycryptodome==3.23.0
diff --git a/backend/templates/oauth2_provider/authorize.html b/backend/templates/oauth2_provider/authorize.html
new file mode 100644
index 00000000..7fdf474f
--- /dev/null
+++ b/backend/templates/oauth2_provider/authorize.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ Авторизация приложения — {{ SITE_NAME|default:"CORP" }}
+
+
+
+
+ {% if not error %}
+ Корпоративный доступ
+ Разрешить доступ приложению «{{ application.name }}»?
+ Приложение получит только перечисленные данные. Пароль портала ему не передаётся.
+
+ {% else %}
+ Ошибка авторизации
+ {{ error.error }}
+ {{ error.description }}
+ {% endif %}
+
+
+
diff --git a/backend/tests/api/auth/test_oidc_provider.py b/backend/tests/api/auth/test_oidc_provider.py
new file mode 100644
index 00000000..d6b31d56
--- /dev/null
+++ b/backend/tests/api/auth/test_oidc_provider.py
@@ -0,0 +1,310 @@
+import base64
+from io import StringIO
+from urllib.parse import parse_qs, urlparse
+
+import pytest
+import jwt
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from django.contrib.auth import get_user_model
+from django.contrib.auth.hashers import check_password
+from django.core.management import call_command
+from django.urls import reverse
+from oauth2_provider.models import get_application_model
+from oauth2_provider.settings import oauth2_settings
+from rest_framework.test import APIClient
+
+from api.oidc.validators import _employee_groups, _preferred_username
+
+pytestmark = pytest.mark.django_db
+User = get_user_model()
+
+
+@pytest.fixture(autouse=True)
+def configured_oidc(settings):
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ pem = private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption(),
+ ).decode("ascii")
+
+ settings.OIDC_PROVIDER_ENABLED = True
+ settings.OIDC_ISSUER_URL = "https://corp.example.test/o"
+ settings.OIDC_PORTAL_LOGIN_URL = "https://corp.example.test/login"
+ settings.OIDC_BROWSER_SESSION_SECONDS = 3600
+ settings.OAUTH2_PROVIDER = {
+ **settings.OAUTH2_PROVIDER,
+ "OIDC_ENABLED": True,
+ "OIDC_ISS_ENDPOINT": settings.OIDC_ISSUER_URL,
+ "OIDC_RSA_PRIVATE_KEY": pem,
+ "ALWAYS_RELOAD_OAUTHLIB_CORE": True,
+ }
+ oauth2_settings.reload()
+ yield private_key.public_key().public_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
+ )
+ oauth2_settings.reload()
+
+
+@pytest.fixture
+def employee():
+ user = User.objects.create_user(
+ email="ivan.petrov@example.test",
+ phone_number="+79990000091",
+ password="Str0ngPass!",
+ username="ipetrov",
+ first_name="Иван",
+ last_name="Петров",
+ is_active=True,
+ send_activation_email=False,
+ )
+ user.email_verified = True
+ user.email_activation_code = None
+ user.email_activation_sent_at = None
+ user.save(
+ update_fields=[
+ "email_verified",
+ "email_activation_code",
+ "email_activation_sent_at",
+ ]
+ )
+ return user
+
+
+@pytest.fixture
+def oidc_client():
+ Application = get_application_model()
+ raw_secret = "gitea-test-secret"
+ application = Application.objects.create(
+ name="Gitea",
+ client_id="gitea-test-client",
+ client_secret=raw_secret,
+ hash_client_secret=True,
+ client_type=Application.CLIENT_CONFIDENTIAL,
+ authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
+ redirect_uris="https://git.example.test/user/oauth2/corp/callback",
+ algorithm=Application.RS256_ALGORITHM,
+ skip_authorization=True,
+ )
+ return application, raw_secret
+
+
+def _authorize(client, application):
+ response = client.get(
+ reverse("oauth2_provider:authorize"),
+ {
+ "response_type": "code",
+ "client_id": application.client_id,
+ "redirect_uri": application.redirect_uris,
+ "scope": "openid profile email groups",
+ "state": "test-state",
+ "nonce": "test-nonce",
+ },
+ )
+ assert response.status_code == 302
+ query = parse_qs(urlparse(response["Location"]).query)
+ assert query["state"] == ["test-state"]
+ return query["code"][0]
+
+
+def _exchange_code(client, application, raw_secret, code):
+ basic = base64.b64encode(
+ f"{application.client_id}:{raw_secret}".encode("utf-8")
+ ).decode("ascii")
+ response = client.post(
+ reverse("oauth2_provider:token"),
+ {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": application.redirect_uris,
+ },
+ HTTP_AUTHORIZATION=f"Basic {basic}",
+ )
+ assert response.status_code == 200, response.content
+ return response.json()
+
+
+def test_discovery_and_jwks_publish_standard_provider_metadata(client):
+ discovery = client.get(reverse("oauth2_provider:oidc-connect-discovery-info"))
+ assert discovery.status_code == 200
+ metadata = discovery.json()
+ assert metadata["issuer"] == "https://corp.example.test/o"
+ assert metadata["response_types_supported"] == ["code"]
+ assert "groups" in metadata["scopes_supported"]
+ assert metadata["token_endpoint_auth_methods_supported"] == [
+ "client_secret_basic",
+ "client_secret_post",
+ ]
+
+ jwks = client.get(reverse("oauth2_provider:jwks-info"))
+ assert jwks.status_code == 200
+ public_key = jwks.json()["keys"][0]
+ assert public_key["alg"] == "RS256"
+ assert public_key["use"] == "sig"
+ assert public_key["kty"] == "RSA"
+ assert "d" not in public_key
+
+
+def test_provider_endpoints_are_closed_when_feature_is_disabled(client, settings):
+ settings.OIDC_PROVIDER_ENABLED = False
+ settings.OAUTH2_PROVIDER = {
+ **settings.OAUTH2_PROVIDER,
+ "OIDC_ENABLED": False,
+ }
+ oauth2_settings.reload()
+
+ assert client.get(reverse("oauth2_provider:authorize")).status_code == 404
+ assert client.post(reverse("oauth2_provider:token")).status_code == 404
+ assert (
+ client.get(reverse("oauth2_provider:oidc-connect-discovery-info")).status_code
+ == 404
+ )
+
+
+def test_anonymous_authorization_is_redirected_to_portal_login(client, oidc_client):
+ application, _ = oidc_client
+ response = client.get(
+ reverse("oauth2_provider:authorize"),
+ {
+ "response_type": "code",
+ "client_id": application.client_id,
+ "redirect_uri": application.redirect_uris,
+ "scope": "openid profile email",
+ "state": "test-state",
+ },
+ )
+ assert response.status_code == 302
+ location = urlparse(response["Location"])
+ assert f"{location.scheme}://{location.netloc}{location.path}" == (
+ "https://corp.example.test/login"
+ )
+ assert parse_qs(location.query)["next"][0].startswith("/o/authorize/")
+
+
+def test_authorization_code_flow_returns_corporate_claims(
+ client, employee, oidc_client, configured_oidc
+):
+ application, raw_secret = oidc_client
+ client.force_login(employee)
+
+ code = _authorize(client, application)
+ tokens = _exchange_code(client, application, raw_secret, code)
+
+ assert tokens["token_type"] == "Bearer"
+ assert tokens["access_token"]
+ assert tokens["id_token"]
+ assert tokens["refresh_token"]
+
+ id_claims = jwt.decode(
+ tokens["id_token"],
+ configured_oidc,
+ algorithms=["RS256"],
+ audience=application.client_id,
+ issuer="https://corp.example.test/o",
+ )
+ assert id_claims["nonce"] == "test-nonce"
+ assert id_claims["preferred_username"] == "ipetrov"
+ assert id_claims["email"] == employee.email
+ assert id_claims["groups"] == ["portal-users"]
+
+ userinfo = client.get(
+ reverse("oauth2_provider:user-info"),
+ HTTP_AUTHORIZATION=f"Bearer {tokens['access_token']}",
+ )
+ assert userinfo.status_code == 200, userinfo.content
+ claims = userinfo.json()
+ assert claims["sub"] == str(employee.pk)
+ assert claims["preferred_username"] == "ipetrov"
+ assert claims["name"] == "Иван Петров"
+ assert claims["email"] == employee.email
+ assert claims["email_verified"] is True
+ assert claims["groups"] == ["portal-users"]
+
+
+def test_inactive_employee_cannot_use_an_existing_access_token(
+ client, employee, oidc_client
+):
+ application, raw_secret = oidc_client
+ client.force_login(employee)
+ code = _authorize(client, application)
+ tokens = _exchange_code(client, application, raw_secret, code)
+
+ employee.is_active = False
+ employee.save(update_fields=["is_active"])
+ response = client.get(
+ reverse("oauth2_provider:user-info"),
+ HTTP_AUTHORIZATION=f"Bearer {tokens['access_token']}",
+ )
+ assert response.status_code == 401
+
+
+def test_browser_session_bridge_accepts_only_own_authorization_endpoint(employee):
+ api = APIClient()
+ api.force_authenticate(employee)
+ next_url = (
+ "/o/authorize/?response_type=code&client_id=gitea&"
+ "redirect_uri=https%3A%2F%2Fgit.example.test%2Fcallback"
+ )
+
+ response = api.post(
+ reverse("api:browser-session"),
+ {"next": next_url},
+ format="json",
+ )
+ assert response.status_code == 200
+ assert response.json() == {"redirect_url": next_url}
+ assert api.session["_auth_user_id"] == str(employee.pk)
+
+ rejected = api.post(
+ reverse("api:browser-session"),
+ {"next": "https://attacker.example/steal"},
+ format="json",
+ )
+ assert rejected.status_code == 400
+
+
+def test_claim_helpers_have_stable_fallback_username_and_sorted_groups(employee):
+ employee.username = None
+ employee.save(update_fields=["username"])
+ employee.groups.create(name="Developers")
+
+ assert _preferred_username(employee) == f"employee-{employee.pk}"
+ assert _employee_groups(employee) == ["Developers", "portal-users"]
+
+
+def test_oidc_client_secret_is_hashed_at_rest(oidc_client):
+ application, raw_secret = oidc_client
+ application.refresh_from_db()
+ assert application.client_secret != raw_secret
+ assert check_password(raw_secret, application.client_secret)
+
+
+def test_provision_command_creates_and_updates_client_without_implicit_rotation():
+ created_output = StringIO()
+ call_command(
+ "provision_oidc_client",
+ name="Gitea Production",
+ client_id="gitea-production",
+ redirect_uris=["https://git.example.test/user/oauth2/corp/callback"],
+ stdout=created_output,
+ )
+ assert "Client secret:" in created_output.getvalue()
+
+ Application = get_application_model()
+ application = Application.objects.get(client_id="gitea-production")
+ original_secret_hash = application.client_secret
+
+ updated_output = StringIO()
+ call_command(
+ "provision_oidc_client",
+ name="Gitea Production",
+ client_id="gitea-production",
+ redirect_uris=["https://git.example.test/user/oauth2/company/callback"],
+ stdout=updated_output,
+ )
+ application.refresh_from_db()
+ assert application.redirect_uris.endswith("/company/callback")
+ assert application.client_secret == original_secret_hash
+ assert "Client secret: unchanged" in updated_output.getvalue()
diff --git a/docs/guides/OIDC.md b/docs/guides/OIDC.md
new file mode 100644
index 00000000..c5b06d06
--- /dev/null
+++ b/docs/guides/OIDC.md
@@ -0,0 +1,152 @@
+# Корпоративный OpenID Connect
+
+Портал выступает доверенным OpenID Connect Provider для внутренних приложений. Первое подключение — Gitea; тот же контракт можно использовать для других клиентов с поддержкой OIDC Authorization Code Flow.
+
+## Контракт
+
+- issuer: `https://corp.example.com/o`
+- discovery: `https://corp.example.com/o/.well-known/openid-configuration`
+- flow: Authorization Code
+- подпись ID token: RS256
+- client authentication: `client_secret_basic` или `client_secret_post`
+- scopes: `openid`, `profile`, `email`, `groups`
+- идентификатор пользователя `sub`: неизменяемый Django PK сотрудника
+- рекомендуемый логин: claim `preferred_username` из LDAP username; fallback `employee-`
+
+Пароль портала стороннему приложению не передаётся. Обычный JWT-вход портала создаёт короткую browser session только перед возвратом на `/o/authorize/`. Redirect из bridge ограничен собственным authorization endpoint и не может увести пользователя на произвольный сайт.
+
+Неактивный, уволенный или не подтвердивший email сотрудник не может обменять authorization code, использовать access token либо обновить токен. Внешний клиент получает только claims из согласованных scopes.
+
+## Production-настройка портала
+
+OIDC и callback клиентов должны работать по HTTPS. Ключ подписи хранится вне Git и резервируется как production secret.
+
+Создать отдельный RSA-ключ:
+
+```bash
+install -d -m 700 /srv/eusrr/secrets
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
+ -out /srv/eusrr/secrets/oidc-private.pem
+chmod 600 /srv/eusrr/secrets/oidc-private.pem
+```
+
+Передать файл backend-контейнеру read-only и задать окружение:
+
+```dotenv
+SITE_URL=https://corp.example.com
+PORTAL_FRONTEND_URL=https://corp.example.com
+OIDC_PROVIDER_ENABLED=true
+OIDC_RSA_PRIVATE_KEY_PATH=/run/secrets/eusrr_oidc_private_key.pem
+# При ротации: OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE=/run/secrets/oidc-private-old.pem
+OIDC_ISSUER_URL=https://corp.example.com/o
+OIDC_PORTAL_LOGIN_URL=https://corp.example.com/login
+OIDC_BROWSER_SESSION_SECONDS=28800
+OIDC_ACCESS_TOKEN_SECONDS=3600
+OIDC_ID_TOKEN_SECONDS=600
+OIDC_REFRESH_TOKEN_SECONDS=43200
+SESSION_COOKIE_SECURE=true
+```
+
+`OIDC_ISSUER_URL` — постоянный публичный идентификатор. Его нельзя менять после подключения клиентов без их перенастройки. Reverse proxy должен сохранять исходный HTTPS scheme и host.
+
+Публичный reverse proxy должен отправлять весь prefix `/o/` в Django так же, как `/api/`. Пример для Nginx:
+
+```nginx
+location ^~ /o/ {
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_pass http://django_backend;
+}
+```
+
+После установки зависимостей выполнить миграции:
+
+```bash
+python manage.py migrate
+python manage.py check --deploy
+```
+
+## Регистрация Gitea в портале
+
+Сначала выберите неизменяемое имя authentication source в Gitea, например `corp`. Оно входит в callback URL:
+
+```text
+https://git.example.com/user/oauth2/corp/callback
+```
+
+Создать confidential-клиент и сохранить показанный secret в менеджер секретов:
+
+```bash
+python manage.py provision_oidc_client \
+ --name Gitea \
+ --client-id gitea \
+ --redirect-uri https://git.example.com/user/oauth2/corp/callback
+```
+
+Повторный запуск обновляет параметры, не меняя secret. Для плановой ротации используйте `--rotate-secret` и сразу обновите Gitea. Клиент также доступен администраторам в Django Admin в разделе OAuth2 Provider, но management command безопаснее для первичного получения секрета.
+
+## Настройка Gitea
+
+В `app.ini`:
+
+```ini
+[oauth2_client]
+ENABLE_AUTO_REGISTRATION = true
+USERNAME = preferred_username
+ACCOUNT_LINKING = login
+UPDATE_AVATAR = true
+OPENID_CONNECT_SCOPES = profile email groups
+REGISTER_EMAIL_CONFIRM = false
+```
+
+Затем в `Site Administration → Identity & Access → Authentication Sources` добавить источник:
+
+| Поле | Значение |
+| --- | --- |
+| Authentication Type | OAuth2 |
+| Authentication Name | `corp` |
+| OAuth2 Provider | OpenID Connect |
+| Client ID | значение из `provision_oidc_client` |
+| Client Secret | одноразово показанный secret |
+| Auto Discovery URL | `https://corp.example.com/o/.well-known/openid-configuration` |
+| Additional Scopes | `profile email groups` |
+| Group Claim Name | `groups` |
+
+До переключения пользователей оставить локальный вход Gitea включённым и сохранить отдельную аварийную локальную учётную запись администратора. `ACCOUNT_LINKING = login` не удаляет и не разлогинивает существующие локальные учётки: при первом конфликте username/email Gitea попросит один раз подтвердить привязку существующим локальным входом.
+
+Gitea 1.23.x не отправляет PKCE challenge для confidential OIDC-клиента, поэтому портал временно допускает Authorization Code без PKCE для совместимости. После обновления Gitea и проверки поддержки PKCE следует включить `PKCE_REQUIRED` в настройках провайдера.
+
+## Claims и группы
+
+`userinfo` и ID token могут содержать:
+
+| Scope | Claims |
+| --- | --- |
+| `openid` | `sub` |
+| `profile` | `name`, `given_name`, `family_name`, `preferred_username`, `nickname`, `picture`, `locale`, `zoneinfo`, `updated_at` |
+| `email` | `email`, `email_verified` |
+| `groups` | Django groups, `portal-users`, а также `portal-staff`/`portal-admins` для служебных ролей |
+
+Не назначайте Gitea administrator по общей группе `portal-users`. Для автоматического администрирования заведите отдельную узкую Django group и явно сопоставьте её в Gitea.
+
+## Проверка
+
+До включения кнопки входа проверить с хоста Gitea:
+
+```bash
+curl -fsS https://corp.example.com/o/.well-known/openid-configuration
+curl -fsS https://corp.example.com/o/.well-known/jwks.json
+```
+
+Discovery должен публиковать один и тот же issuer, HTTPS endpoints и `RS256`; JWKS содержит только публичную часть ключа. После этого пройти вход тестовой активной учёткой, проверить создание/привязку пользователя, имя, email и avatar, затем проверить отказ для деактивированной тестовой учётки.
+
+## Отзыв доступа и ротация
+
+- отключение сотрудника начинает блокировать access/refresh tokens при следующем обращении;
+- OAuth grants и токены можно отозвать через Django Admin;
+- client secret ротируется `provision_oidc_client --rotate-secret`;
+- при RSA rotation укажите старые ключи через `OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE` на переходный период; не удаляйте их до истечения ранее выданных ID token;
+- периодическая очистка выполняется командой `python manage.py cleartokens`.
+
+Dynamic Client Registration и пользовательское создание OAuth applications публично не маршрутизируются. Новый клиент регистрирует только администратор.
diff --git a/docs/guides/README.md b/docs/guides/README.md
index 79e92030..cff3ff71 100644
--- a/docs/guides/README.md
+++ b/docs/guides/README.md
@@ -6,6 +6,7 @@
- [API.md](API.md) — HTTP API, JWT/auth sessions, OpenAPI, frontend proxy и тестирование.
- [LDAP.md](LDAP.md) — LDAP/AD authentication, write-back, sync, retry queue и admin.
+- [OIDC.md](OIDC.md) — корпоративный OpenID Connect Provider и подключение Gitea.
- [WEBSOCKET.md](WEBSOCKET.md) — единый `/ws/`, JWT, actions, events и production proxy.
- [NOTIFICATIONS.md](NOTIFICATIONS.md) — создание, доставка, REST, realtime, email и Web Push.
- [COMMERCE.md](COMMERCE.md) — каталог, МойСклад, изображения, Celery и realtime-прогресс.
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx
index d2ddad4d..c7425799 100644
--- a/frontend/src/app/login/page.tsx
+++ b/frontend/src/app/login/page.tsx
@@ -33,9 +33,10 @@ type QrLoginViewState =
export default function Login() {
const router = useRouter();
- const { refreshUser } = useUser();
+ const { refreshUser, status } = useUser();
const pollTimerRef = useRef | null>(null);
const qrCompletedRef = useRef(false);
+ const oidcRedirectStartedRef = useRef(false);
const [mode, setMode] = useState("password");
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
@@ -59,6 +60,24 @@ export default function Login() {
}
}, []);
+ const continueAfterLogin = useCallback(async () => {
+ const nextUrl = new URLSearchParams(window.location.search).get("next");
+ if (!nextUrl) {
+ router.push("/");
+ return;
+ }
+ if (oidcRedirectStartedRef.current) return;
+
+ oidcRedirectStartedRef.current = true;
+ try {
+ const redirectUrl = await apiClient.establishBrowserSession(nextUrl);
+ window.location.assign(redirectUrl);
+ } catch (caught) {
+ oidcRedirectStartedRef.current = false;
+ throw caught;
+ }
+ }, [router]);
+
const startQrLogin = useCallback(async () => {
clearPollTimer();
qrCompletedRef.current = false;
@@ -111,12 +130,12 @@ export default function Login() {
await refreshUser().catch((err) => {
console.warn("Не удалось обновить пользователя после QR-входа:", err);
});
- window.location.replace("/");
+ await continueAfterLogin();
return;
}
if (status === "claimed" && apiClient.getToken()) {
qrCompletedRef.current = true;
- window.location.replace("/");
+ await continueAfterLogin();
return;
}
if (status === "denied") {
@@ -130,7 +149,7 @@ export default function Login() {
setQrError("Этот QR-код уже был использован");
}
},
- [clearPollTimer, refreshUser],
+ [clearPollTimer, continueAfterLogin, refreshUser],
);
const handleSubmit = async (e: React.FormEvent) => {
@@ -145,15 +164,36 @@ export default function Login() {
await apiClient.login(credentials);
await refreshUser();
- router.push("/");
} catch (err) {
console.error("Ошибка авторизации:", err);
setError("Неверный логин или пароль");
+ setLoading(false);
+ return;
+ }
+
+ try {
+ await continueAfterLogin();
+ } catch (err) {
+ console.error("Ошибка продолжения корпоративного входа:", err);
+ setError("Вход выполнен, но не удалось открыть внешнее приложение");
} finally {
setLoading(false);
}
};
+ useEffect(() => {
+ if (status !== "authenticated" || oidcRedirectStartedRef.current) return;
+ const nextUrl = new URLSearchParams(window.location.search).get("next");
+ if (!nextUrl) return;
+
+ setLoading(true);
+ void continueAfterLogin().catch((caught) => {
+ console.error("Ошибка продолжения корпоративного входа:", caught);
+ setError("Не удалось продолжить вход во внешнее приложение");
+ setLoading(false);
+ });
+ }, [continueAfterLogin, status]);
+
const handleCancelQrLogin = async () => {
clearPollTimer();
qrCompletedRef.current = true;
diff --git a/frontend/src/contexts/UserContext.tsx b/frontend/src/contexts/UserContext.tsx
index d08f9b29..ee0dd329 100644
--- a/frontend/src/contexts/UserContext.tsx
+++ b/frontend/src/contexts/UserContext.tsx
@@ -117,6 +117,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
clearRetryTimer();
try {
const cleanupResults = await Promise.allSettled([
+ apiClient.logoutBrowserSession(),
apiClient.unsubscribePushForDeviceGroup(getDeviceGroupId()),
unsubscribeAllLocalPushSubscriptions(),
]);
diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts
index aba895b2..8f3bb19d 100644
--- a/frontend/src/lib/api/client.ts
+++ b/frontend/src/lib/api/client.ts
@@ -16,6 +16,10 @@ interface LoginCredentials {
password: string;
}
+interface BrowserSessionResponse {
+ redirect_url: string;
+}
+
export type ApiRequestOptions = RequestInit & {
timeoutMs?: number;
};
@@ -308,6 +312,21 @@ export class ApiClientBase {
this.setToken(data.access);
}
+ async establishBrowserSession(nextUrl: string): Promise {
+ const data = await this.request('/api/auth/browser-session/', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ next: nextUrl }),
+ });
+ return data.redirect_url;
+ }
+
+ async logoutBrowserSession(): Promise {
+ await this.request('/api/auth/browser-session/logout/', {
+ method: 'POST',
+ });
+ }
+
async loginWithQrToken(token: string): Promise {
const data = await this.unauthenticatedRequest('/api/auth/qr-login/exchange/', {
method: 'POST',