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 && (