From c7ba75308719010d9267393fb560ea0cb0dc6afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 4 Sep 2026 12:47:14 +0300 Subject: [PATCH] Allow browser SSO logout without JWT authentication --- .../api/auth/endpoints/browser_sessions.py | 10 +- backend/tests/api/auth/test_oidc_provider.py | 93 +++++++++++++++++++ docs/guides/OIDC.md | 14 +++ frontend/src/app/login/page.tsx | 14 ++- frontend/src/contexts/UserContext.tsx | 3 + frontend/src/lib/api/client.test.js | 25 +++++ frontend/src/lib/api/client.ts | 6 +- 7 files changed, 156 insertions(+), 9 deletions(-) diff --git a/backend/api/auth/endpoints/browser_sessions.py b/backend/api/auth/endpoints/browser_sessions.py index 1319802d..18e8ae9c 100644 --- a/backend/api/auth/endpoints/browser_sessions.py +++ b/backend/api/auth/endpoints/browser_sessions.py @@ -8,7 +8,7 @@ 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.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -70,8 +70,12 @@ class BrowserSessionAPIView(APIView): class BrowserSessionLogoutAPIView(APIView): - authentication_classes = [SessionAwareJWTAuthentication] - permission_classes = [IsAuthenticated] + # Clearing the server-side SSO session must remain possible after the JWT + # has expired or has already been removed by another tab. Requiring JWT + # authentication here can leave a stale Django session that silently logs + # the browser back into an external OIDC client as the previous user. + authentication_classes = [] + permission_classes = [AllowAny] @extend_schema( tags=["Auth"], diff --git a/backend/tests/api/auth/test_oidc_provider.py b/backend/tests/api/auth/test_oidc_provider.py index d6b31d56..30f64ef3 100644 --- a/backend/tests/api/auth/test_oidc_provider.py +++ b/backend/tests/api/auth/test_oidc_provider.py @@ -9,6 +9,7 @@ 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.test import Client from django.urls import reverse from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings @@ -265,6 +266,98 @@ def test_browser_session_bridge_accepts_only_own_authorization_endpoint(employee assert rejected.status_code == 400 +def test_browser_session_logout_clears_stale_sso_session_without_jwt( + employee, + oidc_client, +): + application, _ = oidc_client + api = APIClient() + api.force_authenticate(employee) + + established = api.post( + reverse("api:browser-session"), + {"next": ""}, + format="json", + ) + assert established.status_code == 200 + assert api.session["_auth_user_id"] == str(employee.pk) + + # Simulate the frontend reaching logout after JWT removal/expiry. The + # endpoint must still flush the independent Django/OIDC browser session. + anonymous_api = APIClient() + anonymous_api.cookies = api.cookies + logged_out = anonymous_api.post( + reverse("api:browser-session-logout"), + format="json", + ) + assert logged_out.status_code == 204 + assert "_auth_user_id" not in anonymous_api.session + + authorization = anonymous_api.get( + reverse("oauth2_provider:authorize"), + { + "response_type": "code", + "client_id": application.client_id, + "redirect_uri": application.redirect_uris, + "scope": "openid profile email", + "state": "after-logout", + }, + ) + assert authorization.status_code == 302 + assert urlparse(authorization["Location"]).path == "/login" + + +def test_browser_session_bridge_replaces_previous_portal_user( + employee, + oidc_client, + configured_oidc, +): + application, raw_secret = oidc_client + replacement = User.objects.create_user( + email="anna.smirnova@example.test", + phone_number="+79990000092", + password="Str0ngPass!", + username="asmirnova", + first_name="Анна", + last_name="Смирнова", + is_active=True, + send_activation_email=False, + ) + replacement.email_verified = True + replacement.save(update_fields=["email_verified"]) + + api = APIClient() + api.force_authenticate(employee) + established = api.post( + reverse("api:browser-session"), + {"next": ""}, + format="json", + ) + assert established.status_code == 200 + + api.force_authenticate(replacement) + replaced = api.post( + reverse("api:browser-session"), + {"next": ""}, + format="json", + ) + assert replaced.status_code == 200 + browser = Client() + browser.cookies = api.cookies + + code = _authorize(browser, application) + tokens = _exchange_code(browser, application, raw_secret, code) + claims = jwt.decode( + tokens["id_token"], + configured_oidc, + algorithms=["RS256"], + audience=application.client_id, + issuer="https://corp.example.test/o", + ) + assert claims["sub"] == str(replacement.pk) + assert claims["preferred_username"] == "asmirnova" + + def test_claim_helpers_have_stable_fallback_username_and_sorted_groups(employee): employee.username = None employee.save(update_fields=["username"]) diff --git a/docs/guides/OIDC.md b/docs/guides/OIDC.md index 3a3ea715..c2f3a831 100644 --- a/docs/guides/OIDC.md +++ b/docs/guides/OIDC.md @@ -181,6 +181,20 @@ curl -fsS https://corp.example.com/o/.well-known/jwks.json Discovery должен публиковать один и тот же issuer, HTTPS endpoints и `RS256`; JWKS содержит только публичную часть ключа. После этого пройти вход тестовой активной учёткой, проверить создание/привязку пользователя, имя, email и avatar, затем проверить отказ для деактивированной тестовой учётки. +## Сессия браузера и смена пользователя + +Портал хранит отдельную HttpOnly Django-сессию для OIDC. После каждого +успешного входа `/api/auth/browser-session/` должен перепривязать её к текущему +пользователю, включая обычный вход без параметра `next`. При выходе endpoint +`/api/auth/browser-session/logout/` очищает эту сессию независимо от состояния +JWT: access/refresh token к этому моменту уже может истечь или быть удалён в +другой вкладке. Это не позволяет внешнему приложению повторно войти под +предыдущей учётной записью. + +Сессии Gitea и портала остаются независимыми: выход только из Gitea не завершает +сеанс портала. Выход из портала очищает его OIDC-сессию, поэтому следующий вход +из Gitea потребует актуальной авторизации в портале. + ## Отзыв доступа и ротация - отключение сотрудника начинает блокировать access/refresh tokens при следующем обращении; diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 50212245..77c54832 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -62,15 +62,19 @@ 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); + // Synchronize the server-side OIDC session after every successful portal + // login, even when this was a regular login without an OIDC return URL. + // Otherwise switching portal accounts can leave the previous user in the + // Django session and the next external login silently reuses that user. + const redirectUrl = await apiClient.establishBrowserSession(nextUrl ?? ""); + if (!nextUrl) { + router.push("/"); + return; + } window.location.assign(redirectUrl); } catch (caught) { oidcRedirectStartedRef.current = false; diff --git a/frontend/src/contexts/UserContext.tsx b/frontend/src/contexts/UserContext.tsx index ee0dd329..2fa81997 100644 --- a/frontend/src/contexts/UserContext.tsx +++ b/frontend/src/contexts/UserContext.tsx @@ -85,6 +85,9 @@ export function UserProvider({ children }: { children: ReactNode }) { if (terminal) { clearRetryTimer(); retryAttemptRef.current = 0; + void apiClient.logoutBrowserSession().catch((logoutError) => { + console.warn('[auth] Не удалось очистить браузерную OIDC-сессию:', logoutError); + }); apiClient.clearToken('expired'); setUser(null); setStatus('unauthenticated'); diff --git a/frontend/src/lib/api/client.test.js b/frontend/src/lib/api/client.test.js index d3a7a3a5..74adcb0d 100644 --- a/frontend/src/lib/api/client.test.js +++ b/frontend/src/lib/api/client.test.js @@ -165,3 +165,28 @@ test('request timeout terminates the request without deleting tokens', async (t) assert.equal(storage.getItem('access_token'), 'access'); assert.equal(storage.getItem('refresh_token'), 'refresh'); }); + +test('browser SSO logout does not depend on JWT and includes its cookie', async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { globalThis.fetch = originalFetch; }); + const storage = installBrowserGlobals(); + storage.setItem('access_token', 'possibly-expired-access'); + storage.setItem('refresh_token', 'possibly-expired-refresh'); + + const requests = []; + globalThis.fetch = async (url, options = {}) => { + requests.push({ url, options }); + return new Response(null, { status: 204 }); + }; + + const client = new ApiClientBase(); + await client.logoutBrowserSession(); + + assert.equal(requests.length, 1); + assert.equal(requests[0].url, '/api/auth/browser-session/logout/'); + assert.equal(requests[0].options.method, 'POST'); + assert.equal(requests[0].options.credentials, 'include'); + assert.equal(requests[0].options.headers?.Authorization, undefined); + assert.equal(storage.getItem('access_token'), 'possibly-expired-access'); + assert.equal(storage.getItem('refresh_token'), 'possibly-expired-refresh'); +}); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index cc70a038..85bd3658 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -323,8 +323,12 @@ export class ApiClientBase { } async logoutBrowserSession(): Promise { - await this.request('/api/auth/browser-session/logout/', { + // This endpoint deliberately does not depend on JWT authentication: + // the browser SSO cookie must still be cleared when access/refresh + // tokens have expired or were removed by another tab. + await this.unauthenticatedRequest('/api/auth/browser-session/logout/', { method: 'POST', + credentials: 'include', }); }