mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 11:58:53 +00:00
wip rely on cookie for the session service
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user