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