From 4827130b2bef658df6482d59c6d4e18b3fe3ac51 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Sun, 1 Nov 2026 08:07:25 -0500 Subject: [PATCH 1/2] OpenConceptLab/ocl_online#236 | TBv2 works at app.v2. as well as app. - getEnv moves to common/env.js and reads app.v2. as that environment - The TBv3 and Mapper links from app.v2. go to app.v3. and map. - Sign-in, sign-up and sign-out return to the host they started on, so the PKCE verifier in that origin's sessionStorage is there for the code exchange. LOGIN_REDIRECT_URL now only turns SSO on. On app. nothing changes: LOGIN_REDIRECT_URL is that same origin in every environment. Co-Authored-By: Claude Opus 5.5 --- src/common/__tests__/env.test.js | 44 +++++++++++++++++++++++ src/common/env.js | 22 ++++++++++++ src/common/utils.js | 32 +++++------------ src/components/users/OIDLoginCallback.jsx | 2 +- 4 files changed, 76 insertions(+), 24 deletions(-) create mode 100644 src/common/__tests__/env.test.js create mode 100644 src/common/env.js diff --git a/src/common/__tests__/env.test.js b/src/common/__tests__/env.test.js new file mode 100644 index 00000000..44933825 --- /dev/null +++ b/src/common/__tests__/env.test.js @@ -0,0 +1,44 @@ +/* global globalThis */ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { getEnv, toAppOrigin } from '../env.js' + +const envAt = origin => { + globalThis.window = {location: {origin}} + return [getEnv(), getEnv(true)] +} + +test('app.openconceptlab.org is production', () => { + assert.deepEqual(envAt('https://app.openconceptlab.org'), ['production', '']) +}) + +test('app..openconceptlab.org is that environment', () => { + assert.deepEqual(envAt('https://app.staging.openconceptlab.org'), ['staging', 'staging']) + assert.deepEqual(envAt('https://app.qa.openconceptlab.org'), ['qa', 'qa']) + assert.deepEqual(envAt('https://app.demo.openconceptlab.org'), ['demo', 'demo']) + assert.deepEqual(envAt('https://app.dev.openconceptlab.org'), ['dev', 'dev']) + assert.deepEqual(envAt('https://app.staging.who.openconceptlab.org'), ['staging-who', 'staging.who']) +}) + +test('app.v2.openconceptlab.org, where TBv2 stays once TBv3 is the default Term Browser, is production', () => { + assert.deepEqual(envAt('https://app.v2.openconceptlab.org'), ['production', '']) +}) + +test('app.v2..openconceptlab.org is that environment', () => { + assert.deepEqual(envAt('https://app.v2.staging.openconceptlab.org'), ['staging', 'staging']) + assert.deepEqual(envAt('https://app.v2.qa.openconceptlab.org'), ['qa', 'qa']) + assert.deepEqual(envAt('https://app.v2.demo.openconceptlab.org'), ['demo', 'demo']) + assert.deepEqual(envAt('https://app.v2.dev.openconceptlab.org'), ['dev', 'dev']) +}) + +test('any other host is development', () => { + assert.deepEqual(envAt('http://localhost:4000'), ['development', 'development']) +}) + +test('toAppOrigin maps app.v2. to app. and leaves other origins alone', () => { + assert.equal(toAppOrigin('https://app.v2.openconceptlab.org'), 'https://app.openconceptlab.org') + assert.equal(toAppOrigin('https://app.v2.staging.openconceptlab.org'), 'https://app.staging.openconceptlab.org') + assert.equal(toAppOrigin('https://app.qa.openconceptlab.org'), 'https://app.qa.openconceptlab.org') + assert.equal(toAppOrigin('http://localhost:4000'), 'http://localhost:4000') +}) diff --git a/src/common/env.js b/src/common/env.js new file mode 100644 index 00000000..98affecb --- /dev/null +++ b/src/common/env.js @@ -0,0 +1,22 @@ +// TBv2 is served from app[.].openconceptlab.org and from app.v2[.].openconceptlab.org, where it stays +// once TBv3 becomes the default Term Browser (OpenConceptLab/ocl_online#236); either host names the same environment. +export const toAppOrigin = origin => origin.replace('://app.v2.', '://app.') + +export const getEnv = forURL => { + const fqdn = toAppOrigin(window.location.origin); + + if(fqdn.match('app.staging.openconceptlab')) + return 'staging'; + if(fqdn.match('app.qa.openconceptlab')) + return 'qa'; + if(fqdn.match('app.demo.openconceptlab')) + return 'demo'; + if(fqdn.match('app.dev.openconceptlab')) + return 'dev'; + if(fqdn.match('app.staging.who.openconceptlab')) + return forURL ? 'staging.who' : 'staging-who'; + if(fqdn.match('app.openconceptlab')) + return forURL ? '' : 'production'; + + return 'development'; +} diff --git a/src/common/utils.js b/src/common/utils.js index 959cfc3d..1d0bac60 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -20,6 +20,7 @@ import GAService from '../services/GAService'; import { ensureIdToken } from './ssoSession'; import { SERVER_CONFIGS } from './serverConfigs'; import { windowConfig } from './windowConfig'; +import { getEnv, toAppOrigin } from './env'; export const currentPath = () => window.location.hash.split('?')[0]; @@ -688,24 +689,7 @@ export const arrayToSentence = (arr, separator, lastSeparator=' and ') => { export const generateRandomString = () => Math.random().toString(36).substring(7); -export const getEnv = forURL => { - const fqdn = window.location.origin; - - if(fqdn.match('app.staging.openconceptlab')) - return 'staging'; - if(fqdn.match('app.qa.openconceptlab')) - return 'qa'; - if(fqdn.match('app.demo.openconceptlab')) - return 'demo'; - if(fqdn.match('app.dev.openconceptlab')) - return 'dev'; - if(fqdn.match('app.staging.who.openconceptlab')) - return forURL ? 'staging.who' : 'staging-who'; - if(fqdn.match('app.openconceptlab')) - return forURL ? '' : 'production'; - - return 'development'; -} +export { getEnv }; export const getOpenMRSURL = () => { let env = getEnv(true); @@ -988,8 +972,10 @@ export const consumeAndValidateOAuthState = returnedState => { return !returnedState || returnedState === storedState } +// Sign-in, sign-up and sign-out come back to the host they started on (app. or app.v2.), since the +// PKCE verifier and OAuth state are in that origin's sessionStorage. LOGIN_REDIRECT_URL only turns SSO on. export const getLoginURL = async returnTo => { - let redirectURL = windowConfig('LOGIN_REDIRECT_URL') || process.env.LOGIN_REDIRECT_URL + let redirectURL = window.location.origin const oidClientID = windowConfig('OIDC_RP_CLIENT_ID') || process.env.OIDC_RP_CLIENT_ID redirectURL = redirectURL.replace(/([^:]\/)\/+/g, "$1"); @@ -1014,7 +1000,7 @@ export const getLoginURL = async returnTo => { } export const getRegisterURL = async returnTo => { - let redirectURL = returnTo || windowConfig('LOGIN_REDIRECT_URL') || process.env.LOGIN_REDIRECT_URL + let redirectURL = returnTo || window.location.origin const oidClientID = windowConfig('OIDC_RP_CLIENT_ID') || process.env.OIDC_RP_CLIENT_ID redirectURL = redirectURL.replace(/([^:]\/)\/+/g, "$1"); @@ -1036,7 +1022,7 @@ export const getRegisterURL = async returnTo => { } export const getSSOLogoutURL = returnTo => { - const redirectURL = returnTo || windowConfig('LOGIN_REDIRECT_URL') || process.env.LOGIN_REDIRECT_URL + const redirectURL = returnTo || window.location.origin const idToken = localStorage.id_token if(redirectURL && idToken) return `${getAPIURL()}/users/logout/?id_token_hint=${idToken}&post_logout_redirect_uri=${redirectURL}` @@ -1193,7 +1179,7 @@ export const toMapperURL = path => { if(['app.qa.openconceptlab.org', 'app.demo.openconceptlab.org'].includes(window.location.host)) url = 'https://map.qa.openconceptlab.org' if(window.location.host.match('app.*.openconceptlab.org')) - url = window.location.origin.replace('//app.', '//map.') + url = toAppOrigin(window.location.origin).replace('//app.', '//map.') // The Mapper reads auth only from the segment after the first '?' in the referrer's hash, so drop this page's own query let referrerParams = `referrer=${window.location.href.split('?')[0]}` @@ -1271,7 +1257,7 @@ export const toV3URL = path => { if(window.location.host?.includes('localhost')) url = 'http://localhost:4002' if(window.location.host.match('app.*.openconceptlab.org')) - url = window.location.origin.replace('//app.', '//app.v3.') + url = toAppOrigin(window.location.origin).replace('//app.', '//app.v3.') let referrerParams = `referrer=${window.location.href}` if(isLoggedIn()) diff --git a/src/components/users/OIDLoginCallback.jsx b/src/components/users/OIDLoginCallback.jsx index ed4706a1..d4bd65e5 100644 --- a/src/components/users/OIDLoginCallback.jsx +++ b/src/components/users/OIDLoginCallback.jsx @@ -36,7 +36,7 @@ class OIDLoginCallback extends React.Component { return } this.setState({next: next && next !== '/' ? next : null }, () => { - const redirectURL = this.state.next ? window.location.origin + this.state.next : (windowConfig('LOGIN_REDIRECT_URL') || process.env.LOGIN_REDIRECT_URL) + const redirectURL = this.state.next ? window.location.origin + this.state.next : window.location.origin const clientId = windowConfig('OIDC_RP_CLIENT_ID') || process.env.OIDC_RP_CLIENT_ID APIService.users().appendToUrl('oidc/code-exchange/').post({code: code, redirect_uri: redirectURL, client_id: clientId, code_verifier: codeVerifier}).then(res => { From 6cc36fa79620b134fdde39ee372a0fd5c473f585 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Sun, 1 Nov 2026 08:27:47 -0500 Subject: [PATCH 2/2] OpenConceptLab/ocl_online#236 | Sign-out goes through Keycloak only when SSO is on getSSOLogoutURL's redirect is now always set, so it no longer stood in for the SSO check. Without isSSOEnabled(), a stale id_token on a deployment with SSO off sent sign-out to the API's logout endpoint. Co-Authored-By: Claude Opus 5.5 --- src/common/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/utils.js b/src/common/utils.js index 1d0bac60..78d442a0 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -1024,7 +1024,7 @@ export const getRegisterURL = async returnTo => { export const getSSOLogoutURL = returnTo => { const redirectURL = returnTo || window.location.origin const idToken = localStorage.id_token - if(redirectURL && idToken) + if(isSSOEnabled() && idToken) return `${getAPIURL()}/users/logout/?id_token_hint=${idToken}&post_logout_redirect_uri=${redirectURL}` }