wip rely on cookie for the session service

This commit is contained in:
lebaudantoine
2026-03-30 23:24:16 +02:00
parent 3f77a9d884
commit f6ec01f3c8
6 changed files with 257 additions and 86 deletions
+1 -1
View File
@@ -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 ──────────────────────────────────────────────────────
@@ -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}`);
+64 -12
View File
@@ -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
+78 -12
View File
@@ -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
+1 -1
View File
@@ -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"),
]
),
+28 -1
View File
@@ -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
)