diff --git a/public/config.js.example b/public/config.js.example index febdf287d..902e529b3 100644 --- a/public/config.js.example +++ b/public/config.js.example @@ -1,12 +1,26 @@ // 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. 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/public/locales/en-US/login.json b/public/locales/en-US/login.json index 2d8df07cf..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": { @@ -25,9 +26,9 @@ "eulaLinkLabel": "EarthRanger EULA (opens in a new tab)", "loginButton": "Log in", "loginButtonEmail": "Sign in with email", - "loginButtonIdp": "Sign in", "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 d57393682..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": { @@ -25,9 +26,9 @@ "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", + "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 c8db968db..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": { @@ -25,9 +26,9 @@ "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", + "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 730596763..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": { @@ -25,9 +26,9 @@ "eulaLinkLabel": "अर्थरेन्जर EULA (नयाँ ट्याबमा खुल्छ)", "loginButton": "लग इन", "loginButtonEmail": "इमेलबाट साइन इन", - "loginButtonIdp": "साइन इन", "loginButtonLoadingLabel": "लोड हुँदैछ", "passwordLabel": "पासवर्ड", + "signInUnavailable": "EarthRanger ले {{site}} मा तपाईंलाई कसरी साइन इन गराउने निर्धारण गर्न सकेन। पुनः प्रयास गर्न पृष्ठ रिफ्रेस गर्नुहोस्, र समस्या जारी रहे प्रशासकलाई सम्पर्क गर्नुहोस्।", "title": "लग इन", "usernameLabel": "युजरनेम" } diff --git a/public/locales/pt/login.json b/public/locales/pt/login.json index 615ba0b9d..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": { @@ -25,9 +26,9 @@ "eulaLinkLabel": "EULA EarthRanger (abre em uma nova guia)", "loginButton": "Conecte-se", "loginButtonEmail": "Conecte-se com e-mail", - "loginButtonIdp": "Conecte-se", "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 137ac22b1..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": { @@ -25,9 +26,9 @@ "eulaLinkLabel": "EarthRanger EULA (hufungua kwenye kichupo kipya)", "loginButton": "Ingia", "loginButtonEmail": "Ingia kwa barua pepe", - "loginButtonIdp": "Ingia", "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/Auth0TokenManager/accountLinkingGate.integration.test.js b/src/Auth0TokenManager/accountLinkingGate.integration.test.js index 87d8f9b76..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, idp_org_id: null } }, // common-DB site + 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 c0e5b73ae..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,8 +23,8 @@ 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 { audience, grant } = useSelector(selectResolution); + const usesRedirectGrant = grant === GRANT.AUTHORIZATION_CODE; const { isAuthenticated, getAccessTokenSilently, logout } = useAuth0(); @@ -44,7 +44,7 @@ const Auth0TokenManager = () => { try { const token = await getAccessTokenSilently({ - authorizationParams: { audience: appConfig.auth0.audience }, + authorizationParams: { audience }, }); const safe = String(token).trim(); @@ -54,8 +54,7 @@ const Auth0TokenManager = () => { return; } - // Account-linking gate — common-DB path only; org-scoped (rcuksa) sites skip it. - if (requireIdp && !idpOrgId?.trim()) { + if (usesRedirectGrant) { const { result, linkUrl } = await checkAccountLinked(safe); // Unlinked: hand off to the server-owned link page (always a validated URL). @@ -98,12 +97,12 @@ const Auth0TokenManager = () => { return; } - if (!requireIdp || !isAuthenticated || existingToken) { + if (!usesRedirectGrant || !isAuthenticated || existingToken) { return; } }; ensureIdpToken(); - }, [dispatch, existingToken, getAccessTokenSilently, idpOrgId, 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 c94645baa..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, idp_org_id: null } } + 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,17 +250,20 @@ describe('Auth0TokenManager', () => { expect(applyAccessToken).not.toHaveBeenCalled(); }); - test('org-scoped (idp_org_id set): skips the gate and authenticates', 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(applyAccessToken).toHaveBeenCalledWith(VALID_TOKEN); - }); + await waitFor(() => expect(mockGetAccessTokenSilently).toHaveBeenCalled()); expect(checkAccountLinked).not.toHaveBeenCalled(); }); }); 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..1314bd8db --- /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: { loaded: true }, +}); +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(); + }); +}); diff --git a/src/Login/index.js b/src/Login/index.js index 38fc1feab..4c55a2c8d 100644 --- a/src/Login/index.js +++ b/src/Login/index.js @@ -11,10 +11,11 @@ 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 { 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 { setResolvedIssuer } from '../utils/auth'; import useNavigate from '../hooks/useNavigate'; import * as styles from './styles.module.scss'; @@ -47,19 +48,22 @@ const LoginPage = () => { const [formErrors, setFormErrors] = useState({ username: null, password: null }); const [isLoading, setIsLoading] = useState(false); - const idpOrgId = systemConfig?.idp_org_id?.trim() || null; + const { audience, clientId, grant, issuer } = 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 { + // Carried across the redirect so the callback leg does not have to probe again. + setResolvedIssuer(issuer); await auth0LoginWithRedirect({ - authorizationParams: buildAuth0AuthorizationParams(appConfig.auth0.audience, idpOrgId), + authorizationParams: { audience }, }); } catch (_error) { setAlertMessage(t('errorAlert.signInFailed')); } - }, [auth0LoginWithRedirect, idpOrgId, t]); + }, [audience, auth0LoginWithRedirect, issuer, t]); const onFormSubmit = useCallback(async (event) => { event.preventDefault(); @@ -86,7 +90,18 @@ const LoginPage = () => { setIsLoading(true); try { - await dispatch(postAuth({ username, password })); + const accessToken = await dispatch(postAuth({ username, password }, clientId)); + + // 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 } } : {}; @@ -109,7 +124,7 @@ const LoginPage = () => { } finally { setIsLoading(false); } - }, [dispatch, formData, location, navigate, t]); + }, [clientId, dispatch, formData, location, navigate, t]); const onInputChange = useCallback((event) => { setFormData((prevFormData) => ({ ...prevFormData, [event.target.name]: event.target.value })); @@ -154,11 +169,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. */} + {usesRedirectGrant && (

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

)} - {requireIdp ? ( + {usesRedirectGrant ? (
) : ( diff --git a/src/Login/index.test.js b/src/Login/index.test.js index 856e85511..d9ad12515 100644 --- a/src/Login/index.test.js +++ b/src/Login/index.test.js @@ -4,8 +4,9 @@ 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 { getResolvedIssuer } from '../utils/auth'; import { fetchEula } from '../ducks/eula'; import { mockStore } from '../__test-helpers/MockStore'; import { render, screen, waitFor } from '../test-utils'; @@ -26,12 +27,37 @@ 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'; + +const REDIRECT_GRANT = { + discovery: { + ok: true, + grant: 'authorization_code', + audience: 'https://discovered.example/api', + clientId: 'discoveredClient', + issuer: 'https://auth.discovered.example/', + skipped: [], + }, + settled: true, +}; + +const PASSWORD_GRANT = { + discovery: { ok: true, grant: 'password', clientId: 'das_web_client', issuer: 'http://localhost/oauth2', skipped: [] }, + settled: true, +}; + describe('Login', () => { let loginWithRedirect, navigate, store; beforeEach(() => { @@ -40,7 +66,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); @@ -51,6 +79,7 @@ describe('Login', () => { }, }, view: { + authDiscovery: PASSWORD_GRANT, systemConfig: {}, }, }); @@ -88,12 +117,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: { authDiscovery: REDIRECT_GRANT, systemConfig: {} }, }); 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,78 +131,73 @@ 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: { authDiscovery: REDIRECT_GRANT, systemConfig: {} }, }); 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: 'https://discovered.example/api' }, }); }); - test('disables the Auth0 sign-in button and shows a loading state while Auth0 reports loading', () => { + test('stashes the resolved issuer so the callback leg need not probe again', async () => { store = mockStore({ data: { eula: { eula_url: '' } }, - view: { systemConfig: { require_idp: true, idp_org_id: 'org_abc' } }, + view: { authDiscovery: REDIRECT_GRANT, systemConfig: {} }, }); - 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' })); + + expect(getResolvedIssuer()).toBe(REDIRECT_GRANT.discovery.issuer); }); - test('shows the common-DB "Sign in with email" button and no configuration error when IDP is required without an organization ID', () => { + 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 } }, + view: { authDiscovery: REDIRECT_GRANT, systemConfig: {} }, }); + loginWithRedirect.mockResolvedValue(undefined); 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(); + await userEvent.click(screen.getByRole('button', { name: 'Sign in with email' })); + + expect(loginWithRedirect).toHaveBeenCalledWith({ + authorizationParams: { audience: 'https://discovered.example/api' }, + }); }); - 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 } }, + view: { authDiscovery: REDIRECT_GRANT, systemConfig: {} }, }); - 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 } }, + view: { authDiscovery: REDIRECT_GRANT, systemConfig: {} }, }); renderLogin(); @@ -196,37 +220,23 @@ 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: { authDiscovery: REDIRECT_GRANT, systemConfig: {} }, }); 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: '' } }, - view: { systemConfig: { require_idp: false } }, + view: { authDiscovery: PASSWORD_GRANT, systemConfig: {} }, }); renderLogin(); @@ -241,13 +251,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: { authDiscovery: REDIRECT_GRANT, systemConfig: {} }, }); 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.'); @@ -497,6 +507,72 @@ 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('signs in with the client ID discovery resolved', async () => { + renderLogin(); + + await submitCredentials(); + + await waitFor(() => expect(postAuth).toHaveBeenCalledWith( + { username: 'alice', password: 'secret' }, + PASSWORD_GRANT.discovery.clientId, + )); + }); + + 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(); @@ -505,7 +581,10 @@ describe('Login', () => { await userEvent.click(screen.getByRole('button', { name: 'Log in' })); await waitFor(() => { - expect(postAuth).toHaveBeenCalledWith({ username: 'alice', password: 'secret' }); + expect(postAuth).toHaveBeenCalledWith( + { username: 'alice', password: 'secret' }, + PASSWORD_GRANT.discovery.clientId, + ); }); }); @@ -617,6 +696,7 @@ describe('Login', () => { store = mockStore({ data: { eula: { eula_url: 'https://example.com/eula' } }, view: { + authDiscovery: PASSWORD_GRANT, systemConfig: { [SYSTEM_CONFIG_FLAGS.EULA]: true }, }, }); @@ -634,6 +714,7 @@ describe('Login', () => { store = mockStore({ data: { eula: { eula_url: 'https://example.com/eula' } }, view: { + authDiscovery: PASSWORD_GRANT, systemConfig: { [SYSTEM_CONFIG_FLAGS.EULA]: false }, }, }); diff --git a/src/Nav/index.js b/src/Nav/index.js index 3f034d0d6..51150c2af 100644 --- a/src/Nav/index.js +++ b/src/Nav/index.js @@ -8,6 +8,7 @@ import { addModal } from '../ducks/modals'; import { APP_ROUTES } from '../constants/routes'; import { BREAKPOINTS, MAX_ZOOM, REACT_APP_ROUTE_PREFIX } from '../constants'; import { clearAuth } from '../ducks/auth'; +import { selectUsesRedirectGrant } from '../ducks/auth-discovery'; import { clearUserProfile, fetchCurrentUser, fetchCurrentUserProfiles, setUserProfile } from '../ducks/user'; import getWindowLocation from '../utils/getWindowLocation'; import { globalMenuDrawerId } from '../Drawer'; @@ -61,8 +62,7 @@ const Nav = () => { const user = useSelector((state) => state.data.user); const userProfiles = useSelector((state) => state.data.userProfiles); const selectedUserProfile = useSelector((state) => state.data.selectedUserProfile); - const systemConfig = useSelector((state) => state.view.systemConfig); - const requireIdp = !!systemConfig?.require_idp; + const usesRedirectGrant = useSelector(selectUsesRedirectGrant); const onHomeMapSelect = (chosenMap) => { dispatch(setHomeMap(chosenMap)); @@ -111,7 +111,7 @@ const Nav = () => { await dispatch(clearAuth()); // Log out of IDP if enabled - if (requireIdp) { + if (usesRedirectGrant) { auth0Logout({ logoutParams: { returnTo: window.location.origin + REACT_APP_ROUTE_PREFIX, @@ -127,7 +127,7 @@ const Nav = () => { await dispatch(clearAuth()); navigate({ pathname: APP_ROUTES.LOGIN }, { replace: true }); } - }, [dispatch, navigate, requireIdp, auth0Logout]); + }, [dispatch, navigate, usesRedirectGrant, auth0Logout]); useEffect(() => { dispatch(fetchCurrentUser()) diff --git a/src/RequireAccessToken/index.js b/src/RequireAccessToken/index.js index e3f52e9b2..727a6fe84 100644 --- a/src/RequireAccessToken/index.js +++ b/src/RequireAccessToken/index.js @@ -6,27 +6,27 @@ import { useAuth0 } from '@auth0/auth0-react'; import { APP_ROUTES } from '../constants/routes'; import { getTemporaryAccessTokenFromCookies, setIntendedPostAuth0SuccessRoute } from '../utils/auth'; import { hasAuth0CallbackParams } from '../utils/auth0'; +import { selectUsesRedirectGrant } from '../ducks/auth-discovery'; import LoadingOverlay from '../LoadingOverlay'; -const RequireAccessToken = ({ children, token, systemConfig }) => { +const RequireAccessToken = ({ children, token, usesRedirectGrant }) => { const location = useLocation(); const { isLoading: auth0Loading } = useAuth0(); const temporaryAccessToken = getTemporaryAccessTokenFromCookies(); - const requireIdp = !!systemConfig?.require_idp; const hasAuth0Params = hasAuth0CallbackParams(location.search); const hasToken = temporaryAccessToken || token.access_token; // Store intended route when redirecting to login (only for IDP mode to survive Auth0 redirect) useEffect(() => { - if (requireIdp && !hasToken && !hasAuth0Params) { + if (usesRedirectGrant && !hasToken && !hasAuth0Params) { setIntendedPostAuth0SuccessRoute(`${location.pathname}${location.search}`); } - }, [requireIdp, hasToken, hasAuth0Params, location.pathname, location.search]); + }, [usesRedirectGrant, hasToken, hasAuth0Params, location.pathname, location.search]); // Show loading during Auth0 callback OR while Auth0 is processing - if ((requireIdp && auth0Loading) || hasAuth0Params) { + if ((usesRedirectGrant && auth0Loading) || hasAuth0Params) { return ; } @@ -41,6 +41,9 @@ const RequireAccessToken = ({ children, token, systemConfig }) => { />; }; -const mapStateToProps = ({ data: { token }, view: { systemConfig } }) => ({ token, systemConfig }); +const mapStateToProps = (state) => ({ + token: state.data.token, + usesRedirectGrant: selectUsesRedirectGrant(state), +}); export default connect(mapStateToProps)(memo(RequireAccessToken)); diff --git a/src/RequireAccessToken/index.test.js b/src/RequireAccessToken/index.test.js index 5b7bef2ed..a9657bb01 100644 --- a/src/RequireAccessToken/index.test.js +++ b/src/RequireAccessToken/index.test.js @@ -21,13 +21,14 @@ jest.mock('../LoadingOverlay', () => () =>
Loading... describe('RequireAccessToken', () => { const mockToken = { access_token: 'test_token' }; - const mockSystemConfig = { require_idp: false }; + const PASSWORD_GRANT = { discovery: { ok: true, grant: 'password' }, settled: true }; + const REDIRECT_GRANT = { discovery: { ok: true, grant: 'authorization_code' }, settled: true }; let store; - const renderWithProvider = (component, token = { access_token: null }, systemConfig = mockSystemConfig) => { + const renderWithProvider = (component, token = { access_token: null }, authDiscovery = PASSWORD_GRANT) => { store = mockStore({ data: { token }, - view: { systemConfig } + view: { authDiscovery } }); return render( @@ -71,7 +72,7 @@ describe('RequireAccessToken', () => {
Protected Content
, { access_token: null }, - { require_idp: true } + REDIRECT_GRANT ); expect(screen.getByTestId('loading')).toBeInTheDocument(); diff --git a/src/config.js b/src/config.js index 9c46a0ea0..c0d5a0ed2 100644 --- a/src/config.js +++ b/src/config.js @@ -6,22 +6,34 @@ // 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__ ?? {}; const appConfig = { - auth0: { - audience: overrides.auth0?.audience ?? PRODUCTION_DEFAULTS.auth0.audience, - 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..7735b1e79 100644 --- a/src/config.test.js +++ b/src/config.test.js @@ -1,9 +1,17 @@ describe('appConfig', () => { + const PRODUCTION_AUTHORIZATION_SERVER = 'https://auth.pamdas.org/'; + const PRODUCTION_DEFAULTS = { - 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', + }, }, }; @@ -26,57 +34,85 @@ describe('appConfig', () => { expect(appConfig).toEqual(PRODUCTION_DEFAULTS); }); - test('returns production defaults when nested group is empty', () => { - window.__APP_CONFIG__ = { auth0: {} }; + // The Auth0 tenant used to be named twice: once as a flat audience/clientId/domain triple + // and once as a registry entry. Only the registry survives. + test('holds no auth0 block at all', () => { + const { default: appConfig } = require('./config'); + + expect(appConfig).not.toHaveProperty('auth0'); + }); + + test('ignores an auth0 block in an override', () => { + window.__APP_CONFIG__ = { auth0: { domain: 'auth-dev.pamdas.org' } }; const { default: appConfig } = require('./config'); 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', () => { @@ -88,12 +124,15 @@ describe('appConfig', () => { expect(appConfig).not.toHaveProperty('unknownSection'); }); - test('ignores unrecognized keys within a known group', () => { - window.__APP_CONFIG__ = { auth0: { domain: 'auth-dev.pamdas.org', unknownKey: 'value' } }; + test('ignores unrecognized keys alongside a known one', () => { + window.__APP_CONFIG__ = { + authorizationServers: { $self: { clientId: 'das_web_client', grant: 'password' } }, + unknownKey: 'value', + }; const { default: appConfig } = require('./config'); - expect(appConfig.auth0.domain).toBe('auth-dev.pamdas.org'); - expect(appConfig.auth0).not.toHaveProperty('unknownKey'); + expect(appConfig).not.toHaveProperty('unknownKey'); + expect(Object.keys(appConfig)).toEqual(['authorizationServers']); }); }); 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/ducks/auth.js b/src/ducks/auth.js index c5e2f009e..22b03b1d6 100644 --- a/src/ducks/auth.js +++ b/src/ducks/auth.js @@ -5,7 +5,7 @@ import { clearUserProfile } from './user'; import { resetGlobalState } from '../reducers/global-resettable'; import { deleteAuthTokenCookie, deleteTemporaryAccessTokenCookie, getAuthTokenFromCookies } from '../utils/auth'; -const AUTH_URL = `${DAS_HOST}${REACT_APP_DAS_AUTH_TOKEN_URL}`; +export const AUTH_URL = `${DAS_HOST}${REACT_APP_DAS_AUTH_TOKEN_URL}`; // actions export const POST_AUTH_SUCCESS = 'POST_AUTH_SUCCESS'; @@ -15,18 +15,21 @@ export const CLEAR_AUTH = 'CLEAR_AUTH'; const RESET_MASTER_CANCEL_TOKEN = 'RESET_MASTER_CANCEL_TOKEN'; // action creators -export const postAuth = (userData) => (dispatch) => { +// The client ID comes from the registration discovery resolved for this site, not from here: +// the registry is the one place a client is described, and a build whose registry names a +// different one has to send that one. +export const postAuth = (userData, clientId) => () => { const formData = new FormData(); formData.set('grant_type', 'password'); - formData.set('client_id', 'das_web_client'); + formData.set('client_id', clientId); Object.keys(userData).forEach(item => { 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 +40,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/ducks/auth.test.js b/src/ducks/auth.test.js index a403e41c9..f6ed5d945 100644 --- a/src/ducks/auth.test.js +++ b/src/ducks/auth.test.js @@ -1,4 +1,49 @@ -import { applyAccessToken, POST_AUTH_SUCCESS } from './auth'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; + +import { applyAccessToken, AUTH_URL, POST_AUTH_SUCCESS, postAuth } from './auth'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('postAuth', () => { + const capturedForm = async (dispatchedThunk) => { + let form = null; + server.use(http.post(AUTH_URL, async ({ request }) => { + form = Object.fromEntries(await request.formData()); + return HttpResponse.json({ access_token: 'issued-token' }); + })); + + await dispatchedThunk(); + return form; + }; + + // The client ID belongs to the registration discovery resolved, not to this module. A build + // whose registry names a different one must send that one. + test('sends the client ID it is given rather than one of its own', async () => { + const form = await capturedForm(postAuth({ username: 'alice', password: 'secret' }, 'some_other_client')); + + expect(form.client_id).toBe('some_other_client'); + }); + + test('sends the password grant with the supplied credentials, and returns the token', async () => { + let token; + const form = await capturedForm(async () => { + token = await postAuth({ username: 'alice', password: 'secret' }, 'das_web_client')(); + }); + + expect(form).toEqual({ + grant_type: 'password', + client_id: 'das_web_client', + username: 'alice', + password: 'secret', + }); + expect(token).toBe('issued-token'); + }); +}); describe('applyAccessToken', () => { let cookieWrites; diff --git a/src/ducks/system-config/index.js b/src/ducks/system-config/index.js index fe2fbde79..9b4ec756b 100644 --- a/src/ducks/system-config/index.js +++ b/src/ducks/system-config/index.js @@ -30,9 +30,8 @@ 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, + loaded: true, previewFeatures: systemStatus.preview_features || {}, - require_idp: !!systemStatus.require_idp, sitename, showTrackDays: systemStatus.show_track_days, }, @@ -75,9 +74,8 @@ export const INITIAL_STATE = { [SYSTEM_CONFIG_FLAGS.SUBJECTS]: false, [SYSTEM_CONFIG_FLAGS.TABLEAU]: false, [SYSTEM_CONFIG_FLAGS.GEO_SPAN]: null, - idp_org_id: null, + loaded: false, previewFeatures: {}, - require_idp: null, sitename: '', showTrackDays: DEFAULT_SHOW_TRACK_DAYS, }; diff --git a/src/ducks/system-config/index.test.js b/src/ducks/system-config/index.test.js index 580bcdbc9..9a8ce6759 100644 --- a/src/ducks/system-config/index.test.js +++ b/src/ducks/system-config/index.test.js @@ -51,7 +51,9 @@ 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 }, + require_idp: true, show_track_days: true, site_name: 'Site name', }; @@ -76,11 +78,10 @@ describe('Ducks - System config', () => { [SYSTEM_CONFIG_FLAGS.SPATIAL_FEATURES]: true, [SYSTEM_CONFIG_FLAGS.SUBJECTS]: true, [SYSTEM_CONFIG_FLAGS.TABLEAU]: true, - idp_org_id: null, + loaded: true, previewFeatures: { community_input_admin_enabled: true, }, - require_idp: false, sitename: 'Site name', showTrackDays: true, }, @@ -202,9 +203,8 @@ describe('Ducks - System config', () => { [SYSTEM_CONFIG_FLAGS.SPATIAL_FEATURES]: true, [SYSTEM_CONFIG_FLAGS.SUBJECTS]: true, [SYSTEM_CONFIG_FLAGS.TABLEAU]: true, - idp_org_id: null, + loaded: false, previewFeatures: { community_input_admin_enabled: true }, - require_idp: null, showTrackDays: true, sitename: 'Site name', }; diff --git a/src/hooks/useAuthRecovery.js b/src/hooks/useAuthRecovery.js index 79f151269..b2c78f514 100644 --- a/src/hooks/useAuthRecovery.js +++ b/src/hooks/useAuthRecovery.js @@ -3,10 +3,9 @@ import { useAuth0 } from '@auth0/auth0-react'; import { useSelector } from 'react-redux'; import { useLocation } from 'react-router'; -import appConfig from '../config'; +import { selectResolution } from '../ducks/auth-discovery'; import { registerAuthRecovery } from '../utils/auth-recovery'; -import { buildAuth0AuthorizationParams } from '../utils/auth0'; -import { setIntendedPostAuth0SuccessRoute } from '../utils/auth'; +import { setIntendedPostAuth0SuccessRoute, setResolvedIssuer } from '../utils/auth'; /** * Registers the live @auth0/auth0-react primitives into the shared auth-recovery unit @@ -15,7 +14,7 @@ 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); + const { audience, issuer } = useSelector(selectResolution); useEffect(() => { registerAuthRecovery({ @@ -24,9 +23,10 @@ const useAuthRecovery = () => { // redirect navigates away, so return a never-settling promise (no premature replay). stepUp: async ({ acrValues, maxAge } = {}) => { setIntendedPostAuth0SuccessRoute(`${pathname}${search}`); + setResolvedIssuer(issuer); await loginWithRedirect({ authorizationParams: { - ...buildAuth0AuthorizationParams(appConfig.auth0.audience, idpOrgId), + audience, ...(acrValues ? { acr_values: acrValues } : {}), ...(maxAge ? { max_age: maxAge } : {}), }, @@ -34,7 +34,7 @@ const useAuthRecovery = () => { return new Promise(() => {}); }, }); - }, [getAccessTokenSilently, loginWithRedirect, idpOrgId, pathname, search]); + }, [audience, getAccessTokenSilently, issuer, loginWithRedirect, pathname, search]); }; export default useAuthRecovery; diff --git a/src/hooks/useAuthRecovery.test.js b/src/hooks/useAuthRecovery.test.js index 0a21825cf..50f6ec741 100644 --- a/src/hooks/useAuthRecovery.test.js +++ b/src/hooks/useAuthRecovery.test.js @@ -5,14 +5,17 @@ import { useLocation } from 'react-router'; import useAuthRecovery from './useAuthRecovery'; import { registerAuthRecovery } from '../utils/auth-recovery'; -import { setIntendedPostAuth0SuccessRoute } from '../utils/auth'; +import { setIntendedPostAuth0SuccessRoute, setResolvedIssuer } 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() })); -jest.mock('../config', () => ({ __esModule: true, default: { auth0: { audience: 'https://api.example' } } })); +jest.mock('../utils/auth', () => ({ + setIntendedPostAuth0SuccessRoute: jest.fn(), + setResolvedIssuer: jest.fn(), +})); + const getRegistered = () => registerAuthRecovery.mock.calls.at(-1)[0]; @@ -24,7 +27,12 @@ describe('useAuthRecovery', () => { getAccessTokenSilently = jest.fn(); loginWithRedirect = jest.fn().mockResolvedValue(undefined); useAuth0.mockReturnValue({ getAccessTokenSilently, loginWithRedirect }); - useSelector.mockReturnValue(null); // idp_org_id (common-DB site) + useSelector.mockReturnValue({ + ok: true, + grant: 'authorization_code', + audience: 'https://api.example', + issuer: 'https://auth.example.org/', + }); useLocation.mockReturnValue({ pathname: '/events/123', search: '?foo=bar' }); }); @@ -43,14 +51,22 @@ 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', - }), + }, }); }); + test('stashes the resolved issuer so the step-up callback leg need not probe again', () => { + renderHook(() => useAuthRecovery()); + + getRegistered().stepUp({ acrValues: 'urn:mfa' }); + + expect(setResolvedIssuer).toHaveBeenCalledWith('https://auth.example.org/'); + }); + test('stepUp omits acr_values / max_age when the challenge lacks them', () => { renderHook(() => useAuthRecovery()); 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( - + - + diff --git a/src/reducers/index.js b/src/reducers/index.js index 14150c42a..a52c683d8 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -19,6 +19,7 @@ import mapPositionReducer, { persistenceConfig as mapPositionPersistenceConfig } import tracksReducer, { trackSettingsReducer } from '../ducks/tracks'; import mapSubjectReducer, { subjectGroupsReducer, subjectStoreReducer } from '../ducks/subjects'; import gearReducer from '../ducks/gear'; +import authDiscoveryReducer from '../ducks/auth-discovery'; import systemConfigReducer from '../ducks/system-config'; import systemStatusReducer from '../ducks/system-status'; import { @@ -153,6 +154,7 @@ const rootReducer = combineReducers({ showReportHeatmap: reportHeatmapStateReducer, trackSettings: persistReducer(trackSettingsPersistenceConfig, trackSettingsReducer), userNotifications: userNotificationReducer, + authDiscovery: authDiscoveryReducer, systemConfig: systemConfigReducer, timeSliderState: timeSliderReducer, printTitle: printTitleReducer, diff --git a/src/store.test.js b/src/store.test.js new file mode 100644 index 000000000..7803b800a --- /dev/null +++ b/src/store.test.js @@ -0,0 +1,16 @@ +import store from './store'; +import { INITIAL_STATE as AUTH_DISCOVERY_INITIAL_STATE } from './ducks/auth-discovery'; + +describe('store', () => { + // Components read these slices straight off the state tree. A duck that is written, tested + // and rendered but never wired into the root reducer throws on first access, and no test + // that builds its own store with mockStore can see that. + test('exposes the auth-discovery slice the startup gate reads', () => { + expect(store.getState().view.authDiscovery).toEqual(AUTH_DISCOVERY_INITIAL_STATE); + }); + + test('leaves auth discovery unpersisted, so a stale authorization decision cannot survive a reload', () => { + expect(store.getState().view.authDiscovery).not.toHaveProperty('_persist'); + expect(store.getState().view.systemConfig).not.toHaveProperty('_persist'); + }); +}); diff --git a/src/utils/auth.js b/src/utils/auth.js index 34cc7dfb6..ff224e285 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; @@ -56,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 5b95a9ed6..4b6d15dda 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, @@ -14,19 +17,19 @@ 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); + 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)', () => { - const systemConfig = { require_idp: false, sitename: 'Test Site' }; - expect(isSystemConfigLoaded(systemConfig)).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)', () => { - const systemConfig = { require_idp: true, sitename: 'Test Site', idp_org_id: 'org_123' }; - expect(isSystemConfigLoaded(systemConfig)).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); }); }); @@ -170,6 +173,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 = ''; 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', - }); - }); - }); }); 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]); + }); +});