diff --git a/src/common/utils.js b/src/common/utils.js index f92cc44..9797bd7 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -811,6 +811,7 @@ export const isDeprecatedBrowser = () => isIE() || isOpera(); const PKCE_CODE_VERIFIER_KEY = 'pkce_code_verifier' const OAUTH_STATE_KEY = 'oauth_state' +const OAUTH_RETURN_TO_KEY = 'oauth_return_to' const base64UrlEncode = buffer => { const bytes = new Uint8Array(buffer) @@ -864,12 +865,40 @@ export const consumeAndValidateOAuthState = returnedState => { return !returnedState || returnedState === storedState } +// A route's path as the router sees it: decoded once, as history does, so /%73ignup is /signup. A malformed +// encoding makes the router throw, so it has no path. +const routePath = route => { + try { + return decodeURI(route.split(/[?#]/)[0]) + } catch { + return null + } +} + +// Keycloak only redeems a code when the token request repeats the sign-in's redirect_uri exactly, and the +// callback can't rebuild a page's query string (e.g. ?referrer= on links from openconceptlab.org). So sign-in +// always goes through LOGIN_REDIRECT_URL, and the page to come back to (its hash route) waits here, in this tab. +const prepareOAuthReturnTo = returnTo => { + const route = returnTo?.includes('#') ? returnTo.slice(returnTo.indexOf('#') + 1) : null + const path = route && routePath(route) + // The router matches paths case-insensitively, so /SIGNUP would start a sign-up too. + if(path?.startsWith('/') && !/^\/(oidc\/login|signin|signup)(\/|$)/i.test(path)) + sessionStorage.setItem(OAUTH_RETURN_TO_KEY, route) + else + sessionStorage.removeItem(OAUTH_RETURN_TO_KEY) +} + +export const consumeOAuthReturnTo = () => { + const route = sessionStorage.getItem(OAUTH_RETURN_TO_KEY) + sessionStorage.removeItem(OAUTH_RETURN_TO_KEY) + return route +} + export const getLoginURL = async returnTo => { const oidClientID = window.OIDC_RP_CLIENT_ID || process.env.OIDC_RP_CLIENT_ID let redirectURL = window.LOGIN_REDIRECT_URL || process.env.LOGIN_REDIRECT_URL redirectURL = redirectURL.replace(/([^:]\/)\/+/g, "$1"); - if(returnTo && returnTo.includes('/#/') && returnTo.split('/#/')[1]) - redirectURL = returnTo.replace('/#/', '/') + prepareOAuthReturnTo(returnTo) const codeChallenge = await preparePKCECodeChallenge() const state = prepareOAuthState() const nonce = generateSecureRandomString(32) @@ -885,6 +914,7 @@ export const getResetPasswordURL = async returnTo => { redirectURL = redirectURL.replace(/([^:]\/)\/+/g, "$1"); + prepareOAuthReturnTo() const codeChallenge = await preparePKCECodeChallenge() return `${getAPIURL()}/users/password/reset/?client_id=${oidClientID}&redirect_uri=${redirectURL}&code_challenge=${codeChallenge}&code_challenge_method=S256` } @@ -895,6 +925,7 @@ export const getRegisterURL = async returnTo => { redirectURL = redirectURL.replace(/([^:]\/)\/+/g, "$1"); + prepareOAuthReturnTo() const codeChallenge = await preparePKCECodeChallenge() const state = prepareOAuthState(SIGNUP_STATE_PREFIX) const nonce = generateSecureRandomString(32) diff --git a/src/components/users/OIDLoginCallback.jsx b/src/components/users/OIDLoginCallback.jsx index 9f58ed7..36b98cf 100644 --- a/src/components/users/OIDLoginCallback.jsx +++ b/src/components/users/OIDLoginCallback.jsx @@ -3,7 +3,7 @@ import React from 'react'; import { withTranslation } from 'react-i18next'; import Button from '@mui/material/Button'; import { - refreshCurrentUserCache, consumeStoredPKCECodeVerifier, consumeAndValidateOAuthState, + refreshCurrentUserCache, consumeStoredPKCECodeVerifier, consumeAndValidateOAuthState, consumeOAuthReturnTo, isSignupOAuthState, isLoggedIn, getLoginURL } from '../../common/utils'; import APIService from '../../services/APIService' @@ -16,6 +16,7 @@ class OIDLoginCallback extends React.Component { super(props) this.state = { next: null, + returnTo: null, } } componentDidMount() { @@ -32,12 +33,14 @@ class OIDLoginCallback extends React.Component { const { setAlert } = this.context const isStateValid = consumeAndValidateOAuthState(state) const codeVerifier = consumeStoredPKCECodeVerifier() + const returnTo = consumeOAuthReturnTo() if(!isStateValid || !codeVerifier) { this.onSignInStartedElsewhere(state, next) return } setAlert({message: this.props.t('auth.signing_in'), severity: 'info'}) - this.setState({next: next && next !== '/' ? next : null }, () => { + // next still decides the redirect_uri sent for sign-ins that started before redirect_uri was fixed. + this.setState({next: next && next !== '/' ? next : null, returnTo: returnTo }, () => { const redirectURL = this.state.next ? window.location.origin + this.state.next : (window.LOGIN_REDIRECT_URL || process.env.LOGIN_REDIRECT_URL) const clientId = window.OIDC_RP_CLIENT_ID || process.env.OIDC_RP_CLIENT_ID @@ -86,7 +89,9 @@ class OIDLoginCallback extends React.Component { cacheUserData() { refreshCurrentUserCache(() => { - if(this.state.next) + if(this.state.returnTo) + window.location.hash = '#' + this.state.returnTo + else if(this.state.next) window.location.hash = '#' + this.state.next else { let returnToURL = '/'