diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py index 92b2e82..942bb2b 100644 --- a/backend/app/core/rate_limit.py +++ b/backend/app/core/rate_limit.py @@ -25,11 +25,17 @@ def _key_func(request: Request) -> str: Devuelve la clave de rate limit para el request. - Si hay un investigador autenticado en el state, usa su orcid_id. - - En caso contrario, usa la IP remota. + - Si hay cabecera X-Forwarded-For (ngrok, nginx, cualquier proxy inverso), + usa la primera IP de la cadena (la del cliente real). + - En caso contrario, usa la IP remota del socket. """ researcher = getattr(request.state, "researcher", None) if researcher is not None: return f"user:{getattr(researcher, 'orcid_id', None) or researcher.id}" + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + client_ip = forwarded_for.split(",")[0].strip() + return f"ip:{client_ip}" return f"ip:{get_remote_address(request)}" diff --git a/backend/app/core/security_headers.py b/backend/app/core/security_headers.py index 18742c9..9a20ea8 100644 --- a/backend/app/core/security_headers.py +++ b/backend/app/core/security_headers.py @@ -49,7 +49,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): "geolocation=(), microphone=(), camera=(), payment=(), usb=(), " "accelerometer=(), gyroscope=(), magnetometer=(), interest-cohort=()", ) - response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") + + response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin-allow-popups") response.headers.setdefault("Cross-Origin-Resource-Policy", "same-site") response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none") @@ -66,7 +67,6 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): hsts += "; preload" response.headers.setdefault("Strict-Transport-Security", hsts) - # `MutableHeaders` no implementa `.pop()`. Eliminamos de forma segura. if "server" in response.headers: del response.headers["server"] if "x-powered-by" in response.headers: diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 4e4c833..5648c96 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -78,6 +78,20 @@ export function AuthProvider({ children }) { return () => window.removeEventListener("message", handleMessage); }, []); + // Fallback when postMessage cannot reach the opener (e.g. browser policy + // severs window.opener during the OAuth redirect chain). localStorage is + // shared between same-origin windows, so the popup's `setItem(...)` fires + // a storage event in this window and we can pick up the new token. + useEffect(() => { + function handleStorage(event) { + if (event.key !== STORAGE_KEY) return; + if (event.newValue) setToken(event.newValue); + else setToken(null); + } + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + /** * Stores a JWT directly (used by AuthCallbackPage). * Does NOT trigger any network request. diff --git a/frontend/src/pages/AuthCallbackPage.jsx b/frontend/src/pages/AuthCallbackPage.jsx index ae5bb8a..21d852e 100644 --- a/frontend/src/pages/AuthCallbackPage.jsx +++ b/frontend/src/pages/AuthCallbackPage.jsx @@ -36,6 +36,7 @@ export function AuthCallbackPage() { hasHandledCodeRef.current = true; const code = searchParams.get("code"); + const state = searchParams.get("state"); const oauthError = searchParams.get("error"); const errorDescription = searchParams.get("error_description"); @@ -69,7 +70,7 @@ export function AuthCallbackPage() { } sessionStorage.setItem(consumedKey, "1"); - exchangeOrcidCode(code) + exchangeOrcidCode(code, { state }) .then(({ access_token }) => { storeToken(access_token); setStatus("success"); @@ -87,16 +88,29 @@ export function AuthCallbackPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // After a short delay, redirect to home if we're NOT in a popup - // (fallback for browsers that block window.open). + // After a short delay, always attempt window.close(): + // - If we're in the OAuth popup (opened by window.open()), the browser + // allows close() and the window disappears. + // - If the window doesn't close (browser blocked it, or the user opened + // /callback directly as a plain tab), we detect that via window.closed + // and fall back to navigating to "/" so the user sees the landing page. + // + // NOTE: Neither window.opener nor window.name are reliable here. + // - window.name is cleared by Chrome on cross-origin navigation + // (our domain → sandbox.orcid.org → our domain clears the name). + // - window.opener is severed by ORCID Sandbox's own COOP header + // while the popup passes through their domain. useEffect(() => { - if (status === "success" || status === "error") { - const isPopup = Boolean(window.opener); - if (!isPopup) { - const timer = setTimeout(() => navigate("/"), 2000); - return () => clearTimeout(timer); - } - } + if (status !== "success" && status !== "error") return; + const outer = setTimeout(() => { + window.close(); + // Give the browser a tick to process the close. If the window + // is still open, we're in a plain tab — navigate to home instead. + setTimeout(() => { + if (!window.closed) navigate("/"); + }, 300); + }, 1500); + return () => clearTimeout(outer); }, [status, navigate]); return ( @@ -154,9 +168,16 @@ export function AuthCallbackPage() { /* ─────────────────────────── Helpers ───────────────────────────── */ /** - * If running in a popup, posts a message to the opener and closes the - * window. If not in a popup (e.g. browser blocked it), the message is - * irrelevant — the useEffect above handles the redirect to "/". + * If running in a popup, posts a message to the opener so the parent + * window can update its auth state without waiting for the storage event + * fallback in AuthContext. The actual `window.close()` is handled by the + * delayed effect above so we don't race with the success/error UI. + * + * `window.opener` may be `null` here when the browser severed the opener + * relationship during the OAuth redirect chain (some COOP combinations + * trigger this). In that case AuthContext picks up the new token via the + * `storage` event instead — that's why we still call `storeToken()` even + * when we can't postMessage. */ function notifyAndClose(message) { if (window.opener && !window.opener.closed) { @@ -165,8 +186,6 @@ function notifyAndClose(message) { } catch { /* opener may have navigated away */ } - // Small delay so the user sees the success/error state before close. - setTimeout(() => window.close(), 1200); } } diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index b6bde80..a192494 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -237,9 +237,11 @@ export function getOrcidAuthorizeUrl() { * Intercambia el authorization code (recibido de ORCID tras el OAuth) * por un JWT propio del backend. Devuelve `{ access_token, token_type }`. */ -export async function exchangeOrcidCode(code, { signal } = {}) { +export async function exchangeOrcidCode(code, { state, signal } = {}) { + const params = { code }; + if (state) params.state = state; return request( - `/auth/orcid/callback?${new URLSearchParams({ code }).toString()}`, + `/auth/orcid/callback?${new URLSearchParams(params).toString()}`, { signal }, ); }