From 8fb9cc85af691f1d6fde4807addaffdbc100533e Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 30 Sep 2026 12:41:56 -0400 Subject: [PATCH 1/3] closes OpenConceptLab/ocl_issues#2857 | "Sign in and start mapping" works on the first try for visitors from openconceptlab.org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Links from openconceptlab.org land on #/?referrer=…, and sign-in used the page URL, query string included, as the OIDC redirect_uri. The callback rebuilt the redirect_uri for the code exchange from the path alone, so Keycloak refused the exchange with "Incorrect redirect_uri", and retrying from the callback page carried its stale state and code into the next sign-in. Same fix as TBv3 (OpenConceptLab/oclweb3#58, ocl_issues#2856): sign-in always uses LOGIN_REDIRECT_URL, and the page to return to (its hash route) waits in this tab's sessionStorage. That page wins over next, which the callback still uses to rebuild the redirect_uri for sign-ins started before this deploy. Sign-up and password reset clear a page left by an abandoned sign-in, and /oidc/login, /signin and /signup are never return pages. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/common/utils.js | 23 +++++++++++++++++++++-- src/components/users/OIDLoginCallback.jsx | 11 ++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/common/utils.js b/src/common/utils.js index f92cc44..5e0d4b4 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,28 @@ export const consumeAndValidateOAuthState = returnedState => { return !returnedState || returnedState === storedState } +// 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 + if(route?.startsWith('/') && !/^\/(oidc\/login|signin|signup)(\/|\?|$)/.test(route)) + 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 +902,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 +913,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 = '/' From 5407db67f08fd117142bee098251a9d6c1523f0a Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 30 Sep 2026 12:50:02 -0400 Subject: [PATCH 2/3] OpenConceptLab/ocl_issues#2857 | The return-page guard matches paths case-insensitively, like the router From the Codex review: the router matches /SIGNUP and /OIDC/login the same as their lowercase forms, and /signin#section slipped past the delimiters. The guard now tests the path before any ? or #, ignoring case. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/common/utils.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/common/utils.js b/src/common/utils.js index 5e0d4b4..d1bd219 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -870,7 +870,9 @@ export const consumeAndValidateOAuthState = returnedState => { // 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 - if(route?.startsWith('/') && !/^\/(oidc\/login|signin|signup)(\/|\?|$)/.test(route)) + const path = route?.split(/[?#]/)[0] + // 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) From f69292aa9c1dad0fa07383363402fba27097781d Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Wed, 30 Sep 2026 12:55:11 -0400 Subject: [PATCH 3/3] OpenConceptLab/ocl_issues#2857 | The return-page guard decodes the path once, like the router From the Codex review: history decodes a route path once before the router matches it, so /%73ignin or /%53IGNUP opened the sign-in or sign-up page after all. The guard now checks the decoded path, and a malformed encoding, which would make the router throw, isn't saved. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/common/utils.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/common/utils.js b/src/common/utils.js index d1bd219..9797bd7 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -865,12 +865,22 @@ 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?.split(/[?#]/)[0] + 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)