feat: enhance OAuth flow and improve token handling

- Added state parameter to exchangeOrcidCode function for better state management during OAuth.
- Implemented storage event listener in AuthContext to handle token updates when postMessage fails.
- Updated AuthCallbackPage to ensure proper handling of OAuth popup closure and state updates.
This commit is contained in:
Alexis
2026-05-12 11:41:19 +02:00
parent 8beb6bc21c
commit fa2de55abe
5 changed files with 61 additions and 20 deletions
+7 -1
View File
@@ -25,11 +25,17 @@ def _key_func(request: Request) -> str:
Devuelve la clave de rate limit para el request. Devuelve la clave de rate limit para el request.
- Si hay un investigador autenticado en el state, usa su orcid_id. - 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) researcher = getattr(request.state, "researcher", None)
if researcher is not None: if researcher is not None:
return f"user:{getattr(researcher, 'orcid_id', None) or researcher.id}" 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)}" return f"ip:{get_remote_address(request)}"
+2 -2
View File
@@ -49,7 +49,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"geolocation=(), microphone=(), camera=(), payment=(), usb=(), " "geolocation=(), microphone=(), camera=(), payment=(), usb=(), "
"accelerometer=(), gyroscope=(), magnetometer=(), interest-cohort=()", "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("Cross-Origin-Resource-Policy", "same-site")
response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none") response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none")
@@ -66,7 +67,6 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
hsts += "; preload" hsts += "; preload"
response.headers.setdefault("Strict-Transport-Security", hsts) response.headers.setdefault("Strict-Transport-Security", hsts)
# `MutableHeaders` no implementa `.pop()`. Eliminamos de forma segura.
if "server" in response.headers: if "server" in response.headers:
del response.headers["server"] del response.headers["server"]
if "x-powered-by" in response.headers: if "x-powered-by" in response.headers:
+14
View File
@@ -78,6 +78,20 @@ export function AuthProvider({ children }) {
return () => window.removeEventListener("message", handleMessage); 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). * Stores a JWT directly (used by AuthCallbackPage).
* Does NOT trigger any network request. * Does NOT trigger any network request.
+34 -15
View File
@@ -36,6 +36,7 @@ export function AuthCallbackPage() {
hasHandledCodeRef.current = true; hasHandledCodeRef.current = true;
const code = searchParams.get("code"); const code = searchParams.get("code");
const state = searchParams.get("state");
const oauthError = searchParams.get("error"); const oauthError = searchParams.get("error");
const errorDescription = searchParams.get("error_description"); const errorDescription = searchParams.get("error_description");
@@ -69,7 +70,7 @@ export function AuthCallbackPage() {
} }
sessionStorage.setItem(consumedKey, "1"); sessionStorage.setItem(consumedKey, "1");
exchangeOrcidCode(code) exchangeOrcidCode(code, { state })
.then(({ access_token }) => { .then(({ access_token }) => {
storeToken(access_token); storeToken(access_token);
setStatus("success"); setStatus("success");
@@ -87,16 +88,29 @@ export function AuthCallbackPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// After a short delay, redirect to home if we're NOT in a popup // After a short delay, always attempt window.close():
// (fallback for browsers that block window.open). // - 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(() => { useEffect(() => {
if (status === "success" || status === "error") { if (status !== "success" && status !== "error") return;
const isPopup = Boolean(window.opener); const outer = setTimeout(() => {
if (!isPopup) { window.close();
const timer = setTimeout(() => navigate("/"), 2000); // Give the browser a tick to process the close. If the window
return () => clearTimeout(timer); // 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]); }, [status, navigate]);
return ( return (
@@ -154,9 +168,16 @@ export function AuthCallbackPage() {
/* ─────────────────────────── Helpers ───────────────────────────── */ /* ─────────────────────────── Helpers ───────────────────────────── */
/** /**
* If running in a popup, posts a message to the opener and closes the * If running in a popup, posts a message to the opener so the parent
* window. If not in a popup (e.g. browser blocked it), the message is * window can update its auth state without waiting for the storage event
* irrelevant — the useEffect above handles the redirect to "/". * 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) { function notifyAndClose(message) {
if (window.opener && !window.opener.closed) { if (window.opener && !window.opener.closed) {
@@ -165,8 +186,6 @@ function notifyAndClose(message) {
} catch { } catch {
/* opener may have navigated away */ /* opener may have navigated away */
} }
// Small delay so the user sees the success/error state before close.
setTimeout(() => window.close(), 1200);
} }
} }
+4 -2
View File
@@ -237,9 +237,11 @@ export function getOrcidAuthorizeUrl() {
* Intercambia el authorization code (recibido de ORCID tras el OAuth) * Intercambia el authorization code (recibido de ORCID tras el OAuth)
* por un JWT propio del backend. Devuelve `{ access_token, token_type }`. * 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( return request(
`/auth/orcid/callback?${new URLSearchParams({ code }).toString()}`, `/auth/orcid/callback?${new URLSearchParams(params).toString()}`,
{ signal }, { signal },
); );
} }