From f6ec01f3c82fa68d8f80f50870b6f0ec1adbd319 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 30 Mar 2026 23:24:16 +0200 Subject: [PATCH] wip rely on cookie for the session service --- src/addins/outlook-addin/src/common.js | 2 +- .../outlook-addin/src/taskpane/taskpane.js | 144 +++++++++++------- src/backend/core/addons/service.py | 76 +++++++-- src/backend/core/addons/viewsets.py | 90 +++++++++-- src/backend/core/urls.py | 2 +- src/backend/meet/settings.py | 29 +++- 6 files changed, 257 insertions(+), 86 deletions(-) diff --git a/src/addins/outlook-addin/src/common.js b/src/addins/outlook-addin/src/common.js index 3a17c652..1004cd33 100644 --- a/src/addins/outlook-addin/src/common.js +++ b/src/addins/outlook-addin/src/common.js @@ -1,6 +1,6 @@ /* global Office */ -const BASE_URL = "https://meet.127.0.0.1.nip.io"; +const BASE_URL = "https://meet.127.0.0.1.nip.io"; // todo - use env variable // ─── Session Storage ────────────────────────────────────────────────────── diff --git a/src/addins/outlook-addin/src/taskpane/taskpane.js b/src/addins/outlook-addin/src/taskpane/taskpane.js index 7435699c..b934741a 100644 --- a/src/addins/outlook-addin/src/taskpane/taskpane.js +++ b/src/addins/outlook-addin/src/taskpane/taskpane.js @@ -14,6 +14,72 @@ function setStatus(msg) { document.getElementById("status").textContent = msg; } +// ─── Polling ────────────────────────────────────────────────────────────── + +function startPolling(session_id, { onSuccess, onTimeout, onError }) { + let pollCount = 0; + const pollInterval = setInterval(() => { + // ─── Timeout after 3 minutes ────────────────────────────── + if (pollCount++ > 180) { + clearInterval(pollInterval); + onTimeout?.(); + return; + } + fetch(`${BASE_URL}/api/v1.0/addons/sessions/wip/`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id }), + }) + .then((res) => res.json()) + .then((sessionData) => { + console.log("Polling:", sessionData); + if (sessionData.state === "authenticated" && sessionData.access_token) { + clearInterval(pollInterval); + onSuccess?.(sessionData); + } + }) + .catch((err) => { + clearInterval(pollInterval); + onError?.(err); + }); + }, 1000); + + return pollInterval; +} + +// ─── Transit Dialog ─────────────────────────────────────────────────────── + +function openTransitDialog(session_id, { onCancel, onError }) { + const meetUrl = `${BASE_URL}/addons/transit/?session_id=${session_id}`; + + Office.context.ui.displayDialogAsync( + meetUrl, + { height: 60, width: 50, displayInIframe: false }, + (asyncResult) => { + if (asyncResult.status === Office.AsyncResultStatus.Failed) { + onError?.(asyncResult.error); + return; + } + + const dialog = asyncResult.value; + + dialog.addEventHandler(Office.EventType.DialogMessageReceived, () => { + onCancel?.(); + dialog.close(); + }); + + dialog.addEventHandler(Office.EventType.DialogEventReceived, (arg) => { + if (arg.error === 12006) { + setStatus("Dialog fermé. En attente d'authentification..."); + } + }); + + return dialog; + } + ); +} + // ─── Auth Flow ──────────────────────────────────────────────────────────── function connect() { @@ -28,68 +94,28 @@ function connect() { const session_id = data.session_id; setStatus("En attente d'authentification..."); - let dialog = null; - let pollInterval = null; - let pollCount = 0; - - pollInterval = setInterval(() => { - - // ─── Timeout after 3 minutes ────────────────────────────── - if (pollCount++ > 180) { - clearInterval(pollInterval); - if (dialog) dialog.close(); + const pollInterval = startPolling(session_id, { + onSuccess: (sessionData) => { + saveSession(sessionData); + setStatus("Connecté !"); + showView("auth"); + }, + onTimeout: () => { setStatus("Délai d'authentification dépassé. Veuillez réessayer."); showView("unauth"); - return; - } + }, + onError: (err) => { + setStatus(`Erreur de polling: ${err.message}`); + }, + }); - fetch(`${BASE_URL}/api/v1.0/addons/sessions/${session_id}`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }) - .then((res) => res.json()) - .then((sessionData) => { - console.log("Polling:", sessionData); - if (sessionData.state === "authenticated" && sessionData.access_token) { - clearInterval(pollInterval); - // if (dialog) dialog.close(); - saveSession(sessionData); - setStatus("Connecté !"); - showView("auth"); - } - }) - .catch((err) => { - clearInterval(pollInterval); - setStatus(`Erreur de polling: ${err.message}`); - }); - }, 1000); - - // ─── Open transit dialog ────────────────────────────────────── - const meetUrl = `${BASE_URL}/addons/transit/?session_id=${session_id}`; - Office.context.ui.displayDialogAsync( - meetUrl, - { height: 60, width: 50, displayInIframe: false }, - (asyncResult) => { - if (asyncResult.status === Office.AsyncResultStatus.Failed) { - clearInterval(pollInterval); - setStatus(`Erreur dialog: ${asyncResult.error.message}`); - return; - } - - dialog = asyncResult.value; - - dialog.addEventHandler(Office.EventType.DialogMessageReceived, () => { - clearInterval(pollInterval); - dialog.close(); - }); - - dialog.addEventHandler(Office.EventType.DialogEventReceived, (arg) => { - if (arg.error === 12006) { - setStatus("Dialog fermé. En attente d'authentification..."); - } - }); - } - ); + openTransitDialog(session_id, { + onCancel: () => clearInterval(pollInterval), + onError: (err) => { + clearInterval(pollInterval); + setStatus(`Erreur dialog: ${err.message}`); + }, + }); }) .catch((err) => { setStatus(`Erreur de connexion: ${err.message}`); diff --git a/src/backend/core/addons/service.py b/src/backend/core/addons/service.py index a3008888..d2bcb77a 100644 --- a/src/backend/core/addons/service.py +++ b/src/backend/core/addons/service.py @@ -15,6 +15,9 @@ from core.services.jwt_token import JwtTokenService logger = getLogger(__name__) +class SessionOwnershipError(Exception): + """Raised when the claimed session_id does not match the result_token binding.""" + class SessionState(str, Enum): """Add-on authentication session states.""" @@ -37,14 +40,20 @@ class TokenExchangeService: token_type=settings.ADDONS_JWT_TOKEN_TYPE, ) - def _get_cache_key(self, session_id: str) -> str: + def _session_cache_key(self, session_id: str) -> str: """Generate cache key for a session ID.""" return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}" - def init_session(self) -> str: + def _token_cache_key(self, result_token: str) -> str: + """Wip.""" + return f"{settings.ADDONS_SESSION_TOKEN_PREFIX}_{result_token}" + + def init_session(self) -> tuple[str, str]: """Create a new pending authentication session and return its ID.""" session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH) + result_token = secrets.token_urlsafe(32) # separate, never in any UR + expires_at = datetime.now(timezone.utc) + timedelta( seconds=settings.ADDONS_SESSION_TIMEOUT ) @@ -54,41 +63,80 @@ class TokenExchangeService: "expires_at": expires_at.isoformat(), } - cache_key = self._get_cache_key(session_id) + # Store the session itself cache.set( - cache_key, + self._session_cache_key(session_id), session_data, timeout=settings.ADDONS_SESSION_TIMEOUT, ) - return session_id + # Store the token → session_id binding (same TTL) + cache.set( + self._token_cache_key(result_token), + session_id, + timeout=settings.ADDONS_SESSION_TIMEOUT, + ) + print('$$ init session_id') + print(session_id) + + return session_id, result_token + + # todo - wip def get_session(self, session_id: str) -> dict: """Retrieve session data and clear it if authenticated.""" - cache_key = self._get_cache_key(session_id) + return self._get_and_maybe_clear(session_id) + + def get_session_by_token(self, result_token: str, claimed_session_id: str) -> dict: + """Resolve result_token → session_id → session data. + + Verifies that the claimed_session_id matches the token binding, + proving the caller initiated this session (ownership check). + Clears the session once authenticated (one-time read). + """ + session_id = cache.get(self._token_cache_key(result_token)) + if not session_id: + return {} + + print("$$$ session_id") + print(session_id) + + print("$$$ claimed_session_id") + print(claimed_session_id) + + if not secrets.compare_digest(session_id, claimed_session_id): + raise SessionOwnershipError("Session ID does not match token binding.") + + return self._get_and_maybe_clear(session_id) + + def _get_and_maybe_clear(self, session_id: str) -> dict: + """Wip.""" + + cache_key = self._session_cache_key(session_id) data = cache.get(cache_key) if not data: return {} if data.get("state") == SessionState.AUTHENTICATED: + # One-time read: clear both the session and the token binding self.clear_session(session_id) # Return copy without internal fields internal_fields = {"expires_at"} return {k: v for k, v in data.items() if k not in internal_fields} - def clear_session(self, session_id: str) -> None: - """Remove session data from cache.""" - - cache_key = self._get_cache_key(session_id) - cache.delete(cache_key) + def clear_session(self, session_id: str, result_token: str | None = None) -> None: + """Wip.""" + cache.delete(self._session_cache_key(session_id)) + if result_token: + cache.delete(self._token_cache_key(result_token)) def set_access_token(self, user: User, session_id: str): """Generate and store access token for an authenticated user session.""" - cache_key = self._get_cache_key(session_id) + cache_key = self._session_cache_key(session_id) existing_data = cache.get(cache_key) if not existing_data: @@ -122,3 +170,7 @@ class TokenExchangeService: } cache.set(cache_key, new_data, timeout=remaining_seconds) + + def token_to_session(self, result_token): + """wip.""" + return None diff --git a/src/backend/core/addons/viewsets.py b/src/backend/core/addons/viewsets.py index 9a04463e..467702a9 100644 --- a/src/backend/core/addons/viewsets.py +++ b/src/backend/core/addons/viewsets.py @@ -2,13 +2,17 @@ from logging import getLogger +from django.conf import settings +from django.core.exceptions import SuspiciousOperation + from rest_framework import ( response as drf_response, ) +from rest_framework import decorators from rest_framework import status as drf_status from rest_framework import viewsets -from core.addons.service import TokenExchangeService +from core.addons.service import TokenExchangeService, SessionOwnershipError logger = getLogger(__name__) @@ -21,15 +25,61 @@ class AuthSessionViewSet(viewsets.ViewSet): throttle_classes = [] def create(self, request): - """Create a new pending authentication session.""" - session_id = TokenExchangeService().init_session() - return drf_response.Response( + """Create a pending session. + + Returns session_id in the body (client forwards it to the 3rd-party view). + Sets result_token as an HttpOnly cookie (the only poll credential). + """ + session_id, result_token = TokenExchangeService().init_session() + response = drf_response.Response( {"session_id": session_id}, status=drf_status.HTTP_201_CREATED ) + response.set_cookie( + key=settings.ADDONS_RESULT_TOKEN_COOKIE_NAME, + value=result_token, + max_age=6000, + httponly=True, + secure=True, + samesite="None", + ) + return response - def retrieve(self, request, pk=None): - """Retrieve authentication session data by session ID.""" - data = TokenExchangeService().get_session(pk) + @decorators.action( + detail=False, + methods=["post"], + url_name="wip", + url_path="wip", + permission_classes=[], + authentication_classes=[], + ) + def long_poll(self, request): + """Long-poll endpoint — only the cookie is accepted, never a session_id. + + pk is intentionally ignored; the session is resolved from the cookie. + """ + + result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME) + session_id = request.data.get("session_id") + + if not result_token: + return drf_response.Response( + {"detail": "Missing result token."}, + status=drf_status.HTTP_401_UNAUTHORIZED, + ) + + if not session_id: + return drf_response.Response( + {"detail": "Missing result session id."}, + status=drf_status.HTTP_401_UNAUTHORIZED, + ) + + try: + data = TokenExchangeService().get_session_by_token( + result_token=result_token, + claimed_session_id=session_id, + ) + except SessionOwnershipError as e: + raise SuspiciousOperation(str(e)) from e if not data: return drf_response.Response( @@ -37,11 +87,27 @@ class AuthSessionViewSet(viewsets.ViewSet): status=drf_status.HTTP_404_NOT_FOUND, ) + if data.get("state") == "pending": + return drf_response.Response( + {"state": "pending"}, + status=drf_status.HTTP_202_ACCEPTED, + ) + return drf_response.Response(data, status=drf_status.HTTP_200_OK) def destroy(self, request, pk=None): - """Delete an authentication session by session ID.""" - TokenExchangeService().clear_session(pk) - return drf_response.Response( - {"status": "ok"}, status=drf_status.HTTP_204_NO_CONTENT - ) + """Explicit session teardown, resolves via cookie, not pk.""" + + result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME) + if not result_token: + return drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT) + + # We need the session_id to clear both keys — resolve it first + session_id = TokenExchangeService().token_to_session(result_token) + if session_id: + TokenExchangeService().clear_session(session_id, result_token) + + response = drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT) + response.delete_cookie(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME) + + return response diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index c81c4229..08d354fa 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -53,7 +53,7 @@ urlpatterns = [ [ *router.urls, *oidc_urls, - *addons_urls, + *addons_urls, # should be in external api path("config/", get_frontend_configuration, name="config"), ] ), diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index f2b0279d..872711d4 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -808,12 +808,18 @@ class Base(Configuration): environ_name="ADDONS_SESSION_ID_LENGTH", environ_prefix=None, ) - # Used in cache key generation + # Cache key for the session itself (session_id → session data) ADDONS_SESSION_KEY_PREFIX = values.Value( "addons_session_id", environ_name="ADDONS_SESSION_KEY_PREFIX", environ_prefix=None, ) + # Cache key for the token → session binding (result_token → session_id) + ADDONS_SESSION_TOKEN_PREFIX = values.Value( + "addons_token_id", + environ_name="ADDONS_SESSION_TOKEN_PREFIX", + environ_prefix=None, + ) # Used as the Django session key in transit page ADDONS_SESSION_KEY_AUTH = values.Value( "addons_session_id", @@ -823,6 +829,27 @@ class Base(Configuration): ADDONS_SESSION_TIMEOUT = values.PositiveIntegerValue( 600, environ_name="ADDONS_SESSION_TIMEOUT", environ_prefix=None ) + ADDONS_RESULT_TOKEN_COOKIE_NAME = values.Value( + "wip", + environ_name="ADDONS_RESULT_TOKEN_COOKIE_NAME", + environ_prefix=None, + ) + ADDONS_RESULT_TOKEN_COOKIE_SECURE = values.BooleanValue( + True, + environ_name="ADDONS_RESULT_TOKEN_COOKIE_SECURE", + environ_prefix=None, + ) + ADDONS_RESULT_TOKEN_COOKIE_HTTP_ONLY = values.BooleanValue( + True, + environ_name="ADDONS_RESULT_TOKEN_COOKIE_HTTP_ONLY", + environ_prefix=None, + ) + ADDONS_RESULT_TOKEN_COOKIE_SAMESITE = values.Value( + "strict", + environ_name="ADDONS_RESULT_TOKEN_COOKIE_SAMESITE", + environ_prefix=None, + ) + ADDONS_JWT_SECRET_KEY = SecretFileValue( None, environ_name="ADDONS_JWT_SECRET_KEY", environ_prefix=None )