From ffa03fa1742dad7cda3b09253e7250a8acd15ec5 Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Mon, 3 Aug 2026 09:52:23 -0700 Subject: [PATCH 01/16] ERA-13429 Collapse the login page's org branch to a single Auth0 path With rcuksa migrating off Auth0 Organizations, no site sets idp_org_id, so the org-scoped render branch is dead. The login page now shows one Auth0 path -- "Sign in with email" plus the migration info box -- and sends only the audience, ignoring an idp_org_id the status response still reports until ERA-13666 clears it. Co-Authored-By: Claude Opus 5 (1M context) --- src/Login/index.js | 15 ++++---- src/Login/index.test.js | 79 ++++++++++++----------------------------- 2 files changed, 29 insertions(+), 65 deletions(-) diff --git a/src/Login/index.js b/src/Login/index.js index 38fc1feab..49692ee03 100644 --- a/src/Login/index.js +++ b/src/Login/index.js @@ -12,7 +12,6 @@ import { import { ACCOUNT_LINKER_URL, SYSTEM_CONFIG_FLAGS } from '../constants'; import { APP_ROUTES } from '../constants/routes'; import appConfig from '../config'; -import { buildAuth0AuthorizationParams } from '../utils/auth0'; import { clearAuth, postAuth } from '../ducks/auth'; import { fetchEula } from '../ducks/eula'; import useNavigate from '../hooks/useNavigate'; @@ -47,19 +46,18 @@ const LoginPage = () => { const [formErrors, setFormErrors] = useState({ username: null, password: null }); const [isLoading, setIsLoading] = useState(false); - const idpOrgId = systemConfig?.idp_org_id?.trim() || null; const isEULAEnabled = !!systemConfig?.[SYSTEM_CONFIG_FLAGS.EULA]; const requireIdp = !!systemConfig?.require_idp; const onAuth0Login = useCallback(async () => { try { await auth0LoginWithRedirect({ - authorizationParams: buildAuth0AuthorizationParams(appConfig.auth0.audience, idpOrgId), + authorizationParams: { audience: appConfig.auth0.audience }, }); } catch (_error) { setAlertMessage(t('errorAlert.signInFailed')); } - }, [auth0LoginWithRedirect, idpOrgId, t]); + }, [auth0LoginWithRedirect, t]); const onFormSubmit = useCallback(async (event) => { event.preventDefault(); @@ -154,11 +152,10 @@ const LoginPage = () => {

{t('title')}

