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:
@@ -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)}"
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user