"""Add-ons API endpoints""" from logging import getLogger from django.conf import settings from django.core.exceptions import SuspiciousOperation from rest_framework import decorators, viewsets from rest_framework import ( response as drf_response, ) from rest_framework import status as drf_status from core.addons.service import ( CSRFTokenError, SessionDataError, SessionExpiredError, SessionNotFoundError, SuspiciousSessionError, TokenExchangeService, TransitTokenError, ) from core.api.feature_flag import FeatureFlag from core.api.permissions import IsAuthenticated logger = getLogger(__name__) class SessionViewSet(viewsets.ViewSet): """ViewSet for managing add-on authentication sessions via token exchange. Implements a three-step flow that lets a third-party add-on (running in an embedded iframe) obtain an access token without exposing it to client-side JavaScript: 1. /init: the add-on opens a session and receives a short-lived transit token (used to bootstrap the OAuth-style exchange in a dialog) and a CSRF token. The opaque session id is stored in an HttpOnly, Secure, SameSite=None cookie so it can accompany cross-origin polls. 2. /poll: the add-on polls until the session transitions from pending to authenticated. On the terminal read, the session payload (access token, token type, expiry, etc.) is returned, the session is evicted server-side, and the session cookie is cleared so the tokens can be retrieved exactly once. 3. /exchange: called from the post-login callback page on our own domain, after the user has authenticated in a dialog opened by the addon. The transit token (carried client-side via postMessage + sessionStorage) is redeemed here for the authenticated user's access token, which is stored server-side against the session. Requires an authenticated user — that user is whose access token gets bound to the session. /init and /poll authenticate the caller through the session cookie + CSRF token pair alone — no user login is required, since the whole point of the flow is to bootstrap one. /exchange, by contrast, requires an authenticated user and does not use the addonsSid cookie. """ throttle_classes = [] @decorators.action( detail=False, methods=["POST"], url_path="init", authentication_classes=[], permission_classes=[], ) @FeatureFlag.require("addons") def init(self, request): """Open a new add-on authentication session. Creates a fresh session server-side and returns the credentials the add-on needs to drive the rest of the flow. """ transit_token, session_id, csrf_token = TokenExchangeService().init_session() response = drf_response.Response( {"transit_token": transit_token, "csrf_token": csrf_token}, status=drf_status.HTTP_201_CREATED, ) # SameSite=None allows the cookie to be sent on cross-origin requests, # which is required because the /poll endpoint is called from an iframe # embedded in a third-party site. Secure=True is mandatory when SameSite=None. # HttpOnly prevents JS access, so the cookie can only be read by the server. response.set_cookie( key=settings.ADDONS_SESSION_ID_COOKIE, value=session_id, max_age=settings.ADDONS_SESSION_TTL, httponly=True, secure=True, samesite="None", ) return response @decorators.action( detail=False, methods=["POST"], url_path="poll", authentication_classes=[], permission_classes=[], ) @FeatureFlag.require("addons") def poll(self, request): """Poll a session for its current state and, if terminal, consume it. Authenticates the caller using the addonsSid cookie (set by /init) together with the X-CSRFToken header, which must match the CSRF token issued for that session. The session id alone is not sufficient — both must be presented and must correspond. Behavior depends on the session's current state: - **Pending**: the token exchange has not yet completed. Returns 202 Accepted with `{"state": "pending"}`. The cookie is preserved so the add-on can keep polling. - **Authenticated** (or any other terminal state): returns 200 OK with the session payload (access token, token type, expiry, etc.) and clears the `addonsSid` cookie. The session is also evicted server-side on this terminal read, so the tokens can be retrieved exactly once. A CSRF mismatch is treated as a `SuspiciousOperation` rather than a normal 4xx, so it is logged by Django's security middleware and surfaced as a 400 without leaking which check failed. """ session_id = request.COOKIES.get(settings.ADDONS_SESSION_ID_COOKIE) submitted_csrf = request.headers.get("X-CSRFToken") if not session_id: return drf_response.Response( {"detail": "Missing credentials."}, status=drf_status.HTTP_401_UNAUTHORIZED, ) if not submitted_csrf: return drf_response.Response( {"detail": "Missing CSRF token."}, status=drf_status.HTTP_400_BAD_REQUEST, ) service = TokenExchangeService() try: service.verify_csrf(session_id, submitted_csrf) except CSRFTokenError as e: raise SuspiciousOperation(str(e)) from e try: session = service.get_session(session_id) except SessionNotFoundError: return drf_response.Response( {"detail": "Session not found."}, status=drf_status.HTTP_404_NOT_FOUND, ) except SessionDataError: return drf_response.Response( {"detail": "Invalid or expired session."}, status=drf_status.HTTP_400_BAD_REQUEST, ) if service.is_session_pending(session): return drf_response.Response( {"state": "pending"}, status=drf_status.HTTP_202_ACCEPTED ) response = drf_response.Response(session, status=drf_status.HTTP_200_OK) response.delete_cookie( key=settings.ADDONS_SESSION_ID_COOKIE, samesite="None", ) return response @decorators.action( detail=False, methods=["POST"], url_path="exchange", permission_classes=[IsAuthenticated], ) @FeatureFlag.require("addons") def exchange(self, request): """Redeem a transit token for an access token bound to the current user. Called from the post-OIDC callback page on our own domain. The transit token was issued by /init, passed to the authentication dialog via postMessage, stashed in sessionStorage, and read back by this page after login completes. The authenticated user (request.user) is whose access token gets stored against the session. On success, the addon's next /poll will transition from pending to authenticated and receive the token payload. Transit tokens are single-use: a replayed token is rejected with 400. """ transit_token = request.data.get("transit_token") if not transit_token: return drf_response.Response( {"detail": "Missing transit_token."}, status=drf_status.HTTP_400_BAD_REQUEST, ) service = TokenExchangeService() try: session_id = service.consume_transit_token(transit_token) except TransitTokenError: return drf_response.Response( {"detail": "Invalid or expired transit token."}, status=drf_status.HTTP_400_BAD_REQUEST, ) try: service.set_access_token(request.user, session_id) except SessionNotFoundError: return drf_response.Response( {"detail": "Session not found."}, status=drf_status.HTTP_404_NOT_FOUND, ) except (SessionDataError, SessionExpiredError, SuspiciousSessionError): return drf_response.Response( {"detail": "Invalid or expired session."}, status=drf_status.HTTP_400_BAD_REQUEST, ) return drf_response.Response({"status": "ok"}, status=drf_status.HTTP_200_OK)