- {/* Auth0 migration guidance: shown only on common-DB sites (require_idp with - no idp_org_id). "Sign in with email" below auto-drives EarthRanger + {/* Auth0 migration guidance: "Sign in with email" below drives EarthRanger Identity; users who have not converted their account yet are linked to - the server account linker. Org-scoped sites show no box. */} - {requireIdp && !idpOrgId && ( + the server account linker. */} + {requireIdp && (

{t('auth0Info.title')} @@ -188,7 +185,7 @@ const LoginPage = () => { > {isAuth0Loading ? - : t(idpOrgId ? 'loginButtonIdp' : 'loginButtonEmail')} + : t('loginButtonEmail')} ) : ( diff --git a/src/Login/index.test.js b/src/Login/index.test.js index 856e85511..d5c36db69 100644 --- a/src/Login/index.test.js +++ b/src/Login/index.test.js @@ -88,12 +88,12 @@ describe('Login', () => { test('shows the Auth0 sign-in button and hides local credentials when IDP login is required', () => { store = mockStore({ data: { eula: { eula_url: '' } }, - view: { systemConfig: { require_idp: true, idp_org_id: 'org_abc' } }, + view: { systemConfig: { require_idp: true } }, }); renderLogin(); - const signIn = screen.getByRole('button', { name: 'Sign in' }); + const signIn = screen.getByRole('button', { name: 'Sign in with email' }); expect(signIn).toBeVisible(); expect(signIn).toHaveAttribute('type', 'button'); expect(signIn).toBeEnabled(); @@ -102,75 +102,56 @@ describe('Login', () => { expect(screen.queryByLabelText('Username')).not.toBeInTheDocument(); }); - test('calls loginWithRedirect with audience and organization when the user clicks Sign in', async () => { + test('calls loginWithRedirect with the audience when the user clicks Sign in with email', async () => { store = mockStore({ data: { eula: { eula_url: '' } }, - view: { systemConfig: { require_idp: true, idp_org_id: 'org_abc' } }, + view: { systemConfig: { require_idp: true } }, }); loginWithRedirect.mockResolvedValue(undefined); renderLogin(); - await userEvent.click(screen.getByRole('button', { name: 'Sign in' })); + await userEvent.click(screen.getByRole('button', { name: 'Sign in with email' })); expect(loginWithRedirect).toHaveBeenCalledWith({ - authorizationParams: { - audience: appConfig.auth0.audience, - organization: 'org_abc', - }, + authorizationParams: { audience: appConfig.auth0.audience }, }); }); - test('disables the Auth0 sign-in button and shows a loading state while Auth0 reports loading', () => { + test('sends no organization param on a site whose status response still reports an organization ID', async () => { store = mockStore({ data: { eula: { eula_url: '' } }, view: { systemConfig: { require_idp: true, idp_org_id: 'org_abc' } }, }); - useAuth0.mockReturnValue({ loginWithRedirect, isLoading: true }); + loginWithRedirect.mockResolvedValue(undefined); renderLogin(); - const button = screen.getByRole('button', { name: 'Loading' }); - expect(button).toHaveAttribute('type', 'button'); - expect(button).toHaveAttribute('aria-busy', 'true'); - expect(button).toHaveAttribute('aria-label', 'Loading'); - expect(button).toBeDisabled(); - }); + await userEvent.click(screen.getByRole('button', { name: 'Sign in with email' })); - test('shows the common-DB "Sign in with email" button and no configuration error when IDP is required without an organization ID', () => { - store = mockStore({ - data: { eula: { eula_url: '' } }, - view: { systemConfig: { require_idp: true } }, + expect(loginWithRedirect).toHaveBeenCalledWith({ + authorizationParams: { audience: appConfig.auth0.audience }, }); - - renderLogin(); - - const signIn = screen.getByRole('button', { name: 'Sign in with email' }); - expect(signIn).toBeVisible(); - expect(signIn).toHaveAttribute('type', 'button'); - expect(signIn).toBeEnabled(); - expect(screen.queryByText('Identity provider organization is not configured.')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Username')).not.toBeInTheDocument(); }); - test('calls loginWithRedirect with audience and no organization when the user clicks Sign in with email', async () => { + test('disables the Auth0 sign-in button and shows a loading state while Auth0 reports loading', () => { store = mockStore({ data: { eula: { eula_url: '' } }, view: { systemConfig: { require_idp: true } }, }); - loginWithRedirect.mockResolvedValue(undefined); + useAuth0.mockReturnValue({ loginWithRedirect, isLoading: true }); renderLogin(); - await userEvent.click(screen.getByRole('button', { name: 'Sign in with email' })); - - expect(loginWithRedirect).toHaveBeenCalledWith({ - authorizationParams: { audience: appConfig.auth0.audience }, - }); + const button = screen.getByRole('button', { name: 'Loading' }); + expect(button).toHaveAttribute('type', 'button'); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(button).toHaveAttribute('aria-label', 'Loading'); + expect(button).toBeDisabled(); }); describe('Auth0 sign-in info box', () => { - test('renders the info box on a common-DB Auth0 site (require_idp without an organization ID)', () => { + test('renders the info box on an Auth0 site', () => { store = mockStore({ data: { eula: { eula_url: '' } }, view: { systemConfig: { require_idp: true } }, @@ -196,33 +177,19 @@ describe('Login', () => { expect(screen.getByText('Questions? Reach out to your site admin.')).toBeVisible(); }); - test('treats a whitespace-only organization ID as common-DB and still renders the info box', () => { + test('renders the info box on a site whose status response still reports an organization ID', () => { store = mockStore({ data: { eula: { eula_url: '' } }, - view: { systemConfig: { require_idp: true, idp_org_id: ' ' } }, + view: { systemConfig: { require_idp: true, idp_org_id: 'org_abc' } }, }); renderLogin(); expect(screen.getByRole('heading', { level: 2 })).toBeVisible(); expect(screen.getByRole('link', { name: 'Convert your account' })).toBeVisible(); - // Whitespace-only org id is treated as no org -> common-DB "Sign in with email". expect(screen.getByRole('button', { name: 'Sign in with email' })).toBeVisible(); }); - test('does not render the info box on an org-scoped Auth0 site (require_idp with an organization ID)', () => { - store = mockStore({ - data: { eula: { eula_url: '' } }, - view: { systemConfig: { require_idp: true, idp_org_id: 'org_abc' } }, - }); - - renderLogin(); - - expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Convert your account' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Sign in' })).toBeVisible(); - }); - test('does not render the info box on a site without Auth0 configured', () => { store = mockStore({ data: { eula: { eula_url: '' } }, @@ -241,13 +208,13 @@ describe('Login', () => { test('shows a sign-in failure alert when loginWithRedirect rejects', async () => { store = mockStore({ data: { eula: { eula_url: '' } }, - view: { systemConfig: { require_idp: true, idp_org_id: 'org_abc' } }, + view: { systemConfig: { require_idp: true } }, }); loginWithRedirect.mockRejectedValue(new Error('Auth0 failed')); renderLogin(); - await userEvent.click(screen.getByRole('button', { name: 'Sign in' })); + await userEvent.click(screen.getByRole('button', { name: 'Sign in with email' })); await waitFor(() => { const alert = screen.getByText('Sign-in failed. Please try again.'); From b1233ebb6f8d62e97b76b7f10d17f76d2832f3e5 Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Mon, 3 Aug 2026 10:08:04 -0700 Subject: [PATCH 02/16] ERA-13429 Apply the account-linking gate to every Auth0 site The gate previously ran only on the common-DB path, skipping org-scoped sites. With the org branch gone, every Auth0 return goes through it, so rcuksa users with an unconverted account are handed to the server link page rather than straight through. Co-Authored-By: Claude Opus 5 (1M context) --- .../accountLinkingGate.integration.test.js | 2 +- src/Auth0TokenManager/index.js | 6 ++---- src/Auth0TokenManager/index.test.js | 8 ++++---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Auth0TokenManager/accountLinkingGate.integration.test.js b/src/Auth0TokenManager/accountLinkingGate.integration.test.js index 87d8f9b76..4840fcf2d 100644 --- a/src/Auth0TokenManager/accountLinkingGate.integration.test.js +++ b/src/Auth0TokenManager/accountLinkingGate.integration.test.js @@ -100,7 +100,7 @@ describe('post-callback account-linking gate', () => { }), { data: { token: { access_token: null } }, - view: { systemConfig: { require_idp: true, idp_org_id: null } }, // common-DB site + view: { systemConfig: { require_idp: true } }, }, applyMiddleware(thunk, promiseMiddleware), ); diff --git a/src/Auth0TokenManager/index.js b/src/Auth0TokenManager/index.js index c0e5b73ae..d4b9069a6 100644 --- a/src/Auth0TokenManager/index.js +++ b/src/Auth0TokenManager/index.js @@ -23,7 +23,6 @@ const Auth0TokenManager = () => { const navigate = useNavigate(); const existingToken = useSelector((state) => state.data.token?.access_token); - const idpOrgId = useSelector((state) => state.view.systemConfig?.idp_org_id); const requireIdp = useSelector((state) => !!state.view.systemConfig?.require_idp); const { isAuthenticated, getAccessTokenSilently, logout } = useAuth0(); @@ -54,8 +53,7 @@ const Auth0TokenManager = () => { return; } - // Account-linking gate — common-DB path only; org-scoped (rcuksa) sites skip it. - if (requireIdp && !idpOrgId?.trim()) { + if (requireIdp) { const { result, linkUrl } = await checkAccountLinked(safe); // Unlinked: hand off to the server-owned link page (always a validated URL). @@ -103,7 +101,7 @@ const Auth0TokenManager = () => { } }; ensureIdpToken(); - }, [dispatch, existingToken, getAccessTokenSilently, idpOrgId, isAuthenticated, logout, requireIdp, navigate, location.search]); + }, [dispatch, existingToken, getAccessTokenSilently, isAuthenticated, logout, requireIdp, navigate, location.search]); return null; }; diff --git a/src/Auth0TokenManager/index.test.js b/src/Auth0TokenManager/index.test.js index c94645baa..df64084bd 100644 --- a/src/Auth0TokenManager/index.test.js +++ b/src/Auth0TokenManager/index.test.js @@ -49,7 +49,7 @@ describe('Auth0TokenManager', () => { useSelector.mockImplementation((selector) => { const state = { data: { token: { access_token: null } }, - view: { systemConfig: { require_idp: true, idp_org_id: null } } + view: { systemConfig: { require_idp: true } } }; return selector(state); }); @@ -239,7 +239,7 @@ describe('Auth0TokenManager', () => { expect(applyAccessToken).not.toHaveBeenCalled(); }); - test('org-scoped (idp_org_id set): skips the gate and authenticates', async () => { + test('runs the gate on a site whose status response still reports an organization ID', async () => { useSelector.mockImplementation((selector) => selector({ data: { token: { access_token: null } }, view: { systemConfig: { require_idp: true, idp_org_id: 'org_abc' } }, @@ -248,9 +248,9 @@ describe('Auth0TokenManager', () => { renderAfterCallback(); await waitFor(() => { - expect(applyAccessToken).toHaveBeenCalledWith(VALID_TOKEN); + expect(checkAccountLinked).toHaveBeenCalledWith(VALID_TOKEN); }); - expect(checkAccountLinked).not.toHaveBeenCalled(); + expect(applyAccessToken).toHaveBeenCalledWith(VALID_TOKEN); }); }); }); From c4a4b8a50f19ad4f4c62cd9dcf6ec2a14785517a Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Mon, 3 Aug 2026 10:30:36 -0700 Subject: [PATCH 03/16] ERA-13429 Drop the organization param from MFA step-up Step-up now re-runs PKCE against the common DB like any other login, so the hook no longer reads systemConfig at all. buildAuth0AuthorizationParams is left with no callers and goes next. Co-Authored-By: Claude Opus 5 (1M context) --- src/hooks/useAuthRecovery.js | 7 ++----- src/hooks/useAuthRecovery.test.js | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/hooks/useAuthRecovery.js b/src/hooks/useAuthRecovery.js index 79f151269..d92637b12 100644 --- a/src/hooks/useAuthRecovery.js +++ b/src/hooks/useAuthRecovery.js @@ -1,11 +1,9 @@ import { useEffect } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; -import { useSelector } from 'react-redux'; import { useLocation } from 'react-router'; import appConfig from '../config'; import { registerAuthRecovery } from '../utils/auth-recovery'; -import { buildAuth0AuthorizationParams } from '../utils/auth0'; import { setIntendedPostAuth0SuccessRoute } from '../utils/auth'; /** @@ -15,7 +13,6 @@ import { setIntendedPostAuth0SuccessRoute } from '../utils/auth'; const useAuthRecovery = () => { const { getAccessTokenSilently, loginWithRedirect } = useAuth0(); const { pathname, search } = useLocation(); - const idpOrgId = useSelector((state) => state.view.systemConfig?.idp_org_id); useEffect(() => { registerAuthRecovery({ @@ -26,7 +23,7 @@ const useAuthRecovery = () => { setIntendedPostAuth0SuccessRoute(`${pathname}${search}`); await loginWithRedirect({ authorizationParams: { - ...buildAuth0AuthorizationParams(appConfig.auth0.audience, idpOrgId), + audience: appConfig.auth0.audience, ...(acrValues ? { acr_values: acrValues } : {}), ...(maxAge ? { max_age: maxAge } : {}), }, @@ -34,7 +31,7 @@ const useAuthRecovery = () => { return new Promise(() => {}); }, }); - }, [getAccessTokenSilently, loginWithRedirect, idpOrgId, pathname, search]); + }, [getAccessTokenSilently, loginWithRedirect, pathname, search]); }; export default useAuthRecovery; diff --git a/src/hooks/useAuthRecovery.test.js b/src/hooks/useAuthRecovery.test.js index 0a21825cf..f4738b9eb 100644 --- a/src/hooks/useAuthRecovery.test.js +++ b/src/hooks/useAuthRecovery.test.js @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react'; import { useAuth0 } from '@auth0/auth0-react'; -import { useSelector } from 'react-redux'; import { useLocation } from 'react-router'; import useAuthRecovery from './useAuthRecovery'; @@ -8,7 +7,6 @@ import { registerAuthRecovery } from '../utils/auth-recovery'; import { setIntendedPostAuth0SuccessRoute } from '../utils/auth'; jest.mock('@auth0/auth0-react'); -jest.mock('react-redux', () => ({ useSelector: jest.fn() })); jest.mock('react-router', () => ({ __esModule: true, useLocation: jest.fn() })); jest.mock('../utils/auth-recovery', () => ({ registerAuthRecovery: jest.fn() })); jest.mock('../utils/auth', () => ({ setIntendedPostAuth0SuccessRoute: jest.fn() })); @@ -24,7 +22,6 @@ describe('useAuthRecovery', () => { getAccessTokenSilently = jest.fn(); loginWithRedirect = jest.fn().mockResolvedValue(undefined); useAuth0.mockReturnValue({ getAccessTokenSilently, loginWithRedirect }); - useSelector.mockReturnValue(null); // idp_org_id (common-DB site) useLocation.mockReturnValue({ pathname: '/events/123', search: '?foo=bar' }); }); @@ -43,11 +40,11 @@ describe('useAuthRecovery', () => { expect(setIntendedPostAuth0SuccessRoute).toHaveBeenCalledWith('/events/123?foo=bar'); expect(loginWithRedirect).toHaveBeenCalledWith({ - authorizationParams: expect.objectContaining({ + authorizationParams: { audience: 'https://api.example', acr_values: 'urn:mfa', max_age: '3600', - }), + }, }); }); From 9d8129a1739ba1ad999924b8124dfba13502400c Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Mon, 3 Aug 2026 10:43:01 -0700 Subject: [PATCH 04/16] ERA-13429 Stop storing idp_org_id in system config Nothing reads it now that routing is two-way. The status response keeps sending the field until ERA-13666 clears it, so the payload drops it rather than passing it through. Co-Authored-By: Claude Opus 5 (1M context) --- src/ducks/system-config/index.js | 2 -- src/ducks/system-config/index.test.js | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ducks/system-config/index.js b/src/ducks/system-config/index.js index fe2fbde79..4139cf2a7 100644 --- a/src/ducks/system-config/index.js +++ b/src/ducks/system-config/index.js @@ -30,7 +30,6 @@ export const setSystemConfigFromSystemStatus = (systemStatus) => (dispatch) => { [SYSTEM_CONFIG_FLAGS.SUBJECTS]: systemStatus[SYSTEM_CONFIG_FLAGS.SUBJECTS] ?? true, [SYSTEM_CONFIG_FLAGS.TABLEAU]: systemStatus[SYSTEM_CONFIG_FLAGS.TABLEAU] ?? true, [SYSTEM_CONFIG_FLAGS.GEO_SPAN]: systemStatus[SYSTEM_CONFIG_FLAGS.GEO_SPAN] ?? null, - idp_org_id: systemStatus.idp_org_id || null, previewFeatures: systemStatus.preview_features || {}, require_idp: !!systemStatus.require_idp, sitename, @@ -75,7 +74,6 @@ export const INITIAL_STATE = { [SYSTEM_CONFIG_FLAGS.SUBJECTS]: false, [SYSTEM_CONFIG_FLAGS.TABLEAU]: false, [SYSTEM_CONFIG_FLAGS.GEO_SPAN]: null, - idp_org_id: null, previewFeatures: {}, require_idp: null, sitename: '', diff --git a/src/ducks/system-config/index.test.js b/src/ducks/system-config/index.test.js index 580bcdbc9..ab47ea3bb 100644 --- a/src/ducks/system-config/index.test.js +++ b/src/ducks/system-config/index.test.js @@ -51,6 +51,7 @@ describe('Ducks - System config', () => { [SYSTEM_CONFIG_FLAGS.SUBJECTS]: true, [SYSTEM_CONFIG_FLAGS.TABLEAU]: true, geoPermissionsEnabled: true, + idp_org_id: 'org_abc', preview_features: { community_input_admin_enabled: true }, show_track_days: true, site_name: 'Site name', @@ -76,7 +77,6 @@ describe('Ducks - System config', () => { [SYSTEM_CONFIG_FLAGS.SPATIAL_FEATURES]: true, [SYSTEM_CONFIG_FLAGS.SUBJECTS]: true, [SYSTEM_CONFIG_FLAGS.TABLEAU]: true, - idp_org_id: null, previewFeatures: { community_input_admin_enabled: true, }, @@ -202,7 +202,6 @@ describe('Ducks - System config', () => { [SYSTEM_CONFIG_FLAGS.SPATIAL_FEATURES]: true, [SYSTEM_CONFIG_FLAGS.SUBJECTS]: true, [SYSTEM_CONFIG_FLAGS.TABLEAU]: true, - idp_org_id: null, previewFeatures: { community_input_admin_enabled: true }, require_idp: null, showTrackDays: true, From 5f66650799c9dda07b2e47b3a17f9ad26c36f3f1 Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Mon, 3 Aug 2026 11:02:05 -0700 Subject: [PATCH 05/16] ERA-13429 Remove what the org-branch collapse orphaned buildAuth0AuthorizationParams lost its last caller when step-up stopped sending an organization, and the loginButtonIdp label died with the org render branch. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) --- public/locales/en-US/login.json | 1 - public/locales/es/login.json | 1 - public/locales/fr/login.json | 1 - public/locales/ne-NP/login.json | 1 - public/locales/pt/login.json | 1 - public/locales/sw/login.json | 1 - src/utils/auth.test.js | 2 +- src/utils/auth0.js | 12 ----------- src/utils/auth0.test.js | 38 +-------------------------------- 9 files changed, 2 insertions(+), 56 deletions(-) diff --git a/public/locales/en-US/login.json b/public/locales/en-US/login.json index 2d8df07cf..4e3e21b9f 100644 --- a/public/locales/en-US/login.json +++ b/public/locales/en-US/login.json @@ -25,7 +25,6 @@ "eulaLinkLabel": "EarthRanger EULA (opens in a new tab)", "loginButton": "Log in", "loginButtonEmail": "Sign in with email", - "loginButtonIdp": "Sign in", "loginButtonLoadingLabel": "Loading", "passwordLabel": "Password", "title": "Log In", diff --git a/public/locales/es/login.json b/public/locales/es/login.json index d57393682..558be0cf7 100644 --- a/public/locales/es/login.json +++ b/public/locales/es/login.json @@ -25,7 +25,6 @@ "eulaLinkLabel": "EULA de EarthRanger (se abre en una nueva pestaña)", "loginButton": "Iniciar sesión", "loginButtonEmail": "Iniciar sesión con correo electrónico", - "loginButtonIdp": "Iniciar sesión", "loginButtonLoadingLabel": "Cargando", "passwordLabel": "Contraseña", "title": "Iniciar sesión", diff --git a/public/locales/fr/login.json b/public/locales/fr/login.json index c8db968db..0657bbbff 100644 --- a/public/locales/fr/login.json +++ b/public/locales/fr/login.json @@ -25,7 +25,6 @@ "eulaLinkLabel": "EULA EarthRanger (s'ouvre dans un nouvel onglet)", "loginButton": "Se Connecter", "loginButtonEmail": "Se connecter avec un e-mail", - "loginButtonIdp": "Se Connecter", "loginButtonLoadingLabel": "Chargement", "passwordLabel": "Mot de passe", "title": "Connexion", diff --git a/public/locales/ne-NP/login.json b/public/locales/ne-NP/login.json index 730596763..33cd65f61 100644 --- a/public/locales/ne-NP/login.json +++ b/public/locales/ne-NP/login.json @@ -25,7 +25,6 @@ "eulaLinkLabel": "अर्थरेन्जर EULA (नयाँ ट्याबमा खुल्छ)", "loginButton": "लग इन", "loginButtonEmail": "इमेलबाट साइन इन", - "loginButtonIdp": "साइन इन", "loginButtonLoadingLabel": "लोड हुँदैछ", "passwordLabel": "पासवर्ड", "title": "लग इन", diff --git a/public/locales/pt/login.json b/public/locales/pt/login.json index 615ba0b9d..99df6a2fc 100644 --- a/public/locales/pt/login.json +++ b/public/locales/pt/login.json @@ -25,7 +25,6 @@ "eulaLinkLabel": "EULA EarthRanger (abre em uma nova guia)", "loginButton": "Conecte-se", "loginButtonEmail": "Conecte-se com e-mail", - "loginButtonIdp": "Conecte-se", "loginButtonLoadingLabel": "Carregando", "passwordLabel": "Senha", "title": "Entrar", diff --git a/public/locales/sw/login.json b/public/locales/sw/login.json index 137ac22b1..42f183966 100644 --- a/public/locales/sw/login.json +++ b/public/locales/sw/login.json @@ -25,7 +25,6 @@ "eulaLinkLabel": "EarthRanger EULA (hufungua kwenye kichupo kipya)", "loginButton": "Ingia", "loginButtonEmail": "Ingia kwa barua pepe", - "loginButtonIdp": "Ingia", "loginButtonLoadingLabel": "Inapakia", "passwordLabel": "Nenosiri", "title": "Ingia", diff --git a/src/utils/auth.test.js b/src/utils/auth.test.js index 5b95a9ed6..603cf81f1 100644 --- a/src/utils/auth.test.js +++ b/src/utils/auth.test.js @@ -25,7 +25,7 @@ describe('auth utils', () => { }); test('returns true when require_idp is true (loaded)', () => { - const systemConfig = { require_idp: true, sitename: 'Test Site', idp_org_id: 'org_123' }; + const systemConfig = { require_idp: true, sitename: 'Test Site' }; expect(isSystemConfigLoaded(systemConfig)).toBe(true); }); }); diff --git a/src/utils/auth0.js b/src/utils/auth0.js index cb942fc07..a656ca6ca 100644 --- a/src/utils/auth0.js +++ b/src/utils/auth0.js @@ -2,15 +2,3 @@ export const hasAuth0CallbackParams = (searchParams) => { const urlParams = new URLSearchParams(searchParams); return urlParams.has('code') && (urlParams.has('state') || urlParams.has('error')); }; - -// Build the authorizationParams for an Auth0 redirect login. The IdP -// organization is forwarded only when one is configured (a non-blank value -// after trimming); common-DB sites have no org and must omit the param -// entirely so Auth0 falls back to the tenant's Default Directory. -export const buildAuth0AuthorizationParams = (audience, idpOrgId) => { - const org = idpOrgId?.trim(); - return { - audience, - ...(org ? { organization: org } : {}), - }; -}; diff --git a/src/utils/auth0.test.js b/src/utils/auth0.test.js index a182ee93c..a13721d28 100644 --- a/src/utils/auth0.test.js +++ b/src/utils/auth0.test.js @@ -1,4 +1,4 @@ -import { buildAuth0AuthorizationParams, hasAuth0CallbackParams } from './auth0'; +import { hasAuth0CallbackParams } from './auth0'; describe('auth0 utils', () => { describe('hasAuth0CallbackParams', () => { @@ -47,40 +47,4 @@ describe('auth0 utils', () => { expect(hasAuth0CallbackParams('?foo=bar&code=abc123&baz=qux')).toBe(false); }); }); - - describe('buildAuth0AuthorizationParams', () => { - const AUDIENCE = 'https://example.org/api'; - - test('forwards the organization when an idp org id is provided', () => { - expect(buildAuth0AuthorizationParams(AUDIENCE, 'org_abc')).toEqual({ - audience: AUDIENCE, - organization: 'org_abc', - }); - }); - - test('omits the organization entirely when the idp org id is null', () => { - const params = buildAuth0AuthorizationParams(AUDIENCE, null); - expect(params).toEqual({ audience: AUDIENCE }); - expect(params).not.toHaveProperty('organization'); - }); - - test('omits the organization entirely when the idp org id is an empty string', () => { - const params = buildAuth0AuthorizationParams(AUDIENCE, ''); - expect(params).toEqual({ audience: AUDIENCE }); - expect(params).not.toHaveProperty('organization'); - }); - - test('omits the organization entirely when the idp org id is only whitespace', () => { - const params = buildAuth0AuthorizationParams(AUDIENCE, ' '); - expect(params).toEqual({ audience: AUDIENCE }); - expect(params).not.toHaveProperty('organization'); - }); - - test('forwards a trimmed organization when the idp org id has surrounding whitespace', () => { - expect(buildAuth0AuthorizationParams(AUDIENCE, ' org_abc ')).toEqual({ - audience: AUDIENCE, - organization: 'org_abc', - }); - }); - }); }); From 7a482b7c23148c799e0ba5b6a7f322de9869dd51 Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Wed, 5 Aug 2026 08:20:43 -0700 Subject: [PATCH 06/16] ERA-13805 Add a client registry to app config authorizationServers is keyed by the RFC 8414 issuer identifier a site advertises in its RFC 9728 metadata, and each entry names the grant it uses along with the client registration for it. It is also this build's trust policy: an advertised issuer the map does not name cannot be authenticated against, so an override replaces the map wholesale rather than merging -- a non-production build must not go on trusting the production tenant. $self is the reserved key for the site's own authorization server, django-oauth-toolkit serving /oauth2/token as das_web_client. That server is not a legacy side-channel to be branched around; it is an OAuth 2.0 registration like any other, differing only in grant type. Its issuer is the site origin, so it cannot be a literal key and is matched by predicate. Omitting it builds a client that holds no password registration at all. The map sits at the top level rather than under auth0, which now holds only the scalars the Auth0 provider and login paths still read. Those go once those paths read the registration discovery resolves; until then the example override has to supply both and keep them in step. Co-Authored-By: Claude Opus 5 (1M context) --- public/config.js.example | 27 ++++++++++++- src/config.js | 22 +++++++++++ src/config.test.js | 82 +++++++++++++++++++++++++++++----------- 3 files changed, 106 insertions(+), 25 deletions(-) diff --git a/public/config.js.example b/public/config.js.example index febdf287d..dd2161be6 100644 --- a/public/config.js.example +++ b/public/config.js.example @@ -1,12 +1,35 @@ // Copy this file to public/config.js for local development. // public/config.js is gitignored and will not be committed. // -// Without this file, the app uses production values -// from src/config.js. +// Without this file, the app uses production values from src/config.js — including the +// production Auth0 tenant and client ID, so a local build pointed at a dev site needs +// this override to sign in at all. +// +// authorizationServers is the client registry, keyed by the issuer identifier a site +// advertises at /.well-known/oauth-protected-resource. A site naming an issuer that is +// not a key here holds no registration and is not trusted; this map replaces the +// production one outright rather than merging into it. $self is the reserved key for the +// site's own authorization server (django-oauth-toolkit at /oauth2/token) — its issuer is +// the site origin, so it is matched by predicate rather than written out. +// +// The auth0 block below is still read directly by the Auth0 provider and the login paths. +// It goes away once those read the registration that discovery resolves; until then both +// have to be supplied and kept in step. window.__APP_CONFIG__ = { auth0: { audience: 'https://dev.pamdas.org/api', clientId: 'hLoPCTgBCrlLAVzqg74YSmOftaSfb5Uf', domain: 'auth-dev.pamdas.org', }, + authorizationServers: { + 'https://auth-dev.pamdas.org/': { + audience: 'https://dev.pamdas.org/api', + clientId: 'hLoPCTgBCrlLAVzqg74YSmOftaSfb5Uf', + grant: 'authorization_code', + }, + $self: { + clientId: 'das_web_client', + grant: 'password', + }, + }, }; diff --git a/src/config.js b/src/config.js index 9c46a0ea0..8cac0a8e0 100644 --- a/src/config.js +++ b/src/config.js @@ -6,12 +6,33 @@ // provided by a Kubernetes ConfigMap; for local development, copy // public/config.js.example to public/config.js. +// authorizationServers is this build's client registry, keyed by the RFC 8414 issuer +// identifier a site advertises in its RFC 9728 metadata. It is also the trust policy: +// a site naming an issuer absent from the map holds no registration here and cannot be +// authenticated against, so an override replaces the map wholesale instead of merging +// into it — a non-production build must not go on trusting the production tenant. +// +// $self is the reserved key for the site's own authorization server, django-oauth-toolkit +// serving /oauth2/token. Its issuer is the site origin, which differs per site and so +// cannot be written as a literal; it is matched by predicate instead. Omitting it builds +// a client that holds no password registration at all. const PRODUCTION_DEFAULTS = { auth0: { audience: 'https://pamdas.org/api', clientId: 'FHoeQpdko5EMFU8JjjCzjPWT7k1sqm20', domain: 'auth.pamdas.org', }, + authorizationServers: { + 'https://auth.pamdas.org/': { + audience: 'https://pamdas.org/api', + clientId: 'FHoeQpdko5EMFU8JjjCzjPWT7k1sqm20', + grant: 'authorization_code', + }, + $self: { + clientId: 'das_web_client', + grant: 'password', + }, + }, }; const overrides = window.__APP_CONFIG__ ?? {}; @@ -22,6 +43,7 @@ const appConfig = { clientId: overrides.auth0?.clientId ?? PRODUCTION_DEFAULTS.auth0.clientId, domain: overrides.auth0?.domain ?? PRODUCTION_DEFAULTS.auth0.domain, }, + authorizationServers: overrides.authorizationServers ?? PRODUCTION_DEFAULTS.authorizationServers, }; export default appConfig; diff --git a/src/config.test.js b/src/config.test.js index 1293a9a3f..1adc36fad 100644 --- a/src/config.test.js +++ b/src/config.test.js @@ -1,10 +1,25 @@ describe('appConfig', () => { + const PRODUCTION_AUTHORIZATION_SERVER = 'https://auth.pamdas.org/'; + const PRODUCTION_DEFAULTS = { + // The scalar audience/clientId/domain are still read by the Auth0 provider and the + // login paths, and remain until those read the registration discovery resolves. auth0: { audience: 'https://pamdas.org/api', clientId: 'FHoeQpdko5EMFU8JjjCzjPWT7k1sqm20', domain: 'auth.pamdas.org', }, + authorizationServers: { + [PRODUCTION_AUTHORIZATION_SERVER]: { + audience: 'https://pamdas.org/api', + clientId: 'FHoeQpdko5EMFU8JjjCzjPWT7k1sqm20', + grant: 'authorization_code', + }, + $self: { + clientId: 'das_web_client', + grant: 'password', + }, + }, }; afterEach(() => { @@ -34,49 +49,69 @@ describe('appConfig', () => { expect(appConfig).toEqual(PRODUCTION_DEFAULTS); }); - test('overrides all properties when full override is provided', () => { + test('carries a grant type on every registration, and no audience on the DAS one', () => { + const { default: appConfig } = require('./config'); + + const { authorizationServers } = appConfig; + expect(authorizationServers[PRODUCTION_AUTHORIZATION_SERVER].grant).toBe('authorization_code'); + expect(authorizationServers.$self).toEqual({ clientId: 'das_web_client', grant: 'password' }); + // django-oauth-toolkit does not accept an audience, so the DAS entry must not carry one. + expect(authorizationServers.$self).not.toHaveProperty('audience'); + }); + + test('replaces the trusted authorization servers rather than merging them, so a non-production build does not keep trusting the production tenant', () => { const override = { - auth0: { - audience: 'https://dev.pamdas.org/api', - clientId: 'devClientId123', - domain: 'auth-dev.pamdas.org', + authorizationServers: { + 'https://auth-dev.pamdas.org/': { + audience: 'https://dev.pamdas.org/api', + clientId: 'devClientId123', + grant: 'authorization_code', + }, + $self: { clientId: 'das_web_client', grant: 'password' }, }, }; window.__APP_CONFIG__ = override; const { default: appConfig } = require('./config'); - expect(appConfig).toEqual(override); + expect(appConfig.authorizationServers).toEqual(override.authorizationServers); + expect(appConfig.authorizationServers).not.toHaveProperty(PRODUCTION_AUTHORIZATION_SERVER); }); - test('overrides a single nested property while preserving other defaults', () => { - window.__APP_CONFIG__ = { auth0: { domain: 'auth-staging.pamdas.org' } }; + test('accepts several trusted authorization servers, each with its own client registration', () => { + window.__APP_CONFIG__ = { + authorizationServers: { + 'https://auth.pamdas.org/': { audience: 'https://pamdas.org/api', clientId: 'prodClient', grant: 'authorization_code' }, + 'https://auth-us.pamdas.org/': { audience: 'https://us.pamdas.org/api', clientId: 'usClient', grant: 'authorization_code' }, + }, + }; const { default: appConfig } = require('./config'); - expect(appConfig).toEqual({ - auth0: { - audience: PRODUCTION_DEFAULTS.auth0.audience, - clientId: PRODUCTION_DEFAULTS.auth0.clientId, - domain: 'auth-staging.pamdas.org', - }, - }); + expect(Object.keys(appConfig.authorizationServers)).toEqual([ + 'https://auth.pamdas.org/', + 'https://auth-us.pamdas.org/', + ]); }); - test('preserves empty string overrides instead of falling back to defaults', () => { - window.__APP_CONFIG__ = { auth0: { audience: '', clientId: 'devClientId', domain: 'auth-dev.pamdas.org' } }; + test('lets an override omit $self, building a client that holds no password registration', () => { + window.__APP_CONFIG__ = { + authorizationServers: { + 'https://auth-dev.pamdas.org/': { audience: 'a', clientId: 'c', grant: 'authorization_code' }, + }, + }; const { default: appConfig } = require('./config'); - expect(appConfig.auth0.audience).toBe(''); + expect(appConfig.authorizationServers).not.toHaveProperty('$self'); }); - test('falls back to defaults for null override values', () => { - window.__APP_CONFIG__ = { auth0: { audience: null } }; + test('falls back to defaults when the override supplies no authorization servers', () => { + window.__APP_CONFIG__ = { authorizationServers: null }; const { default: appConfig } = require('./config'); - expect(appConfig.auth0.audience).toBe(PRODUCTION_DEFAULTS.auth0.audience); + expect(appConfig.authorizationServers).toEqual(PRODUCTION_DEFAULTS.authorizationServers); }); test('ignores unrecognized top-level keys', () => { @@ -89,11 +124,12 @@ describe('appConfig', () => { }); test('ignores unrecognized keys within a known group', () => { - window.__APP_CONFIG__ = { auth0: { domain: 'auth-dev.pamdas.org', unknownKey: 'value' } }; + window.__APP_CONFIG__ = { + auth0: { domain: 'auth-dev.pamdas.org', unknownKey: 'value' }, + }; const { default: appConfig } = require('./config'); - expect(appConfig.auth0.domain).toBe('auth-dev.pamdas.org'); expect(appConfig.auth0).not.toHaveProperty('unknownKey'); }); }); From ab7e76787c1eca29981fcafec77e671318d5beae Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Wed, 5 Aug 2026 08:20:44 -0700 Subject: [PATCH 07/16] ERA-13805 Add the RFC 9728 probe and resolve to a client registration Fetches the site's protected-resource document and answers with a registration rather than a routing decision: one pass over what the server advertised, in the server's order, taking the first issuer this build holds a registration for. The registry is the trust policy, so there is no Auth0-versus-legacy branch -- the grant is the only thing that differs, and it is what callers dispatch on. Falling past an unrecognized issuer is deliberate. Server emits Auth0 first, so a client that can use Auth0 always does; reaching a later entry means the server named a server we cannot use while also naming one we can, and refusing then would decline a login the server sanctioned. `skipped` keeps that mismatch visible without making it a failure. The probe answers before any token exists, so it runs on a bare axios client rather than inheriting the app's Authorization header, cancel token, or 401-to-login handling, and an abort deadline keeps startup from waiting on a server that never answers. A document is trusted only when its resource is the origin it came from: an ingress that does not route the well-known path serves the SPA shell instead, so a 200 alone proves nothing. Same-origin is matched on the origin boundary, so a host merely beginning with this one cannot pass. Failure is one of three reasons, reported to the console rather than the screen: a site that answered but not usefully is an administrator's problem, one that did not answer may just be a bad moment, and one advertising nothing we hold is the wrong build for that site. Whoever debugs it gets the reason, the URL probed, the status, and both the advertised and registered issuers. It also reads back an issuer stashed across the Auth0 redirect, running it through the same registry, so the callback leg never has to depend on a live probe. Nothing dispatches this yet. Co-Authored-By: Claude Opus 5 (1M context) --- src/ducks/auth-discovery/index.js | 214 +++++++++++++ src/ducks/auth-discovery/index.test.js | 415 +++++++++++++++++++++++++ src/utils/auth.js | 30 ++ src/utils/auth.test.js | 50 ++- 4 files changed, 703 insertions(+), 6 deletions(-) create mode 100644 src/ducks/auth-discovery/index.js create mode 100644 src/ducks/auth-discovery/index.test.js diff --git a/src/ducks/auth-discovery/index.js b/src/ducks/auth-discovery/index.js new file mode 100644 index 000000000..203563cc2 --- /dev/null +++ b/src/ducks/auth-discovery/index.js @@ -0,0 +1,214 @@ +import axios from 'axios'; + +import appConfig from '../../config'; +import { DAS_HOST } from '../../constants'; +import { clearResolvedIssuer, getResolvedIssuer } from '../../utils/auth'; + +export const PROTECTED_RESOURCE_URL = `${DAS_HOST}/.well-known/oauth-protected-resource`; + +// Reserved registry key for the site's own authorization server. Its issuer is the site +// origin, which differs per site, so it is matched by predicate rather than by key. +const SELF = '$self'; + +const PROBE_TIMEOUT_MS = 3000; + +export const REASON = { + UNREACHABLE: 'unreachable', + SITE_NOT_READY: 'site_not_ready', + NO_USABLE_AS: 'no_usable_as', +}; + +// Both authorization servers are OAuth 2.0; the grant is the only thing that differs, and +// it is what the client dispatches on. +export const GRANT = { + AUTHORIZATION_CODE: 'authorization_code', + PASSWORD: 'password', +}; + +// Actions +export const SET_AUTH_DISCOVERY = 'AUTH_DISCOVERY.SET_AUTH_DISCOVERY'; + +// The probe answers before any token exists and must not inherit the app's Authorization +// header, cancel token, or 401-to-login handling. Built on first use rather than at import, +// so merely importing this module does not reach into axios. +let probeClient = null; +const getProbeClient = () => { + if (!probeClient) probeClient = axios.create(); + return probeClient; +}; + +// RFC 8414 s2: an issuer identifier is a URL bearing no query or fragment. Scheme and host are +// case-insensitive and the parser folds those, along with a default port; the path is not, so +// lowercasing the whole string would equate issuers that differ -- too loose for the comparison +// deciding who this build authenticates against. The trailing slash Auth0 carries is not +// significant. Anything unparseable is no identity at all rather than a string that might +// collide with one. +const asIssuerIdentity = (value) => { + let url; + try { + url = new URL(String(value).trim()); + } catch { + return null; + } + + if (url.search || url.hash) return null; + + return `${url.origin}${url.pathname}`.replace(/\/+$/, ''); +}; + +// Match on the origin boundary, not a bare prefix: a host that merely begins with this one +// (site.example.attacker.test) is a different site. +const isThisSite = (issuerIdentity) => { + const origin = asIssuerIdentity(DAS_HOST); + if (!origin) return false; + + return issuerIdentity === origin || issuerIdentity.startsWith(`${origin}/`); +}; + +// The issuer travels with the registration, and it is the registered key rather than the string +// that matched it: what reaches the Auth0 SDK as its domain is then a value this build holds, +// not one a site or a restored stash chose the spelling of. +const matchRegistration = (issuer, registry) => { + const identity = asIssuerIdentity(issuer); + if (!identity) return null; + + const literal = Object.entries(registry) + .find(([key]) => key !== SELF && asIssuerIdentity(key) === identity); + if (literal) return { ...literal[1], issuer: literal[0] }; + + // $self is matched by predicate and holds no key to carry, so the normalised form stands in + // -- still this module's own output rather than the site's spelling. Nothing external + // consumes it: the password grant posts to a URL built from DAS_HOST. + return registry[SELF] && isThisSite(identity) + ? { ...registry[SELF], issuer: identity } + : null; +}; + +// The registry is this build's trust policy, so resolution is a single pass over what the +// server advertised, in the server's order, taking the first issuer we hold a registration +// for. Falling past an unrecognized issuer is deliberate: Server emits Auth0 first, so a +// client that can use Auth0 always does, and reaching a later entry means the server named +// an authorization server this build cannot use while also naming one it can. Refusing then +// would decline a login the server sanctioned; `skipped` keeps the mismatch visible. +export const resolveAdvertised = (advertised, registry = appConfig.authorizationServers) => { + const skipped = []; + + for (const issuer of advertised) { + const registration = matchRegistration(issuer, registry); + if (registration) return { ok: true, ...registration, skipped }; + skipped.push(issuer); + } + + return { ok: false, reason: REASON.NO_USABLE_AS }; +}; + +// RFC 9728 s3.3: a document whose resource is not the origin it was fetched from must be +// discarded. An ingress that does not route the well-known path answers with the SPA shell, +// so a 200 is not on its own evidence of a real document. +const readAuthorizationServers = (document) => { + const resource = asIssuerIdentity(document?.resource); + if (!resource || resource !== asIssuerIdentity(DAS_HOST)) return null; + + const authorizationServers = document.authorization_servers; + if (!Array.isArray(authorizationServers) || !authorizationServers.length) return null; + + return authorizationServers; +}; + +// A site that answered but not usefully is an administrator's problem; one that did not +// answer at all may just be a bad moment. +const reasonFor = (error) => { + const status = error?.response?.status; + if (!status || status >= 500) return REASON.UNREACHABLE; + return REASON.SITE_NOT_READY; +}; + +// The screen tells the reader one thing, because refreshing or finding an administrator is all +// they can do. Everything that would identify the cause is logged here, where it still exists. +const reportFailure = (reason, detail) => console.warn( + 'EarthRanger authorization discovery failed', + { reason, probed: PROTECTED_RESOURCE_URL, ...detail }, +); + +// Action creators +export const fetchAuthDiscovery = ({ timeoutMs = PROBE_TIMEOUT_MS } = {}) => async (dispatch) => { + // App startup waits on this probe, so a server that never answers is cut loose. + const abortProbe = new AbortController(); + const deadline = setTimeout(() => abortProbe.abort(), timeoutMs); + + let discovery; + try { + const { data } = await getProbeClient().get(PROTECTED_RESOURCE_URL, { signal: abortProbe.signal }); + const advertised = readAuthorizationServers(data); + + if (!advertised) { + discovery = { ok: false, reason: REASON.SITE_NOT_READY }; + reportFailure(discovery.reason, { resource: data?.resource, body: typeof data }); + } else { + discovery = resolveAdvertised(advertised); + if (!discovery.ok) { + reportFailure(discovery.reason, { + advertised, + registered: Object.keys(appConfig.authorizationServers).filter((key) => key !== SELF), + }); + } + } + } catch (error) { + discovery = { ok: false, reason: reasonFor(error) }; + reportFailure(discovery.reason, { status: error?.response?.status, cause: error?.message }); + } finally { + clearTimeout(deadline); + } + + dispatch({ type: SET_AUTH_DISCOVERY, payload: discovery }); +}; + +// Auth0Provider has to be mounted to exchange ?code&state, so the callback leg cannot wait on +// a live probe: a failure there would spend the code and bounce the user with nothing said. +// The issuer resolved before the redirect is read back instead and run through the same +// registry, so the trust policy gates the restored path exactly as it gates a probe result. +// Resolves false when there was nothing stashed, leaving the caller to probe as usual. +export const restoreAuthDiscovery = () => async (dispatch) => { + const issuer = getResolvedIssuer(); + if (!issuer) return false; + + clearResolvedIssuer(); + + const registration = matchRegistration(issuer, appConfig.authorizationServers); + dispatch({ + type: SET_AUTH_DISCOVERY, + payload: registration + ? { ok: true, ...registration, skipped: [] } + : { ok: false, reason: REASON.NO_USABLE_AS }, + }); + + return true; +}; + +// Selectors +export const selectResolution = (state) => state.view.authDiscovery.discovery; + +// The gate withholds the app unless discovery resolved, so anything rendered below it can +// rely on a resolution being present. Keeping the grant comparison here means callers cannot +// drift onto the wrong constant. +export const selectUsesRedirectGrant = (state) => + selectResolution(state)?.grant === GRANT.AUTHORIZATION_CODE; + +// Reducer +export const INITIAL_STATE = { + // Null while the probe is in flight; `settled` is what distinguishes that from a failure. + discovery: null, + settled: false, +}; + +const authDiscoveryReducer = (state = INITIAL_STATE, action) => { + switch (action.type) { + case SET_AUTH_DISCOVERY: + return { discovery: action.payload, settled: true }; + + default: + return state; + } +}; + +export default authDiscoveryReducer; diff --git a/src/ducks/auth-discovery/index.test.js b/src/ducks/auth-discovery/index.test.js new file mode 100644 index 000000000..7af00e109 --- /dev/null +++ b/src/ducks/auth-discovery/index.test.js @@ -0,0 +1,415 @@ +import { delay, http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; + +import { DAS_HOST } from '../../constants'; +import { getResolvedIssuer, setResolvedIssuer } from '../../utils/auth'; +import { mockStore } from '../../__test-helpers/MockStore'; + +import authDiscoveryReducer, { + fetchAuthDiscovery, + INITIAL_STATE, + PROTECTED_RESOURCE_URL, + REASON, + resolveAdvertised, + restoreAuthDiscovery, + selectResolution, + selectUsesRedirectGrant, + SET_AUTH_DISCOVERY, +} from './'; + +// RFC 8414 issuer identifiers as EarthRanger Server advertises them: the Auth0 tenant by +// its custom domain, the site's own server by django-oauth-toolkit's OIDC issuer. Server +// emits Auth0 first, so a client that can use it always does. +const AUTH0_AUTHORIZATION_SERVER = 'https://auth.example.org/'; +const SELF_AUTHORIZATION_SERVER = `${DAS_HOST}/oauth2`; +const FOREIGN_AUTHORIZATION_SERVER = 'https://auth.someone-elses-tenant.example/'; + +const AUTH0_REGISTRATION = { + audience: 'https://api.example', + clientId: 'exampleClientId', + grant: 'authorization_code', +}; + +const SELF_REGISTRATION = { clientId: 'das_web_client', grant: 'password' }; + +jest.mock('../../config', () => ({ + __esModule: true, + default: { + authorizationServers: { + 'https://auth.example.org/': { + audience: 'https://api.example', + clientId: 'exampleClientId', + grant: 'authorization_code', + }, + $self: { clientId: 'das_web_client', grant: 'password' }, + }, + }, +})); + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const respond = (resolver) => server.use(http.get(PROTECTED_RESOURCE_URL, resolver)); + +const respondWith = (body) => respond(() => HttpResponse.json(body)); + +const advertise = (authorizationServers) => respondWith({ resource: DAS_HOST, authorization_servers: authorizationServers }); + +const probe = async (options) => { + const store = mockStore({}); + await store.dispatch(fetchAuthDiscovery(options)); + return store.getActions()[0].payload; +}; + +describe('Ducks - Auth discovery', () => { + describe('resolving an advertised authorization server', () => { + test('takes the Auth0 registration on a fully migrated site', async () => { + advertise([AUTH0_AUTHORIZATION_SERVER]); + + expect(await probe()).toEqual({ + ok: true, + issuer: AUTH0_AUTHORIZATION_SERVER, + skipped: [], + ...AUTH0_REGISTRATION, + }); + }); + + test('takes Auth0 over the site own server when both are advertised, on advertised order', async () => { + advertise([AUTH0_AUTHORIZATION_SERVER, SELF_AUTHORIZATION_SERVER]); + + expect(await probe()).toMatchObject({ ok: true, grant: 'authorization_code', skipped: [] }); + }); + + test('takes the password registration on a site advertising only its own server', async () => { + advertise([SELF_AUTHORIZATION_SERVER]); + + expect(await probe()).toEqual({ + ok: true, + issuer: SELF_AUTHORIZATION_SERVER, + skipped: [], + ...SELF_REGISTRATION, + }); + }); + + test('falls through an unrecognized issuer to the site own server, recording what it skipped', async () => { + // The real shape of a tenant mismatch: Server emits Auth0 first, this build has no + // registration for that tenant, and the site still accepts its own tokens. + advertise([FOREIGN_AUTHORIZATION_SERVER, SELF_AUTHORIZATION_SERVER]); + + expect(await probe()).toEqual({ + ok: true, + issuer: SELF_AUTHORIZATION_SERVER, + skipped: [FOREIGN_AUTHORIZATION_SERVER], + ...SELF_REGISTRATION, + }); + }); + + test('reaches no usable authorization server when nothing advertised has a registration', async () => { + advertise([FOREIGN_AUTHORIZATION_SERVER]); + + expect(await probe()).toEqual({ ok: false, reason: REASON.NO_USABLE_AS }); + }); + + test('does not treat a host that merely begins with this site host as the site own server', async () => { + advertise([`${DAS_HOST}.somewhere-else.example/oauth2`]); + + expect(await probe()).toEqual({ ok: false, reason: REASON.NO_USABLE_AS }); + }); + + test('resolves trailing-slash and letter-case variants identically', async () => { + advertise(['HTTPS://AUTH.EXAMPLE.ORG']); + + expect(await probe()).toMatchObject({ ok: true, ...AUTH0_REGISTRATION }); + }); + }); + + describe('comparing issuer identifiers', () => { + const pathBearing = { 'https://auth.example.org/tenant/one': AUTH0_REGISTRATION }; + + test('holds the path case-sensitive while scheme and host are not', () => { + expect(resolveAdvertised(['https://auth.example.org/Tenant/One'], pathBearing)) + .toEqual({ ok: false, reason: REASON.NO_USABLE_AS }); + + expect(resolveAdvertised(['HTTPS://AUTH.EXAMPLE.ORG/tenant/one'], pathBearing)) + .toMatchObject({ ok: true, ...AUTH0_REGISTRATION }); + }); + + test('reads the default port as absent', () => { + expect(resolveAdvertised(['https://auth.example.org:443/'])).toMatchObject({ ok: true, ...AUTH0_REGISTRATION }); + }); + + test('refuses an issuer carrying a query or a fragment', () => { + expect(resolveAdvertised([`${AUTH0_AUTHORIZATION_SERVER}?tenant=other`])) + .toEqual({ ok: false, reason: REASON.NO_USABLE_AS }); + + expect(resolveAdvertised([`${AUTH0_AUTHORIZATION_SERVER}#other`])) + .toEqual({ ok: false, reason: REASON.NO_USABLE_AS }); + }); + + test('refuses an advertised entry that is not a URL', () => { + expect(resolveAdvertised(['not-a-url', ''])).toEqual({ ok: false, reason: REASON.NO_USABLE_AS }); + }); + + test('carries the registered issuer, not the spelling the site advertised', async () => { + advertise(['HTTPS://AUTH.EXAMPLE.ORG']); + + expect(await probe()).toMatchObject({ ok: true, issuer: AUTH0_AUTHORIZATION_SERVER }); + }); + + test('carries the advertised issuer for the site own server, which the registry names by predicate', async () => { + advertise([SELF_AUTHORIZATION_SERVER]); + + expect(await probe()).toMatchObject({ ok: true, issuer: SELF_AUTHORIZATION_SERVER }); + }); + }); + + describe('resolveAdvertised against a registry holding no $self', () => { + const withoutSelf = { [AUTH0_AUTHORIZATION_SERVER]: AUTH0_REGISTRATION }; + + test('refuses the password grant outright on a site advertising only its own server', () => { + expect(resolveAdvertised([SELF_AUTHORIZATION_SERVER], withoutSelf)) + .toEqual({ ok: false, reason: REASON.NO_USABLE_AS }); + }); + + test('still resolves a registered Auth0 issuer', () => { + expect(resolveAdvertised([AUTH0_AUTHORIZATION_SERVER], withoutSelf)) + .toMatchObject({ ok: true, grant: 'authorization_code' }); + }); + }); + + describe('the site is not ready to be discovered', () => { + test('when the endpoint is absent, as on a server predating it', async () => { + respond(() => new HttpResponse(null, { status: 404 })); + + expect(await probe()).toEqual({ ok: false, reason: REASON.SITE_NOT_READY }); + }); + + test('when an ingress that does not route the path serves the SPA shell instead', async () => { + respond(() => HttpResponse.html('EarthRanger')); + + expect(await probe()).toEqual({ ok: false, reason: REASON.SITE_NOT_READY }); + }); + + test('when the resource identifier is not the origin the document was fetched from', async () => { + respondWith({ + resource: 'https://another-site.example', + authorization_servers: [AUTH0_AUTHORIZATION_SERVER], + }); + + expect(await probe()).toEqual({ ok: false, reason: REASON.SITE_NOT_READY }); + }); + + test('when authorization_servers is absent', async () => { + respondWith({ resource: DAS_HOST }); + + expect(await probe()).toEqual({ ok: false, reason: REASON.SITE_NOT_READY }); + }); + + test('when authorization_servers is not an array', async () => { + respondWith({ resource: DAS_HOST, authorization_servers: AUTH0_AUTHORIZATION_SERVER }); + + expect(await probe()).toEqual({ ok: false, reason: REASON.SITE_NOT_READY }); + }); + + test('when authorization_servers is empty, which advertises nothing rather than a legacy site', async () => { + advertise([]); + + expect(await probe()).toEqual({ ok: false, reason: REASON.SITE_NOT_READY }); + }); + }); + + describe('the site cannot be reached', () => { + test('when the request fails outright', async () => { + respond(() => HttpResponse.error()); + + expect(await probe()).toEqual({ ok: false, reason: REASON.UNREACHABLE }); + }); + + test('when the server errors', async () => { + respond(() => new HttpResponse(null, { status: 503 })); + + expect(await probe()).toEqual({ ok: false, reason: REASON.UNREACHABLE }); + }); + + test('when the response outlasts the timeout', async () => { + respond(async () => { + await delay(100); + return HttpResponse.json({ resource: DAS_HOST, authorization_servers: [AUTH0_AUTHORIZATION_SERVER] }); + }); + + expect(await probe({ timeoutMs: 20 })).toEqual({ ok: false, reason: REASON.UNREACHABLE }); + }); + }); + + describe('reporting a failure for diagnosis', () => { + let warn; + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => warn.mockRestore()); + + test('logs the reason and what it probed, which is more than a screen can carry', async () => { + respond(() => new HttpResponse(null, { status: 404 })); + + await probe(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('authorization discovery'), + expect.objectContaining({ + reason: REASON.SITE_NOT_READY, + probed: PROTECTED_RESOURCE_URL, + status: 404, + }), + ); + }); + + test('names the issuers it holds registrations for when none advertised is usable', async () => { + advertise([FOREIGN_AUTHORIZATION_SERVER]); + + await probe(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('authorization discovery'), + expect.objectContaining({ + reason: REASON.NO_USABLE_AS, + advertised: [FOREIGN_AUTHORIZATION_SERVER], + registered: [AUTH0_AUTHORIZATION_SERVER], + }), + ); + }); + + test('stays quiet when discovery succeeds', async () => { + advertise([AUTH0_AUTHORIZATION_SERVER]); + + await probe(); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + + describe('restoring a resolution stashed across the Auth0 redirect', () => { + beforeEach(() => sessionStorage.clear()); + afterEach(() => sessionStorage.clear()); + + const restore = async () => { + const store = mockStore({}); + const restored = await store.dispatch(restoreAuthDiscovery()); + return { restored, actions: store.getActions() }; + }; + + test('resolves the stashed issuer through the registry, without probing', async () => { + setResolvedIssuer(AUTH0_AUTHORIZATION_SERVER); + + const { restored, actions } = await restore(); + + expect(restored).toBe(true); + expect(actions).toEqual([{ + type: SET_AUTH_DISCOVERY, + payload: { ok: true, issuer: AUTH0_AUTHORIZATION_SERVER, skipped: [], ...AUTH0_REGISTRATION }, + }]); + }); + + test('does nothing when no issuer was stashed', async () => { + const { restored, actions } = await restore(); + + expect(restored).toBe(false); + expect(actions).toEqual([]); + }); + + // The stash carries a key, never a registration, so the trust policy still gates it. + test('refuses a stashed issuer this build holds no registration for', async () => { + setResolvedIssuer(FOREIGN_AUTHORIZATION_SERVER); + + const { restored, actions } = await restore(); + + expect(restored).toBe(true); + expect(actions).toEqual([{ + type: SET_AUTH_DISCOVERY, + payload: { ok: false, reason: REASON.NO_USABLE_AS }, + }]); + }); + + test('resolves a stashed issuer back to the registered spelling', async () => { + setResolvedIssuer('HTTPS://AUTH.EXAMPLE.ORG'); + + const { actions } = await restore(); + + expect(actions[0].payload).toMatchObject({ ok: true, issuer: AUTH0_AUTHORIZATION_SERVER }); + }); + + test('clears the stash once consumed, so it cannot serve a later attempt', async () => { + setResolvedIssuer(AUTH0_AUTHORIZATION_SERVER); + + await restore(); + + expect(getResolvedIssuer()).toBeNull(); + }); + }); + + describe('selectors', () => { + const stateFor = (discovery) => ({ + view: { + authDiscovery: discovery + ? authDiscoveryReducer(INITIAL_STATE, { type: SET_AUTH_DISCOVERY, payload: discovery }) + : INITIAL_STATE, + }, + }); + + const AUTH0 = { ok: true, issuer: AUTH0_AUTHORIZATION_SERVER, skipped: [], ...AUTH0_REGISTRATION }; + const SELF = { ok: true, issuer: SELF_AUTHORIZATION_SERVER, skipped: [], ...SELF_REGISTRATION }; + + test('selectResolution exposes the resolution consumers read', () => { + expect(selectResolution(stateFor(AUTH0))).toEqual(AUTH0); + }); + + test('selectResolution is null while the probe is in flight', () => { + expect(selectResolution(stateFor(null))).toBeNull(); + }); + + test('selectUsesRedirectGrant is true for the authorization_code grant', () => { + expect(selectUsesRedirectGrant(stateFor(AUTH0))).toBe(true); + }); + + test('selectUsesRedirectGrant is false for the password grant', () => { + expect(selectUsesRedirectGrant(stateFor(SELF))).toBe(false); + }); + + test('selectUsesRedirectGrant is false before the probe has resolved', () => { + expect(selectUsesRedirectGrant(stateFor(null))).toBe(false); + }); + + test('selectUsesRedirectGrant is false when discovery failed', () => { + expect(selectUsesRedirectGrant(stateFor({ ok: false, reason: REASON.UNREACHABLE }))).toBe(false); + }); + }); + + describe('authDiscoveryReducer', () => { + test('starts out unsettled, holding no discovery', () => { + expect(authDiscoveryReducer(undefined, {})).toEqual({ discovery: null, settled: false }); + }); + + test('records a resolution', () => { + const discovery = { + ok: true, + issuer: AUTH0_AUTHORIZATION_SERVER, + skipped: [], + ...AUTH0_REGISTRATION, + }; + + expect(authDiscoveryReducer(INITIAL_STATE, { type: SET_AUTH_DISCOVERY, payload: discovery })) + .toEqual({ discovery, settled: true }); + }); + + test('records a failure, which is distinct from a probe still in flight', () => { + const discovery = { ok: false, reason: REASON.SITE_NOT_READY }; + + expect(authDiscoveryReducer(INITIAL_STATE, { type: SET_AUTH_DISCOVERY, payload: discovery })) + .toEqual({ discovery, settled: true }); + }); + }); +}); diff --git a/src/utils/auth.js b/src/utils/auth.js index 34cc7dfb6..47406ec6e 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -42,6 +42,36 @@ export const setIntendedPostAuth0SuccessRoute = (route) => { } }; +// The authorization server resolved for the login attempt now in flight, carried across the +// Auth0 redirect the same way the intended route is. sessionStorage rather than localStorage +// scopes it to one tab and one attempt; the redirect is a same-tab top-level navigation, so it +// survives. Only the issuer is stored -- never the client registration it resolves to. +const RESOLVED_ISSUER_KEY = 'er:resolved_issuer'; + +export const setResolvedIssuer = (issuer) => { + try { + sessionStorage.setItem(RESOLVED_ISSUER_KEY, issuer); + } catch (_) { + // Ignore errors + } +}; + +export const getResolvedIssuer = () => { + try { + return sessionStorage.getItem(RESOLVED_ISSUER_KEY); + } catch (_) { + return null; + } +}; + +export const clearResolvedIssuer = () => { + try { + sessionStorage.removeItem(RESOLVED_ISSUER_KEY); + } catch (_) { + // Ignore errors + } +}; + export const stripAuth0Params = (url) => { const [pathname, searchString] = url.split('?'); if (!searchString) return pathname; diff --git a/src/utils/auth.test.js b/src/utils/auth.test.js index 603cf81f1..b9b56b3ea 100644 --- a/src/utils/auth.test.js +++ b/src/utils/auth.test.js @@ -4,6 +4,9 @@ import { getIntendedPostAuth0SuccessRoute, setIntendedPostAuth0SuccessRoute, clearIntendedPostAuth0SuccessRoute, + setResolvedIssuer, + getResolvedIssuer, + clearResolvedIssuer, stripAuth0Params, getAuthTokenFromCookies, getTemporaryAccessTokenFromCookies, @@ -15,18 +18,15 @@ import { describe('auth utils', () => { describe('isSystemConfigLoaded', () => { test('returns false when require_idp is null (not loaded)', () => { - const systemConfig = { require_idp: null, sitename: '' }; - expect(isSystemConfigLoaded(systemConfig)).toBe(false); + expect(isSystemConfigLoaded({ require_idp: null, sitename: '' })).toBe(false); }); test('returns true when require_idp is false (loaded)', () => { - const systemConfig = { require_idp: false, sitename: 'Test Site' }; - expect(isSystemConfigLoaded(systemConfig)).toBe(true); + expect(isSystemConfigLoaded({ require_idp: false, sitename: 'Test Site' })).toBe(true); }); test('returns true when require_idp is true (loaded)', () => { - const systemConfig = { require_idp: true, sitename: 'Test Site' }; - expect(isSystemConfigLoaded(systemConfig)).toBe(true); + expect(isSystemConfigLoaded({ require_idp: true, sitename: 'Test Site' })).toBe(true); }); }); @@ -170,6 +170,44 @@ describe('auth utils', () => { }); }); + describe('sessionStorage resolved issuer', () => { + beforeEach(() => sessionStorage.clear()); + afterEach(() => sessionStorage.clear()); + + test('round-trips the issuer across a redirect', () => { + setResolvedIssuer('https://auth.example.org/'); + + expect(getResolvedIssuer()).toBe('https://auth.example.org/'); + }); + + test('returns null when nothing was stashed', () => { + expect(getResolvedIssuer()).toBeNull(); + }); + + test('clears the stash', () => { + setResolvedIssuer('https://auth.example.org/'); + clearResolvedIssuer(); + + expect(getResolvedIssuer()).toBeNull(); + }); + + // sessionStorage rather than localStorage: one tab, one login attempt. + test('does not write to localStorage', () => { + setResolvedIssuer('https://auth.example.org/'); + + expect(localStorage.getItem('er:resolved_issuer')).toBeNull(); + }); + + test('survives storage being unavailable', () => { + const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + + expect(() => setResolvedIssuer('https://auth.example.org/')).not.toThrow(); + setItem.mockRestore(); + }); + }); + describe('cookie utilities', () => { beforeEach(() => { document.cookie = ''; From b67c8d4364e190a1e28bec4ee61e15163de58535 Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Wed, 5 Aug 2026 08:20:44 -0700 Subject: [PATCH 08/16] ERA-13805 Add the gate that builds Auth0 from the resolution Renders Auth0Provider with the client registration discovery resolved, runs a site whose grant is password with no provider above it at all, and withholds the app until the probe settles. Auth0Provider wants a host while discovery names the server by issuer, so the host is derived from the matched registry key rather than stored beside it. One message covers every failure, because refreshing or finding an administrator is the whole of what the reader can do; which reason it was goes to the console. The string lives in the login namespace, which is preloaded -- this screen is the app's first render, and a namespace fetched on demand would show the fallback about as often as the translation. It also fires the system-status fetch alongside the probe. Neither answer depends on the other and startup waits on both, so leaving that to a component rendered underneath would put two round trips in series on every cold load. Its tests derive state by running the reducers over real actions. Hand-written store literals cannot see a change to a slice shape, and would go on asserting against a shape production no longer has. Nothing renders this yet -- index.js still builds Auth0Provider statically. Co-Authored-By: Claude Opus 5 (1M context) --- public/locales/en-US/login.json | 1 + public/locales/es/login.json | 1 + public/locales/fr/login.json | 1 + public/locales/ne-NP/login.json | 1 + public/locales/pt/login.json | 1 + public/locales/sw/login.json | 1 + src/AuthDiscoveryGate/index.js | 81 ++++++++++ src/AuthDiscoveryGate/index.test.js | 226 ++++++++++++++++++++++++++++ 8 files changed, 313 insertions(+) create mode 100644 src/AuthDiscoveryGate/index.js create mode 100644 src/AuthDiscoveryGate/index.test.js diff --git a/public/locales/en-US/login.json b/public/locales/en-US/login.json index 4e3e21b9f..d0e9635ff 100644 --- a/public/locales/en-US/login.json +++ b/public/locales/en-US/login.json @@ -27,6 +27,7 @@ "loginButtonEmail": "Sign in with email", "loginButtonLoadingLabel": "Loading", "passwordLabel": "Password", + "signInUnavailable": "EarthRanger could not work out how to sign you in to {{site}}. Refresh to try again, and contact your administrator if it keeps happening.", "title": "Log In", "usernameLabel": "Username" } diff --git a/public/locales/es/login.json b/public/locales/es/login.json index 558be0cf7..81bab186c 100644 --- a/public/locales/es/login.json +++ b/public/locales/es/login.json @@ -27,6 +27,7 @@ "loginButtonEmail": "Iniciar sesión con correo electrónico", "loginButtonLoadingLabel": "Cargando", "passwordLabel": "Contraseña", + "signInUnavailable": "EarthRanger no pudo determinar cómo iniciar su sesión en {{site}}. Actualice la página para volver a intentarlo y contacte a su administrador si el problema persiste.", "title": "Iniciar sesión", "usernameLabel": "Usuario" } diff --git a/public/locales/fr/login.json b/public/locales/fr/login.json index 0657bbbff..867751f74 100644 --- a/public/locales/fr/login.json +++ b/public/locales/fr/login.json @@ -27,6 +27,7 @@ "loginButtonEmail": "Se connecter avec un e-mail", "loginButtonLoadingLabel": "Chargement", "passwordLabel": "Mot de passe", + "signInUnavailable": "EarthRanger n'a pas pu déterminer comment vous connecter à {{site}}. Actualisez la page pour réessayer, et contactez votre administrateur si le problème persiste.", "title": "Connexion", "usernameLabel": "Nom d'utilisateur" } diff --git a/public/locales/ne-NP/login.json b/public/locales/ne-NP/login.json index 33cd65f61..3cabbad79 100644 --- a/public/locales/ne-NP/login.json +++ b/public/locales/ne-NP/login.json @@ -27,6 +27,7 @@ "loginButtonEmail": "इमेलबाट साइन इन", "loginButtonLoadingLabel": "लोड हुँदैछ", "passwordLabel": "पासवर्ड", + "signInUnavailable": "EarthRanger ले {{site}} मा तपाईंलाई कसरी साइन इन गराउने निर्धारण गर्न सकेन। पुनः प्रयास गर्न पृष्ठ रिफ्रेस गर्नुहोस्, र समस्या जारी रहे प्रशासकलाई सम्पर्क गर्नुहोस्।", "title": "लग इन", "usernameLabel": "युजरनेम" } diff --git a/public/locales/pt/login.json b/public/locales/pt/login.json index 99df6a2fc..9cc427c7c 100644 --- a/public/locales/pt/login.json +++ b/public/locales/pt/login.json @@ -27,6 +27,7 @@ "loginButtonEmail": "Conecte-se com e-mail", "loginButtonLoadingLabel": "Carregando", "passwordLabel": "Senha", + "signInUnavailable": "O EarthRanger não conseguiu determinar como iniciar a sua sessão em {{site}}. Atualize a página para tentar novamente e contacte o seu administrador se o problema persistir.", "title": "Entrar", "usernameLabel": "Nome de usuário" } diff --git a/public/locales/sw/login.json b/public/locales/sw/login.json index 42f183966..1b237035a 100644 --- a/public/locales/sw/login.json +++ b/public/locales/sw/login.json @@ -27,6 +27,7 @@ "loginButtonEmail": "Ingia kwa barua pepe", "loginButtonLoadingLabel": "Inapakia", "passwordLabel": "Nenosiri", + "signInUnavailable": "EarthRanger haikuweza kubaini jinsi ya kukuingiza katika {{site}}. Onyesha upya ukurasa ili kujaribu tena, na wasiliana na msimamizi wako ikiwa hali inaendelea.", "title": "Ingia", "usernameLabel": "Jina la Mtumiaji" } diff --git a/src/AuthDiscoveryGate/index.js b/src/AuthDiscoveryGate/index.js new file mode 100644 index 000000000..0b1a4b661 --- /dev/null +++ b/src/AuthDiscoveryGate/index.js @@ -0,0 +1,81 @@ +import React, { useEffect } from 'react'; +import { Auth0Provider } from '@auth0/auth0-react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; + +import { + fetchAuthDiscovery, + GRANT, + restoreAuthDiscovery, +} from '../ducks/auth-discovery'; +import { fetchSystemStatus } from '../ducks/system-status'; +import { isSystemConfigLoaded } from '../utils/auth'; +import { hasAuth0CallbackParams } from '../utils/auth0'; +import { DAS_HOST, REACT_APP_ROUTE_PREFIX } from '../constants'; +import ErrorMessage from '../ErrorMessage'; +import LoadingOverlay from '../EarthRangerIconLoadingOverlay'; + +// Auth0Provider wants a host, while discovery names the authorization server by issuer. +const hostOf = (authorizationServer) => new URL(authorizationServer).host; + +const AuthDiscoveryGate = ({ children }) => { + const dispatch = useDispatch(); + // The 'login' namespace is preloaded, unlike 'errors'; this screen is the app's first render, + // so a namespace fetched on demand would show the key or the fallback about as often as the + // translation. defaultValue still covers a cold cache. + const { t } = useTranslation('login'); + + const { discovery, settled } = useSelector((state) => state.view.authDiscovery); + const systemConfigLoaded = useSelector((state) => isSystemConfigLoaded(state.view.systemConfig)); + + // Both in flight together. Neither answer depends on the other and startup waits on both, + // so firing them in series would add a round trip to every cold load. + useEffect(() => { + // Returning from the Auth0 redirect, prefer the resolution stashed on the way out. The SDK + // needs its provider mounted to exchange ?code&state, and a probe that failed here would + // spend the code for nothing. Only when there is no stash does this leg probe. + const resolveDiscovery = async () => { + const restored = hasAuth0CallbackParams(window.location.search) + && await dispatch(restoreAuthDiscovery()); + + if (!restored) dispatch(fetchAuthDiscovery()); + }; + + resolveDiscovery(); + dispatch(fetchSystemStatus()); + }, [dispatch]); + + // A system config that never arrives holds the overlay indefinitely, as it did before this + // gate existed: fetchSystemStatus swallows its own errors and resolves undefined, so a + // caller cannot tell failure from a slow answer. Worth fixing, but not from here -- App.js + // consumes the same thunk's resolved value. + if (!settled || !systemConfigLoaded) return ; + + // One message for every reason: refreshing or finding an administrator is the whole of what + // the reader can do. Which reason it was is in the console, for whoever debugs it. + if (!discovery.ok) { + return ; + } + + // Only the redirect grant needs a provider above the app. The password grant is served + // by the site's own authorization server, which the SDK plays no part in. + if (discovery.grant !== GRANT.AUTHORIZATION_CODE) return children; + + return + {children} + ; +}; + +export default AuthDiscoveryGate; diff --git a/src/AuthDiscoveryGate/index.test.js b/src/AuthDiscoveryGate/index.test.js new file mode 100644 index 000000000..c18c3be74 --- /dev/null +++ b/src/AuthDiscoveryGate/index.test.js @@ -0,0 +1,226 @@ +import React from 'react'; +import { Provider } from 'react-redux'; + +import authDiscoveryReducer, { + fetchAuthDiscovery, + INITIAL_STATE, + REASON, + restoreAuthDiscovery, + SET_AUTH_DISCOVERY, +} from '../ducks/auth-discovery'; +import systemConfigReducer, { SET_SYSTEM_CONFIG } from '../ducks/system-config'; +import { fetchSystemStatus } from '../ducks/system-status'; +import { mockStore } from '../__test-helpers/MockStore'; +import { REACT_APP_ROUTE_PREFIX } from '../constants'; +import { render, screen, waitFor } from '../test-utils'; + +import AuthDiscoveryGate from './'; + +// A plain function component, not jest.fn: a jest.fn used as a component is invoked +// but renders nothing here, which would hide whether children reached the app. +const mockAuth0ProviderProps = []; + +jest.mock('@auth0/auth0-react', () => ({ + Auth0Provider: (props) => { + mockAuth0ProviderProps.push(props); + return props.children; + }, +})); + +jest.mock('../ducks/system-status', () => ({ + ...jest.requireActual('../ducks/system-status'), + fetchSystemStatus: jest.fn(), +})); + +jest.mock('../ducks/auth-discovery', () => ({ + __esModule: true, + ...jest.requireActual('../ducks/auth-discovery'), + fetchAuthDiscovery: jest.fn(), + restoreAuthDiscovery: jest.fn(), +})); + +// A known registry, so the "wrong build" copy is asserted against this fixture rather than +// against whatever the production defaults happen to hold. +jest.mock('../config', () => ({ + __esModule: true, + default: { + authorizationServers: { + 'https://auth.example.org/': { + audience: 'https://api.example', + clientId: 'exampleClientId', + grant: 'authorization_code', + }, + $self: { clientId: 'das_web_client', grant: 'password' }, + }, + }, +})); + +const AUTH0_RESOLUTION = { + ok: true, + issuer: 'https://auth.example.org/', + audience: 'https://api.example', + clientId: 'exampleClientId', + grant: 'authorization_code', + skipped: [], +}; + +const PASSWORD_RESOLUTION = { + ok: true, + issuer: 'http://localhost/oauth2', + clientId: 'das_web_client', + grant: 'password', + skipped: [], +}; + +const PROTECTED_APP = 'the protected app'; + +// Both slices come from their real reducers rather than hand-written literals, so a change +// to either shape surfaces here instead of leaving these tests asserting against a shape +// production no longer has. +const LOADED_SYSTEM_CONFIG = systemConfigReducer(undefined, { + type: SET_SYSTEM_CONFIG, + payload: { require_idp: false }, +}); +const UNLOADED_SYSTEM_CONFIG = systemConfigReducer(undefined, {}); + +const renderGate = (discovery, systemConfig = LOADED_SYSTEM_CONFIG) => { + const authDiscovery = discovery + ? authDiscoveryReducer(INITIAL_STATE, { type: SET_AUTH_DISCOVERY, payload: discovery }) + : INITIAL_STATE; + + const store = mockStore({ view: { authDiscovery, systemConfig } }); + const utils = render( + +
{PROTECTED_APP}
+
+ ); + return { ...utils, store }; +}; + +describe('AuthDiscoveryGate', () => { + beforeEach(() => { + mockAuth0ProviderProps.length = 0; + fetchAuthDiscovery.mockImplementation(() => ({ type: 'PROBE_DISPATCHED' })); + fetchSystemStatus.mockImplementation(() => () => Promise.resolve({})); + restoreAuthDiscovery.mockImplementation(() => () => Promise.resolve(false)); + }); + + afterEach(() => { + window.history.replaceState({}, '', '/'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('probes for the site authorization server on mount', () => { + const { store } = renderGate(null); + + expect(fetchAuthDiscovery).toHaveBeenCalledTimes(1); + expect(store.getActions()).toEqual([{ type: 'PROBE_DISPATCHED' }]); + }); + + // Neither answer depends on the other, and startup waits on both. Firing them in series + // would double time-to-login for no reason. + test('puts the probe and the system-status fetch in flight together', () => { + renderGate(null, UNLOADED_SYSTEM_CONFIG); + + expect(fetchAuthDiscovery).toHaveBeenCalledTimes(1); + expect(fetchSystemStatus).toHaveBeenCalledTimes(1); + }); + + test('withholds the app until system config has loaded, even once discovery has settled', () => { + renderGate(AUTH0_RESOLUTION, UNLOADED_SYSTEM_CONFIG); + + expect(screen.queryByText(PROTECTED_APP)).not.toBeInTheDocument(); + expect(mockAuth0ProviderProps).toHaveLength(0); + }); + + describe('returning from the Auth0 redirect', () => { + const arriveOnCallback = () => window.history.replaceState({}, '', '/?code=abc&state=xyz'); + + test('takes the resolution stashed before the redirect rather than probing again', async () => { + arriveOnCallback(); + restoreAuthDiscovery.mockImplementation(() => () => Promise.resolve(true)); + + renderGate(null); + + await waitFor(() => expect(restoreAuthDiscovery).toHaveBeenCalledTimes(1)); + expect(fetchAuthDiscovery).not.toHaveBeenCalled(); + }); + + test('probes when the callback leg has no stashed resolution to fall back on', async () => { + arriveOnCallback(); + + renderGate(null); + + await waitFor(() => expect(fetchAuthDiscovery).toHaveBeenCalledTimes(1)); + }); + + test('probes normally when this is not a callback leg, stash or no stash', async () => { + restoreAuthDiscovery.mockImplementation(() => () => Promise.resolve(true)); + + renderGate(null); + + await waitFor(() => expect(fetchAuthDiscovery).toHaveBeenCalledTimes(1)); + expect(restoreAuthDiscovery).not.toHaveBeenCalled(); + }); + }); + + test('withholds the app until the probe settles', () => { + renderGate(null); + + expect(screen.queryByText(PROTECTED_APP)).not.toBeInTheDocument(); + expect(mockAuth0ProviderProps).toHaveLength(0); + }); + + test('builds the Auth0 provider from the resolved registration', () => { + renderGate(AUTH0_RESOLUTION); + + expect(screen.getByText(PROTECTED_APP)).toBeVisible(); + expect(mockAuth0ProviderProps[0]).toEqual(expect.objectContaining({ + clientId: AUTH0_RESOLUTION.clientId, + domain: 'auth.example.org', + authorizationParams: expect.objectContaining({ + audience: AUTH0_RESOLUTION.audience, + redirect_uri: `${window.location.origin}${REACT_APP_ROUTE_PREFIX}`, + }), + })); + }); + + test('mounts no Auth0 provider at all when the grant is password', () => { + renderGate(PASSWORD_RESOLUTION); + + expect(screen.getByText(PROTECTED_APP)).toBeVisible(); + expect(mockAuth0ProviderProps).toHaveLength(0); + }); + + test.each([ + REASON.UNREACHABLE, + REASON.SITE_NOT_READY, + REASON.NO_USABLE_AS, + ])('withholds the app when discovery fails with %s', (reason) => { + renderGate({ ok: false, reason }); + + expect(screen.queryByText(PROTECTED_APP)).not.toBeInTheDocument(); + expect(mockAuth0ProviderProps).toHaveLength(0); + }); + + // Every reason reads the same to whoever is looking at it: this site cannot say how to sign + // them in. Which reason it was matters to whoever debugs it, and that goes to the console. + test.each([ + REASON.UNREACHABLE, + REASON.SITE_NOT_READY, + REASON.NO_USABLE_AS, + ])('says the same thing for %s, since the reader can only refresh or ask someone', (reason) => { + renderGate({ ok: false, reason }); + + expect(screen.getByText(/could not work out how to sign you in/i)).toBeVisible(); + }); + + test('offers no details widget, because the diagnosis is not the reader\'s to act on', () => { + renderGate({ ok: false, reason: REASON.SITE_NOT_READY }); + + expect(screen.queryByRole('button', { name: /details/i })).not.toBeInTheDocument(); + }); +}); From c62529ee6dda7a2c3c69cbc390684cb777e0d348 Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Wed, 5 Aug 2026 08:20:44 -0700 Subject: [PATCH 09/16] ERA-13805 Check a password-grant token before entering the app The site issues a token whenever the credentials are right. Whether this application may present it is a separate question, enforced per request in the authenticator rather than at the token endpoint, so a client that is not permitted still receives a 200 and a valid token. Adopting it took the user into the app, 401'd every call, failed to renew (no Auth0 provider is mounted on a password-grant site), and returned them to a login form that had just reported success -- with nothing said about why, and no way out, since the resolution is deterministic and every retry repeats it. So postAuth hands the token back instead of adopting it, and adoption waits on one call to /api/v1.0/user/me. A 401 there means the site refused the token and says so, naming the cause rather than the credentials, which were correct. A transport failure adopts anyway: nothing was learned, and refusing on a blip would invent a failure the server never gave. The token is attached per-call and the request opts out of the global 401 recovery, the same arrangement checkAccountLinked uses on the Auth0 path -- the shared header is not installed until adoption, and without skipAuth this check's own 401 would sign the user out mid-check. Costs no extra round trip in practice: Nav already calls user/me on mount right after login. The call moves earlier and its answer is acted on. Co-Authored-By: Claude Opus 5 (1M context) --- public/locales/en-US/login.json | 1 + public/locales/es/login.json | 1 + public/locales/fr/login.json | 1 + public/locales/ne-NP/login.json | 1 + public/locales/pt/login.json | 1 + public/locales/sw/login.json | 1 + src/Login/index.js | 16 ++++++- src/Login/index.test.js | 75 ++++++++++++++++++++++++++++--- src/ducks/auth.js | 10 ++--- src/utils/token-usability.js | 35 +++++++++++++++ src/utils/token-usability.test.js | 73 ++++++++++++++++++++++++++++++ 11 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/utils/token-usability.js create mode 100644 src/utils/token-usability.test.js diff --git a/public/locales/en-US/login.json b/public/locales/en-US/login.json index d0e9635ff..8bfb8813c 100644 --- a/public/locales/en-US/login.json +++ b/public/locales/en-US/login.json @@ -15,6 +15,7 @@ "invalidCredentialsMessage": "Invalid credentials given. Please try again.", "signInFailed": "Sign-in failed. Please try again.", "signInIncomplete": "We couldn't finish signing you in. Please try again.", + "signInNotAcceptedHere": "This site did not accept the sign-in. Your username and password were correct, so ask an administrator to check that this application is permitted to sign in here.", "unknownErrorMessage": "An error has occurred. Please try again." }, "errors": { diff --git a/public/locales/es/login.json b/public/locales/es/login.json index 81bab186c..921b33edf 100644 --- a/public/locales/es/login.json +++ b/public/locales/es/login.json @@ -15,6 +15,7 @@ "invalidCredentialsMessage": "Las credenciales son inválidas. Por favor intente de nuevo.", "signInFailed": "Error al iniciar sesión. Por favor intente de nuevo.", "signInIncomplete": "No pudimos completar su inicio de sesión. Por favor intente de nuevo.", + "signInNotAcceptedHere": "Este sitio no aceptó el inicio de sesión. Su usuario y contraseña eran correctos, así que pida a un administrador que compruebe que esta aplicación tiene permiso para iniciar sesión aquí.", "unknownErrorMessage": "Ha ocurrido un error. Por favor intente de nuevo." }, "errors": { diff --git a/public/locales/fr/login.json b/public/locales/fr/login.json index 867751f74..83bdb61d8 100644 --- a/public/locales/fr/login.json +++ b/public/locales/fr/login.json @@ -15,6 +15,7 @@ "invalidCredentialsMessage": "Identifiants fournis incorrectes. Veuillez réessayez.", "signInFailed": "Échec de la connexion. Veuillez réessayer.", "signInIncomplete": "Nous n'avons pas pu terminer votre connexion. Veuillez réessayer.", + "signInNotAcceptedHere": "Ce site n'a pas accepté la connexion. Votre nom d'utilisateur et votre mot de passe étaient corrects ; demandez à un administrateur de vérifier que cette application est autorisée à se connecter ici.", "unknownErrorMessage": "Une erreur s'est produite. Veuillez réessayez." }, "errors": { diff --git a/public/locales/ne-NP/login.json b/public/locales/ne-NP/login.json index 3cabbad79..ba0a8ff03 100644 --- a/public/locales/ne-NP/login.json +++ b/public/locales/ne-NP/login.json @@ -15,6 +15,7 @@ "invalidCredentialsMessage": "गलत प्रमाणहरु दिइयो । कृपया पुनः प्रयास गर्नुहोस् ।", "signInFailed": "साइन इन असफल भयो। कृपया पुनः प्रयास गर्नुहोस्।", "signInIncomplete": "हामी तपाईंको साइन इन पूरा गर्न सकेनौं। कृपया पुनः प्रयास गर्नुहोस्।", + "signInNotAcceptedHere": "यो साइटले साइन-इन स्वीकार गरेन। तपाईंको प्रयोगकर्ता नाम र पासवर्ड सही थियो, त्यसैले प्रशासकलाई यो एप्लिकेसनलाई यहाँ साइन इन गर्न अनुमति छ भनी जाँच गर्न अनुरोध गर्नुहोस्।", "unknownErrorMessage": "केही त्रुटी भएको छ । कृपया पुनः प्रयास गर्नुहोस् ।" }, "errors": { diff --git a/public/locales/pt/login.json b/public/locales/pt/login.json index 9cc427c7c..a28b9fd28 100644 --- a/public/locales/pt/login.json +++ b/public/locales/pt/login.json @@ -15,6 +15,7 @@ "invalidCredentialsMessage": "As credenciais fornecidas são inválidas. Por favor\ntente novamente.", "signInFailed": "Falha ao entrar. Por favor, tente novamente.", "signInIncomplete": "Não foi possível concluir o seu login. Por favor, tente novamente.", + "signInNotAcceptedHere": "Este site não aceitou o início de sessão. O seu nome de utilizador e palavra-passe estavam corretos, portanto peça a um administrador para verificar que esta aplicação tem permissão para iniciar sessão aqui.", "unknownErrorMessage": "Ocorreu um erro. Por favor, tente\nde novo." }, "errors": { diff --git a/public/locales/sw/login.json b/public/locales/sw/login.json index 1b237035a..75ef7dc25 100644 --- a/public/locales/sw/login.json +++ b/public/locales/sw/login.json @@ -15,6 +15,7 @@ "invalidCredentialsMessage": "Maelezo yasiyo sahihi yametolewa. Tafadhali jaribu tena.", "signInFailed": "Kuingia kumeshindwa. Tafadhali jaribu tena.", "signInIncomplete": "Hatukuweza kukamilisha kuingia kwako. Tafadhali jaribu tena.", + "signInNotAcceptedHere": "Tovuti hii haikukubali kuingia. Jina lako la mtumiaji na neno la siri yalikuwa sahihi, kwa hivyo muombe msimamizi kuhakikisha kwamba programu hii inaruhusiwa kuingia hapa.", "unknownErrorMessage": "Kumetokea kosa. Tafadhali jaribu tena." }, "errors": { diff --git a/src/Login/index.js b/src/Login/index.js index 49692ee03..4b089d8e1 100644 --- a/src/Login/index.js +++ b/src/Login/index.js @@ -12,8 +12,9 @@ import { import { ACCOUNT_LINKER_URL, SYSTEM_CONFIG_FLAGS } from '../constants'; import { APP_ROUTES } from '../constants/routes'; import appConfig from '../config'; -import { clearAuth, postAuth } from '../ducks/auth'; +import { applyAccessToken, clearAuth, postAuth } from '../ducks/auth'; import { fetchEula } from '../ducks/eula'; +import { checkTokenUsable, TOKEN_RESULT } from '../utils/token-usability'; import useNavigate from '../hooks/useNavigate'; import * as styles from './styles.module.scss'; @@ -84,7 +85,18 @@ const LoginPage = () => { setIsLoading(true); try { - await dispatch(postAuth({ username, password })); + const accessToken = await dispatch(postAuth({ username, password })); + + // The site issues a token whenever the credentials are right, but whether this + // application may present it is enforced per request. Adopting an unusable one enters + // the app and bounces straight back here, reporting nothing. + if (await checkTokenUsable(accessToken) === TOKEN_RESULT.REFUSED) { + setAlertMessage(t('errorAlert.signInNotAcceptedHere')); + return; + } + + dispatch(applyAccessToken(accessToken)); + const options = location.state?.from ? { state: { comesFromLogin: true } } : {}; diff --git a/src/Login/index.test.js b/src/Login/index.test.js index d5c36db69..582849c2d 100644 --- a/src/Login/index.test.js +++ b/src/Login/index.test.js @@ -4,8 +4,8 @@ import { useAuth0 } from '@auth0/auth0-react'; import userEvent from '@testing-library/user-event'; import { APP_ROUTES } from '../constants/routes'; -import appConfig from '../config'; -import { clearAuth, postAuth } from '../ducks/auth'; +import { applyAccessToken, clearAuth, postAuth } from '../ducks/auth'; +import { checkTokenUsable, TOKEN_RESULT } from '../utils/token-usability'; import { fetchEula } from '../ducks/eula'; import { mockStore } from '../__test-helpers/MockStore'; import { render, screen, waitFor } from '../test-utils'; @@ -26,12 +26,20 @@ jest.mock('../ducks/eula', () => ({ jest.mock('../ducks/auth', () => ({ ...jest.requireActual('../ducks/auth'), + applyAccessToken: jest.fn(), postAuth: jest.fn(), clearAuth: jest.fn(), })); +jest.mock('../utils/token-usability', () => ({ + ...jest.requireActual('../utils/token-usability'), + checkTokenUsable: jest.fn(), +})); + jest.mock('../hooks/useNavigate', () => jest.fn()); +const ISSUED_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.issued.signature'; + describe('Login', () => { let loginWithRedirect, navigate, store; beforeEach(() => { @@ -40,7 +48,9 @@ describe('Login', () => { clearAuth.mockImplementation(() => () => Promise.resolve()); fetchEula.mockImplementation(() => () => Promise.resolve()); - postAuth.mockImplementation(() => () => Promise.resolve()); + applyAccessToken.mockImplementation(() => () => Promise.resolve()); + postAuth.mockImplementation(() => () => Promise.resolve(ISSUED_TOKEN)); + checkTokenUsable.mockResolvedValue(TOKEN_RESULT.USABLE); useAuth0.mockReturnValue({ loginWithRedirect, isLoading: false }); useNavigate.mockImplementation(() => navigate); @@ -114,7 +124,7 @@ describe('Login', () => { await userEvent.click(screen.getByRole('button', { name: 'Sign in with email' })); expect(loginWithRedirect).toHaveBeenCalledWith({ - authorizationParams: { audience: appConfig.auth0.audience }, + authorizationParams: { audience: 'https://pamdas.org/api' }, }); }); @@ -130,7 +140,7 @@ describe('Login', () => { await userEvent.click(screen.getByRole('button', { name: 'Sign in with email' })); expect(loginWithRedirect).toHaveBeenCalledWith({ - authorizationParams: { audience: appConfig.auth0.audience }, + authorizationParams: { audience: 'https://pamdas.org/api' }, }); }); @@ -464,6 +474,61 @@ describe('Login', () => { }); }); + describe('adopting a password-grant token', () => { + const submitCredentials = async () => { + await userEvent.type(screen.getByLabelText('Username'), 'alice'); + await userEvent.type(screen.getByLabelText('Password'), 'secret'); + await userEvent.click(screen.getByRole('button', { name: 'Log in' })); + }; + + test('checks the issued token against the API before entering the app', async () => { + renderLogin(); + + await submitCredentials(); + + await waitFor(() => expect(checkTokenUsable).toHaveBeenCalledWith(ISSUED_TOKEN)); + expect(applyAccessToken).toHaveBeenCalledWith(ISSUED_TOKEN); + expect(navigate).toHaveBeenCalled(); + }); + + test('does not enter the app when the API refuses the issued token', async () => { + checkTokenUsable.mockResolvedValue(TOKEN_RESULT.REFUSED); + + renderLogin(); + + await submitCredentials(); + + await waitFor(() => expect(checkTokenUsable).toHaveBeenCalled()); + expect(applyAccessToken).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + + test('says the site refused the sign-in rather than blaming the credentials', async () => { + checkTokenUsable.mockResolvedValue(TOKEN_RESULT.REFUSED); + + renderLogin(); + + await submitCredentials(); + + await waitFor(() => { + expect(screen.getByText(/this site did not accept/i)).toBeVisible(); + }); + expect(screen.queryByText(/invalid credentials/i)).not.toBeInTheDocument(); + }); + + test('enters the app when the check itself could not reach the API', async () => { + // Nothing was learned, so refusing here would invent a failure the server never gave. + checkTokenUsable.mockResolvedValue(TOKEN_RESULT.TRANSIENT); + + renderLogin(); + + await submitCredentials(); + + await waitFor(() => expect(applyAccessToken).toHaveBeenCalledWith(ISSUED_TOKEN)); + expect(navigate).toHaveBeenCalled(); + }); + }); + test('sends trimmed username and password to postAuth', async () => { renderLogin(); diff --git a/src/ducks/auth.js b/src/ducks/auth.js index c5e2f009e..e9f46cc48 100644 --- a/src/ducks/auth.js +++ b/src/ducks/auth.js @@ -15,7 +15,7 @@ export const CLEAR_AUTH = 'CLEAR_AUTH'; const RESET_MASTER_CANCEL_TOKEN = 'RESET_MASTER_CANCEL_TOKEN'; // action creators -export const postAuth = (userData) => (dispatch) => { +export const postAuth = (userData) => () => { const formData = new FormData(); formData.set('grant_type', 'password'); formData.set('client_id', 'das_web_client'); @@ -23,10 +23,10 @@ export const postAuth = (userData) => (dispatch) => { formData.set(item, userData[item]); }); + // Returns the token rather than adopting it: a token being issued is not the same fact as + // a token being usable, so the caller checks before entering the app. return axios.post(AUTH_URL, formData) - .then(response => { - dispatch(postAuthSuccess(response)); - }); + .then(({ data }) => data.access_token); }; export const applyAccessToken = accessToken => (dispatch) => { @@ -37,8 +37,6 @@ export const applyAccessToken = accessToken => (dispatch) => { }); }; -const postAuthSuccess = response => applyAccessToken(response.data.access_token); - export const clearAuth = () => (dispatch) => { return new Promise((resolve) => { diff --git a/src/utils/token-usability.js b/src/utils/token-usability.js new file mode 100644 index 000000000..7d741e8f5 --- /dev/null +++ b/src/utils/token-usability.js @@ -0,0 +1,35 @@ +import axios from 'axios'; + +import { API_URL } from '../constants'; + +export const TOKEN_USABILITY_PROBE_URL = `${API_URL}user/me`; + +export const TOKEN_RESULT = { + USABLE: 'USABLE', // 200 — the API accepts it + REFUSED: 'REFUSED', // 401 — issued, but not accepted here + TRANSIENT: 'TRANSIENT', // network error / 5xx — nothing learned +}; + +// A token being issued is not the same fact as a token being usable. The site's own +// authorization server issues one whenever the credentials are right, while whether this +// application may present it is enforced later, per request (das/accounts/backends.py). So +// a token is checked against the API before it is adopted, or the app is entered with a +// token every call will reject and the user is bounced back to a login form that just told +// them they succeeded. +// +// Attaching the token per-call and opting out of the global 401 handling is the same +// arrangement checkAccountLinked uses on the Auth0 path: the shared Authorization header is +// not installed until the token is adopted, and without skipAuth this 401 would re-enter +// auth recovery and sign the user out mid-check. +export const checkTokenUsable = async (accessToken) => { + try { + await axios.get(TOKEN_USABILITY_PROBE_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + skipAuth: true, + }); + + return TOKEN_RESULT.USABLE; + } catch (error) { + return error?.response?.status === 401 ? TOKEN_RESULT.REFUSED : TOKEN_RESULT.TRANSIENT; + } +}; diff --git a/src/utils/token-usability.test.js b/src/utils/token-usability.test.js new file mode 100644 index 000000000..6731f3a1e --- /dev/null +++ b/src/utils/token-usability.test.js @@ -0,0 +1,73 @@ +import axios from 'axios'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; + +import { checkTokenUsable, TOKEN_RESULT, TOKEN_USABILITY_PROBE_URL } from './token-usability'; + +const TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const respond = (resolver) => server.use(http.get(TOKEN_USABILITY_PROBE_URL, resolver)); + +describe('checkTokenUsable', () => { + test('reports a token the API accepts as usable', async () => { + respond(() => HttpResponse.json({ data: { id: 'user-1' } })); + + expect(await checkTokenUsable(TOKEN)).toBe(TOKEN_RESULT.USABLE); + }); + + test('reports a token the API rejects as refused', async () => { + // The authorization server issued it, but this application is not permitted to present + // DAS-issued tokens to this site. + respond(() => new HttpResponse(null, { status: 401 })); + + expect(await checkTokenUsable(TOKEN)).toBe(TOKEN_RESULT.REFUSED); + }); + + test('reports a transport failure as transient rather than refused', async () => { + respond(() => HttpResponse.error()); + + expect(await checkTokenUsable(TOKEN)).toBe(TOKEN_RESULT.TRANSIENT); + }); + + test('reports a server error as transient rather than refused', async () => { + respond(() => new HttpResponse(null, { status: 503 })); + + expect(await checkTokenUsable(TOKEN)).toBe(TOKEN_RESULT.TRANSIENT); + }); + + test('attaches the token per-call, since the shared header is not installed yet', async () => { + let authorization = null; + respond(({ request }) => { + authorization = request.headers.get('authorization'); + return HttpResponse.json({ data: {} }); + }); + + await checkTokenUsable(TOKEN); + + expect(authorization).toBe(`Bearer ${TOKEN}`); + }); + + test('keeps its 401 away from the global auth-recovery interceptor', async () => { + respond(() => new HttpResponse(null, { status: 401 })); + + // Mirrors the test RequestConfigManager applies before it tries to recover and then + // signs the user out. Recovering here would reproduce the bounce loop from inside the + // check meant to prevent it. + const treatedAsAuthError = []; + const interceptor = axios.interceptors.response.use(undefined, (error) => { + treatedAsAuthError.push(error?.response?.status === 401 && !error.config?.skipAuth); + return Promise.reject(error); + }); + + await checkTokenUsable(TOKEN); + axios.interceptors.response.eject(interceptor); + + expect(treatedAsAuthError).toEqual([false]); + }); +}); From 26223af4cfd126f316d570a9cfeefe06d47f6179 Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Wed, 5 Aug 2026 08:20:44 -0700 Subject: [PATCH 10/16] ERA-13805 Let discovery decide which provider the app runs under The gate replaces the statically-configured Auth0Provider, so the tenant the SDK talks to now comes from what the site advertised rather than from build config. This is the commit where the probe starts running. RootApp was already a startup gate -- it fetched system config, blocked on it, and rendered its own error. Leaving that inside a second gate would have put the two round trips in series on every cold load, since RootApp cannot mount until discovery settles. So the two are one gate now, firing both fetches together and waiting on both. A system config that never arrives still holds the overlay indefinitely, exactly as before: fetchSystemStatus swallows its errors and resolves undefined, so no caller can tell failure from slowness. That wants fixing, but not from here -- App.js consumes the same thunk's resolved value. Co-Authored-By: Claude Opus 5 (1M context) --- src/index.js | 38 +++++--------------------------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/src/index.js b/src/index.js index 6a334857f..d874af9f4 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,9 @@ -import React, { lazy, Suspense, useEffect, useRef, useState } from 'react'; +import React, { lazy, Suspense, useEffect, useRef } from 'react'; import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router'; import { createRoot } from 'react-dom/client'; import { PersistGate } from 'redux-persist/integration/react'; import { persistStore } from 'redux-persist'; -import { Provider, useDispatch, useSelector } from 'react-redux'; +import { Provider } from 'react-redux'; import ReactGA4 from 'react-ga4'; import { useTranslation } from 'react-i18next'; @@ -14,17 +14,14 @@ import './i18n'; import './index.scss'; import { APP_ROUTES } from './constants/routes'; -import appConfig from './config'; -import { Auth0Provider } from '@auth0/auth0-react'; import { EXTERNAL_SAME_DOMAIN_ROUTES, REACT_APP_GA4_TRACKING_ID, REACT_APP_ROUTE_PREFIX } from './constants'; -import { fetchSystemStatus } from './ducks/system-status'; -import { isSystemConfigLoaded } from './utils/auth'; import registerServiceWorker from './registerServiceWorker'; import { setClientReleaseIdentifier } from './utils/analytics'; import store from './store'; import withTracker from './WithTracker'; import Auth0TokenManager from './Auth0TokenManager'; +import AuthDiscoveryGate from './AuthDiscoveryGate'; import DetectOffline from './DetectOffline'; import GeoLocationWatcher from './GeoLocationWatcher'; import JiraSupportWidget from './JiraSupportWidget'; @@ -69,17 +66,9 @@ const PathNormalizationRouteComponent = () => { const RootApp = () => { const { i18n } = useTranslation(); - const dispatch = useDispatch(); - const systemConfig = useSelector((state) => state.view.systemConfig); - const [configError, setConfigError] = useState(false); useWebVitals(); - useEffect(() => { - dispatch(fetchSystemStatus()) - .catch(() => setConfigError(true)); - }, [dispatch]); - useEffect(() => { if (window?.Osano?.cm) { document.documentElement.lang = i18n.language; @@ -87,14 +76,6 @@ const RootApp = () => { } }, [i18n.language]); - // Block until system config is loaded from the server - if (!isSystemConfigLoaded(systemConfig)) { - if (configError) { - return
Failed to load system configuration. Please refresh.
; - } - return ; - } - return <> @@ -134,22 +115,13 @@ const root = createRoot(document.getElementById('root')); root.render( - + - + From ed70b3224dbf7c90cda43dcda1959deb50172beb Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Wed, 5 Aug 2026 08:20:44 -0700 Subject: [PATCH 11/16] ERA-13805 Give system config an explicit readiness flag isSystemConfigLoaded inferred readiness from require_idp being non-null, so the field carried two unrelated meanings: how the site authorizes, and whether the status response had arrived at all. Removing it would have broken the startup gate silently -- every site would have looked permanently unloaded. The gate's system-config fixture now comes from the real reducer too, like the discovery one. As a hand-written literal it went stale the moment the predicate changed; deriving it meant this change failed loudly instead. Co-Authored-By: Claude Opus 5 (1M context) --- src/AuthDiscoveryGate/index.test.js | 2 +- src/ducks/system-config/index.js | 2 ++ src/ducks/system-config/index.test.js | 2 ++ src/utils/auth.js | 6 +++--- src/utils/auth.test.js | 15 +++++++++------ 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/AuthDiscoveryGate/index.test.js b/src/AuthDiscoveryGate/index.test.js index c18c3be74..1314bd8db 100644 --- a/src/AuthDiscoveryGate/index.test.js +++ b/src/AuthDiscoveryGate/index.test.js @@ -79,7 +79,7 @@ const PROTECTED_APP = 'the protected app'; // production no longer has. const LOADED_SYSTEM_CONFIG = systemConfigReducer(undefined, { type: SET_SYSTEM_CONFIG, - payload: { require_idp: false }, + payload: { loaded: true }, }); const UNLOADED_SYSTEM_CONFIG = systemConfigReducer(undefined, {}); diff --git a/src/ducks/system-config/index.js b/src/ducks/system-config/index.js index 4139cf2a7..1e22a78cf 100644 --- a/src/ducks/system-config/index.js +++ b/src/ducks/system-config/index.js @@ -30,6 +30,7 @@ export const setSystemConfigFromSystemStatus = (systemStatus) => (dispatch) => { [SYSTEM_CONFIG_FLAGS.SUBJECTS]: systemStatus[SYSTEM_CONFIG_FLAGS.SUBJECTS] ?? true, [SYSTEM_CONFIG_FLAGS.TABLEAU]: systemStatus[SYSTEM_CONFIG_FLAGS.TABLEAU] ?? true, [SYSTEM_CONFIG_FLAGS.GEO_SPAN]: systemStatus[SYSTEM_CONFIG_FLAGS.GEO_SPAN] ?? null, + loaded: true, previewFeatures: systemStatus.preview_features || {}, require_idp: !!systemStatus.require_idp, sitename, @@ -74,6 +75,7 @@ export const INITIAL_STATE = { [SYSTEM_CONFIG_FLAGS.SUBJECTS]: false, [SYSTEM_CONFIG_FLAGS.TABLEAU]: false, [SYSTEM_CONFIG_FLAGS.GEO_SPAN]: null, + loaded: false, previewFeatures: {}, require_idp: null, sitename: '', diff --git a/src/ducks/system-config/index.test.js b/src/ducks/system-config/index.test.js index ab47ea3bb..4ab502369 100644 --- a/src/ducks/system-config/index.test.js +++ b/src/ducks/system-config/index.test.js @@ -77,6 +77,7 @@ describe('Ducks - System config', () => { [SYSTEM_CONFIG_FLAGS.SPATIAL_FEATURES]: true, [SYSTEM_CONFIG_FLAGS.SUBJECTS]: true, [SYSTEM_CONFIG_FLAGS.TABLEAU]: true, + loaded: true, previewFeatures: { community_input_admin_enabled: true, }, @@ -202,6 +203,7 @@ describe('Ducks - System config', () => { [SYSTEM_CONFIG_FLAGS.SPATIAL_FEATURES]: true, [SYSTEM_CONFIG_FLAGS.SUBJECTS]: true, [SYSTEM_CONFIG_FLAGS.TABLEAU]: true, + loaded: false, previewFeatures: { community_input_admin_enabled: true }, require_idp: null, showTrackDays: true, diff --git a/src/utils/auth.js b/src/utils/auth.js index 47406ec6e..ff224e285 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -86,6 +86,6 @@ export const stripAuth0Params = (url) => { return remaining ? `${pathname}?${remaining}` : pathname; }; -export const isSystemConfigLoaded = (systemConfig) => { - return systemConfig.require_idp !== null; -}; +// Set once a status response has been ingested. This used to be inferred from require_idp +// being non-null, which quietly coupled the startup gate to a field that is on its way out. +export const isSystemConfigLoaded = (systemConfig) => !!systemConfig.loaded; diff --git a/src/utils/auth.test.js b/src/utils/auth.test.js index b9b56b3ea..4b6d15dda 100644 --- a/src/utils/auth.test.js +++ b/src/utils/auth.test.js @@ -17,16 +17,19 @@ import { describe('auth utils', () => { describe('isSystemConfigLoaded', () => { - test('returns false when require_idp is null (not loaded)', () => { - expect(isSystemConfigLoaded({ require_idp: null, sitename: '' })).toBe(false); + test('returns false before a status response has been ingested', () => { + expect(isSystemConfigLoaded({ loaded: false, sitename: '' })).toBe(false); }); - test('returns true when require_idp is false (loaded)', () => { - expect(isSystemConfigLoaded({ require_idp: false, sitename: 'Test Site' })).toBe(true); + test('returns true once a status response has been ingested', () => { + expect(isSystemConfigLoaded({ loaded: true, sitename: 'Test Site' })).toBe(true); }); - test('returns true when require_idp is true (loaded)', () => { - expect(isSystemConfigLoaded({ require_idp: true, sitename: 'Test Site' })).toBe(true); + // require_idp used to double as the readiness sentinel, which made removing it from the + // payload a silent break of the startup gate. + test('does not depend on require_idp', () => { + expect(isSystemConfigLoaded({ loaded: true, require_idp: null })).toBe(true); + expect(isSystemConfigLoaded({ loaded: false, require_idp: true })).toBe(false); }); }); From 748031c02195f693724bc6090f275ef1d6cc33e7 Mon Sep 17 00:00:00 2001 From: StephenWithPH Date: Wed, 5 Aug 2026 08:20:44 -0700 Subject: [PATCH 12/16] ERA-13805 Read the resolution rather than the status flag Login picks its form from the grant, Auth0TokenManager and useAuthRecovery take the audience from the registration discovery resolved, and RequireAccessToken and Nav ask the same question of the same source. require_idp now has no readers outside the duck that stores it, and appConfig is no longer imported by any login path -- which is what lets the scalars go. Fixes the gate installed in the previous commit: it reads state.view.authDiscovery and the reducer was never added to the root reducer, so that slice was undefined and destructuring it would have thrown on first render. Its own tests could not see this, because they build a store that already contains the slice. store.test.js asserts the wiring against the real store instead, and fails with `undefined` when the reducer is removed. The probe's axios client is now built on first use. Importing this duck used to call axios.create() as a side effect, which broke any suite mocking axios without a create -- RequestConfigManager's, once useAuthRecovery pulled the duck in. Co-Authored-By: Claude Opus 5 (1M context) --- .../accountLinkingGate.integration.test.js | 21 +++++++++- src/Auth0TokenManager/index.js | 13 +++--- src/Auth0TokenManager/index.test.js | 32 ++++++++++----- src/Login/index.js | 14 ++++--- src/Login/index.test.js | 40 ++++++++++++++----- src/Nav/index.js | 8 ++-- src/RequireAccessToken/index.js | 15 ++++--- src/RequireAccessToken/index.test.js | 9 +++-- src/hooks/useAuthRecovery.js | 8 ++-- src/hooks/useAuthRecovery.test.js | 5 ++- src/reducers/index.js | 2 + src/store.test.js | 16 ++++++++ 12 files changed, 132 insertions(+), 51 deletions(-) create mode 100644 src/store.test.js diff --git a/src/Auth0TokenManager/accountLinkingGate.integration.test.js b/src/Auth0TokenManager/accountLinkingGate.integration.test.js index 4840fcf2d..5dc449ebb 100644 --- a/src/Auth0TokenManager/accountLinkingGate.integration.test.js +++ b/src/Auth0TokenManager/accountLinkingGate.integration.test.js @@ -10,6 +10,7 @@ import { useAuth0 } from '@auth0/auth0-react'; import Auth0TokenManager from './'; import RequireAccessToken from '../RequireAccessToken'; import tokenReducer from '../ducks/auth'; +import authDiscoveryReducer, { SET_AUTH_DISCOVERY } from '../ducks/auth-discovery'; import systemConfigReducer from '../ducks/system-config'; import { GATE_RESULT, checkAccountLinked } from '../utils/account-linking'; import useNavigate from '../hooks/useNavigate'; @@ -96,11 +97,27 @@ describe('post-callback account-linking gate', () => { store = createStore( combineReducers({ data: combineReducers({ token: tokenReducer }), - view: combineReducers({ systemConfig: systemConfigReducer }), + view: combineReducers({ + authDiscovery: authDiscoveryReducer, + systemConfig: systemConfigReducer, + }), }), { data: { token: { access_token: null } }, - view: { systemConfig: { require_idp: true } }, + view: { + authDiscovery: authDiscoveryReducer(undefined, { + type: SET_AUTH_DISCOVERY, + payload: { + ok: true, + grant: 'authorization_code', + audience: 'https://discovered.example/api', + clientId: 'discoveredClient', + issuer: 'https://auth.discovered.example/', + skipped: [], + }, + }), + systemConfig: { loaded: true }, + }, }, applyMiddleware(thunk, promiseMiddleware), ); diff --git a/src/Auth0TokenManager/index.js b/src/Auth0TokenManager/index.js index d4b9069a6..513f357e9 100644 --- a/src/Auth0TokenManager/index.js +++ b/src/Auth0TokenManager/index.js @@ -4,8 +4,8 @@ import { useDispatch, useSelector } from 'react-redux'; import { useLocation } from 'react-router'; import { APP_ROUTES } from '../constants/routes'; -import appConfig from '../config'; import { applyAccessToken, clearAuth } from '../ducks/auth'; +import { GRANT, selectResolution } from '../ducks/auth-discovery'; import { checkAccountLinked, GATE_RESULT } from '../utils/account-linking'; import { clearIntendedPostAuth0SuccessRoute, @@ -23,7 +23,8 @@ const Auth0TokenManager = () => { const navigate = useNavigate(); const existingToken = useSelector((state) => state.data.token?.access_token); - const requireIdp = useSelector((state) => !!state.view.systemConfig?.require_idp); + const { audience, grant } = useSelector(selectResolution); + const usesRedirectGrant = grant === GRANT.AUTHORIZATION_CODE; const { isAuthenticated, getAccessTokenSilently, logout } = useAuth0(); @@ -43,7 +44,7 @@ const Auth0TokenManager = () => { try { const token = await getAccessTokenSilently({ - authorizationParams: { audience: appConfig.auth0.audience }, + authorizationParams: { audience }, }); const safe = String(token).trim(); @@ -53,7 +54,7 @@ const Auth0TokenManager = () => { return; } - if (requireIdp) { + if (usesRedirectGrant) { const { result, linkUrl } = await checkAccountLinked(safe); // Unlinked: hand off to the server-owned link page (always a validated URL). @@ -96,12 +97,12 @@ const Auth0TokenManager = () => { return; } - if (!requireIdp || !isAuthenticated || existingToken) { + if (!usesRedirectGrant || !isAuthenticated || existingToken) { return; } }; ensureIdpToken(); - }, [dispatch, existingToken, getAccessTokenSilently, isAuthenticated, logout, requireIdp, navigate, location.search]); + }, [audience, dispatch, existingToken, getAccessTokenSilently, isAuthenticated, logout, usesRedirectGrant, navigate, location.search]); return null; }; diff --git a/src/Auth0TokenManager/index.test.js b/src/Auth0TokenManager/index.test.js index df64084bd..a999be2cd 100644 --- a/src/Auth0TokenManager/index.test.js +++ b/src/Auth0TokenManager/index.test.js @@ -2,7 +2,6 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useAuth0 } from '@auth0/auth0-react'; import { useDispatch, useSelector } from 'react-redux'; import { useLocation } from 'react-router'; -import appConfig from '../config'; import Auth0TokenManager from './'; import { hasAuth0CallbackParams } from '../utils/auth0'; import { isValidTokenFormat } from '../utils/auth'; @@ -49,7 +48,19 @@ describe('Auth0TokenManager', () => { useSelector.mockImplementation((selector) => { const state = { data: { token: { access_token: null } }, - view: { systemConfig: { require_idp: true } } + view: { + authDiscovery: { + discovery: { + ok: true, + grant: 'authorization_code', + audience: 'https://discovered.example/api', + clientId: 'discoveredClient', + issuer: 'https://auth.discovered.example/', + skipped: [], + }, + settled: true, + }, + } }; return selector(state); }); @@ -96,7 +107,7 @@ describe('Auth0TokenManager', () => { await waitFor(() => { expect(mockGetAccessTokenSilently).toHaveBeenCalledWith({ authorizationParams: { - audience: appConfig.auth0.audience, + audience: 'https://discovered.example/api', }, }); }); @@ -239,18 +250,21 @@ describe('Auth0TokenManager', () => { expect(applyAccessToken).not.toHaveBeenCalled(); }); - test('runs the gate on a site whose status response still reports an organization ID', async () => { + test('does not run on a site resolving to the password grant', async () => { useSelector.mockImplementation((selector) => selector({ data: { token: { access_token: null } }, - view: { systemConfig: { require_idp: true, idp_org_id: 'org_abc' } }, + view: { + authDiscovery: { + discovery: { ok: true, grant: 'password', clientId: 'das_web_client', issuer: 'http://localhost/oauth2', skipped: [] }, + settled: true, + }, + }, })); renderAfterCallback(); - await waitFor(() => { - expect(checkAccountLinked).toHaveBeenCalledWith(VALID_TOKEN); - }); - expect(applyAccessToken).toHaveBeenCalledWith(VALID_TOKEN); + await waitFor(() => expect(mockGetAccessTokenSilently).toHaveBeenCalled()); + expect(checkAccountLinked).not.toHaveBeenCalled(); }); }); }); diff --git a/src/Login/index.js b/src/Login/index.js index 4b089d8e1..31d928098 100644 --- a/src/Login/index.js +++ b/src/Login/index.js @@ -11,8 +11,8 @@ import { import { ACCOUNT_LINKER_URL, SYSTEM_CONFIG_FLAGS } from '../constants'; import { APP_ROUTES } from '../constants/routes'; -import appConfig from '../config'; import { applyAccessToken, clearAuth, postAuth } from '../ducks/auth'; +import { GRANT, selectResolution } from '../ducks/auth-discovery'; import { fetchEula } from '../ducks/eula'; import { checkTokenUsable, TOKEN_RESULT } from '../utils/token-usability'; import useNavigate from '../hooks/useNavigate'; @@ -47,18 +47,20 @@ const LoginPage = () => { const [formErrors, setFormErrors] = useState({ username: null, password: null }); const [isLoading, setIsLoading] = useState(false); + const { audience, grant } = useSelector(selectResolution); + const isEULAEnabled = !!systemConfig?.[SYSTEM_CONFIG_FLAGS.EULA]; - const requireIdp = !!systemConfig?.require_idp; + const usesRedirectGrant = grant === GRANT.AUTHORIZATION_CODE; const onAuth0Login = useCallback(async () => { try { await auth0LoginWithRedirect({ - authorizationParams: { audience: appConfig.auth0.audience }, + authorizationParams: { audience }, }); } catch (_error) { setAlertMessage(t('errorAlert.signInFailed')); } - }, [auth0LoginWithRedirect, t]); + }, [audience, auth0LoginWithRedirect, t]); const onFormSubmit = useCallback(async (event) => { event.preventDefault(); @@ -167,7 +169,7 @@ const LoginPage = () => { {/* Auth0 migration guidance: "Sign in with email" below drives EarthRanger Identity; users who have not converted their account yet are linked to the server account linker. */} - {requireIdp && ( + {usesRedirectGrant && (

{t('auth0Info.title')} @@ -185,7 +187,7 @@ const LoginPage = () => {

)} - {requireIdp ? ( + {usesRedirectGrant ? (