Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions public/config.js.example
Original file line number Diff line number Diff line change
@@ -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',
},
},
};
3 changes: 2 additions & 1 deletion public/locales/en-US/login.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
3 changes: 2 additions & 1 deletion public/locales/es/login.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
3 changes: 2 additions & 1 deletion public/locales/fr/login.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
3 changes: 2 additions & 1 deletion public/locales/ne-NP/login.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"invalidCredentialsMessage": "गलत प्रमाणहरु दिइयो । कृपया पुनः प्रयास गर्नुहोस् ।",
"signInFailed": "साइन इन असफल भयो। कृपया पुनः प्रयास गर्नुहोस्।",
"signInIncomplete": "हामी तपाईंको साइन इन पूरा गर्न सकेनौं। कृपया पुनः प्रयास गर्नुहोस्।",
"signInNotAcceptedHere": "यो साइटले साइन-इन स्वीकार गरेन। तपाईंको प्रयोगकर्ता नाम र पासवर्ड सही थियो, त्यसैले प्रशासकलाई यो एप्लिकेसनलाई यहाँ साइन इन गर्न अनुमति छ भनी जाँच गर्न अनुरोध गर्नुहोस्।",
"unknownErrorMessage": "केही त्रुटी भएको छ । कृपया पुनः प्रयास गर्नुहोस् ।"
},
"errors": {
Expand All @@ -25,9 +26,9 @@
"eulaLinkLabel": "अर्थरेन्जर EULA (नयाँ ट्याबमा खुल्छ)",
"loginButton": "लग इन",
"loginButtonEmail": "इमेलबाट साइन इन",
"loginButtonIdp": "साइन इन",
"loginButtonLoadingLabel": "लोड हुँदैछ",
"passwordLabel": "पासवर्ड",
"signInUnavailable": "EarthRanger ले {{site}} मा तपाईंलाई कसरी साइन इन गराउने निर्धारण गर्न सकेन। पुनः प्रयास गर्न पृष्ठ रिफ्रेस गर्नुहोस्, र समस्या जारी रहे प्रशासकलाई सम्पर्क गर्नुहोस्।",
"title": "लग इन",
"usernameLabel": "युजरनेम"
}
3 changes: 2 additions & 1 deletion public/locales/pt/login.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
3 changes: 2 additions & 1 deletion public/locales/sw/login.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
21 changes: 19 additions & 2 deletions src/Auth0TokenManager/accountLinkingGate.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
);
Expand Down
15 changes: 7 additions & 8 deletions src/Auth0TokenManager/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();

Expand All @@ -44,7 +44,7 @@ const Auth0TokenManager = () => {

try {
const token = await getAccessTokenSilently({
authorizationParams: { audience: appConfig.auth0.audience },
authorizationParams: { audience },
});

const safe = String(token).trim();
Expand All @@ -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).
Expand Down Expand Up @@ -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;
};
Expand Down
30 changes: 22 additions & 8 deletions src/Auth0TokenManager/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -96,7 +107,7 @@ describe('Auth0TokenManager', () => {
await waitFor(() => {
expect(mockGetAccessTokenSilently).toHaveBeenCalledWith({
authorizationParams: {
audience: appConfig.auth0.audience,
audience: 'https://discovered.example/api',
},
});
});
Expand Down Expand Up @@ -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();
});
});
Expand Down
81 changes: 81 additions & 0 deletions src/AuthDiscoveryGate/index.js
Original file line number Diff line number Diff line change
@@ -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]);
Comment on lines +33 to +46

// 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 <LoadingOverlay />;

// 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 <ErrorMessage message={t('signInUnavailable', {
defaultValue: 'EarthRanger could not work out how to sign you in to {{site}}. Refresh to try again, and contact your administrator if it keeps happening.',
site: hostOf(DAS_HOST),
})} />;
}

// 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 <Auth0Provider
cacheLocation="localstorage"
clientId={discovery.clientId}
domain={hostOf(discovery.issuer)}
useRefreshTokens={true}
authorizationParams={{
audience: discovery.audience,
redirect_uri: `${window.location.origin}${REACT_APP_ROUTE_PREFIX}`,
}}
>
{children}
</Auth0Provider>;
};

export default AuthDiscoveryGate;
Loading