diff --git a/package.json b/package.json index f8548024..25cd2195 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stromae", - "version": "2.4.12", + "version": "2.4.13", "description": "Web application for the management of questionnaires powered by Lunatic", "repository": { "type": "git", @@ -18,8 +18,7 @@ "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "date-fns": "^2.29.3", - "jwt-decode": "^3.1.2", - "oidc-client-ts": "^2.3.0", + "oidc-spa": "^2.0.3", "powerhooks": "^1.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/components/auth/hoc/hoc.js b/src/components/auth/hoc/hoc.js index 34cbf130..d014fee8 100644 --- a/src/components/auth/hoc/hoc.js +++ b/src/components/auth/hoc/hoc.js @@ -1,9 +1,9 @@ -import { useContext } from 'react'; -import { AuthContext } from '../provider/component'; +import { useAuth } from 'utils/hooks/useAuth'; const secure = (WrappedComponent) => { const Component = (props) => { - const { isUserLoggedIn, login } = useContext(AuthContext); + const { oidc } = useAuth(); + const { isUserLoggedIn, login } = oidc; const { otherProps } = props; const ReturnedComponent = ; @@ -11,7 +11,9 @@ const secure = (WrappedComponent) => { if (isUserLoggedIn) { return ReturnedComponent; } - login(); + login({ + doesCurrentHrefRequiresAuth: true, + }); return null; }; diff --git a/src/components/auth/provider/component.js b/src/components/auth/provider/component.js index f40b70e3..67e192e3 100644 --- a/src/components/auth/provider/component.js +++ b/src/components/auth/provider/component.js @@ -1,56 +1,60 @@ -import React, { useEffect, useState } from 'react'; -import { errorDictionary } from '../../../i18n'; -import { createOidcClient } from '../../../utils/auth'; -import { NONE, OIDC } from '../../../utils/constants'; -import { listenActivity } from '../../../utils/events'; +import { LoaderSimple } from 'components/shared/loader'; +import { createOidcProvider } from 'oidc-spa/react'; +import React from 'react'; +import { OIDC, READ_ONLY } from '../../../utils/constants'; import { environment, oidcConf } from '../../../utils/read-env-vars'; -import { LoaderSimple } from '../../shared/loader'; const dummyOidcClient = { isUserLoggedIn: true, - getUser: () => ({ accessToken: null, sub: '' }), logout: () => (window.location.href = '/'), + getTokens: () => ({ + accessToken: null, + idToken: null, + refreshToken: null, + refreshTokenExpirationTime: null, + accessTokenExpirationTime: null, + }), renewToken: () => {}, }; const { AUTH_TYPE, IDENTITY_PROVIDER, PORTAIL_URL } = environment; +const { authUrl, realm, client_id } = oidcConf; + +function getCurrentSurvey(path) { + const temp = path.split('/questionnaire/'); + if (temp.length > 1) { + const idQ = temp[1].slice(0, temp[1].indexOf('/')); + return idQ.substr(0, idQ.indexOf('2')).toLowerCase(); + } + return ''; +} + +export const getLogoutUrl = () => + `${PORTAIL_URL}/${getCurrentSurvey(window.location.href)}`; + +const isReadOnlyMode = window.location.pathname.startsWith(`/${READ_ONLY}`); + +const getExtraQueryParams = () => { + if (isReadOnlyMode) return { kc_idp_hint: IDENTITY_PROVIDER }; + return {}; +}; export const AuthContext = React.createContext(); export function AuthProvider({ children }) { - const [oidcClient, setOidcClient] = useState(() => { - switch (AUTH_TYPE) { - case OIDC: - return null; - case NONE: - return dummyOidcClient; - default: - throw new Error(errorDictionary.noAuthFile); - } - }); - - useEffect(() => { - if (AUTH_TYPE !== OIDC) { - return; - } - - (async () => { - const oidcClient = await createOidcClient({ - url: oidcConf.authUrl, - realm: oidcConf.realm, - clientId: oidcConf.client_id, - identityProvider: IDENTITY_PROVIDER, - urlPortail: PORTAIL_URL, - evtUserActivity: listenActivity, - }); - - setOidcClient(oidcClient); - })(); - }, []); - - if (oidcClient === null) return ; + if (AUTH_TYPE === OIDC) { + const { OidcProvider } = createOidcProvider({ + issuerUri: `${authUrl}/realms/${realm}`, + clientId: client_id, + getExtraQueryParams: getExtraQueryParams, + // See above for other parameters + }); + return }>{children}; + } return ( - {children} + + {children} + ); } diff --git a/src/components/navigation/burgerMenu/burgerMenu.js b/src/components/navigation/burgerMenu/burgerMenu.js index 13b14913..e57dda9b 100644 --- a/src/components/navigation/burgerMenu/burgerMenu.js +++ b/src/components/navigation/burgerMenu/burgerMenu.js @@ -4,11 +4,10 @@ import Close from '@material-ui/icons/Close'; import ExitToApp from '@material-ui/icons/ExitToApp'; import Help from '@material-ui/icons/Help'; import MenuIcon from '@material-ui/icons/Menu'; -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { burgerDictionary } from '../../../i18n'; import { HOUSEHOLD } from '../../../utils/constants'; import { SIMPLE_CLICK_EVENT, paradataHandler } from '../../../utils/events'; -import { AuthContext } from '../../auth/provider/component'; import { AppVersion } from '../../designSystem/AppVersion'; import { AssistanceConfirm } from '../../modals/assistance'; import './burgerMenu.css'; @@ -22,8 +21,6 @@ const BurgerMenu = ({ metadata, currentPage, logoutAndClose }) => { const [assistance, setAssistance] = useState(false); const { inseeContext } = metadata; - const { isUserLoggedIn } = useContext(AuthContext); - useEffect(() => { window.addEventListener('scroll', closeMenu); return () => { @@ -53,7 +50,7 @@ const BurgerMenu = ({ metadata, currentPage, logoutAndClose }) => {   {burgerDictionary.help} - {isUserLoggedIn && inseeContext === HOUSEHOLD && ( + {inseeContext === HOUSEHOLD && ( { const { putData, putStateData, postParadata } = useAPI(idSU, idQ); - const { logout, getUser, isUserLoggedIn } = useContext(AuthContext); + const { + oidc: { logout, isUserLoggedIn }, + } = useAuth(); + + const { user } = useUser(); const { getReferentiel } = useGetReferentiel(); @@ -74,12 +79,12 @@ const OrchestratorManager = () => { }); const logoutAndClose = useConstCallback(() => { - logout('portail'); + logout({ redirectTo: 'specific url', url: getLogoutUrl() }); }); useEffect(() => { if (isUserLoggedIn && questionnaire) { - LOGGER.addMetadata({ idSession: getUser().sub }); + LOGGER.addMetadata({ idSession: user.sub }); LOGGER.log(INIT_SESSION_EVENT); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/utils/auth/index.js b/src/utils/auth/index.js deleted file mode 100644 index ad5f17ac..00000000 --- a/src/utils/auth/index.js +++ /dev/null @@ -1 +0,0 @@ -export { createOidcClient } from './oidcClient'; diff --git a/src/utils/auth/oidcClient.js b/src/utils/auth/oidcClient.js deleted file mode 100644 index 7641efaa..00000000 --- a/src/utils/auth/oidcClient.js +++ /dev/null @@ -1,262 +0,0 @@ -/* eslint-disable no-labels, no-lone-blocks */ -import jwt_decode from 'jwt-decode'; -import { UserManager } from 'oidc-client-ts'; -import { - addParamToUrl, - retrieveParamFromUrl, -} from 'powerhooks/tools/urlSearchParams'; -import { READ_ONLY } from '../constants'; -import { Deferred } from '../tools/Deferred'; -import { fnv1aHashToHex } from '../tools/fnv1aHashToHex'; - -export const createOidcClient = async ({ - url, - realm, - clientId, - identityProvider, - evtUserActivity, - urlPortail, -}) => { - const isReadOnlyMode = window.location.pathname.startsWith(`/${READ_ONLY}`); - const configHash = fnv1aHashToHex(`${url} ${realm} ${clientId}`); - const configHashKey = 'configHash'; - - const userManager = new UserManager({ - authority: `${url}/realms/${realm}`, - client_id: clientId, - redirect_uri: '' /* provided when calling login */, - response_type: 'code', - scope: 'openid profile', - automaticSilentRenew: false, - silent_redirect_uri: `${window.location.origin}/silent-sso.html?${configHashKey}=${configHash}`, - }); - - const login = async () => { - const { newUrl: redirect_uri } = addParamToUrl({ - url: window.location.href, - name: configHashKey, - value: configHash, - }); - - await userManager.signinRedirect({ - redirect_uri, - extraQueryParams: isReadOnlyMode - ? { kc_idp_hint: identityProvider } - : null, - redirectMethod: 'replace', - }); - return new Promise(() => {}); - }; - - read_successful_login_query_params: { - let url = window.location.href; - { - const result = retrieveParamFromUrl({ name: configHashKey, url }); - - if (!result.wasPresent || result.value !== configHash) { - break read_successful_login_query_params; - } - - url = result.newUrl; - } - - { - const result = retrieveParamFromUrl({ name: 'error', url }); - - if (result.wasPresent) { - throw new Error(`OIDC error: ${result.value}`); - } - } - - // signinRedirectCallback required url in params - let loginSuccessUrl = 'https://dummy.com'; - - for (const name of ['code', 'state', 'session_state']) { - const result = retrieveParamFromUrl({ name, url }); - - loginSuccessUrl = addParamToUrl({ - url: loginSuccessUrl, - name: name, - value: result.value, - }).newUrl; - url = result.newUrl; - } - - try { - await userManager.signinRedirectCallback(loginSuccessUrl); - } catch { - //NOTE: The user has likely pressed the back button just after logging in. - } - - window.history.pushState(null, '', url); - } - - async function silentSignInGetAccessToken() { - const dLoginSuccessUrl = new Deferred(); - - const timeout = setTimeout( - () => - dLoginSuccessUrl.reject( - new Error(`SSO silent login timeout with clientId: ${clientId}`) - ), - 5000 - ); - - const listener = (event) => { - if ( - event.origin !== window.location.origin || - typeof event.data !== 'string' - ) { - return; - } - - const url = event.data; - - { - let result; - - try { - result = retrieveParamFromUrl({ name: configHashKey, url }); - } catch { - // This could possibly happen if url is not a valid url. - return; - } - - if (!result.wasPresent || result.value !== configHash) { - return; - } - } - - clearTimeout(timeout); - - window.removeEventListener('message', listener); - - { - const result = retrieveParamFromUrl({ name: 'error', url }); - - if (result.wasPresent) { - dLoginSuccessUrl.resolve(undefined); - return; - } - } - - let loginSuccessUrl = 'https://dummy.com'; - - for (const name of ['code', 'state', 'session_state']) { - const result = retrieveParamFromUrl({ name, url }); - - loginSuccessUrl = addParamToUrl({ - url: loginSuccessUrl, - name: name, - value: result.value, - }).newUrl; - } - - dLoginSuccessUrl.resolve(loginSuccessUrl); - }; - - window.addEventListener('message', listener, false); - - userManager.signinSilent({ silentRequestTimeoutInSeconds: 1 }).catch(() => { - /* error expected */ - }); - - const loginSuccessUrl = await dLoginSuccessUrl.pr; - - if (loginSuccessUrl === undefined) { - return undefined; - } - - const user = await userManager.signinRedirectCallback(loginSuccessUrl); - - return user; - } - - let currentUser = await userManager.getUser(); - - let currentAccessToken = currentUser?.access_token ?? ''; - - if (currentUser === null) { - const user = await silentSignInGetAccessToken(); - - if (user) { - currentUser = user; - } - } - - if (currentUser === null) { - return { - isUserLoggedIn: false, - login, - }; - } - const oidcClient = { - isUserLoggedIn: true, - getUser: () => ({ - accessToken: currentUser.access_token, - sub: currentUser.sub, - }), - logout: async ({ redirectTo }) => { - await userManager.signoutRedirect({ - post_logout_redirect_uri: (() => { - switch (redirectTo) { - case 'portail': - return `${urlPortail}/${getCurrentSurvey(window.location.href)}`; - case 'home': - return window.location.origin; - default: - return urlPortail; - } - })(), - }); - return new Promise(() => {}); - }, - renewToken: async () => { - const user = await userManager.signinSilent(); - currentUser = user; - currentAccessToken = user.access_token; - }, - }; - - (function callee() { - const msBeforeExpiration = - getAccessTokenExpirationTime(currentAccessToken) - Date.now(); - - setTimeout(async () => { - console.log( - `OIDC access token will expire in ${minValiditySecond} seconds, waiting for user activity before renewing` - ); - - await evtUserActivity(); - - console.log('User activity detected. Refreshing access token now'); - - try { - await oidcClient.renewToken(); - } catch { - console.log("Can't refresh OIDC access token, getting a new one"); - //NOTE: Never resolves - await login(); - } - - callee(); - }, msBeforeExpiration - minValiditySecond * 1000); - })(); - - return oidcClient; -}; - -const minValiditySecond = 25; - -function getAccessTokenExpirationTime(accessToken) { - return jwt_decode(accessToken).exp * 1000; -} - -function getCurrentSurvey(path) { - const temp = path.split('/questionnaire/'); - if (temp.length > 1) { - const idQ = temp[1].slice(0, temp[1].indexOf('/')); - return idQ.substr(0, idQ.indexOf('2')).toLowerCase(); - } - return ''; -} diff --git a/src/utils/hooks/api.js b/src/utils/hooks/api.js index a886dc5f..b911e88f 100644 --- a/src/utils/hooks/api.js +++ b/src/utils/hooks/api.js @@ -1,10 +1,10 @@ -import { useContext, useEffect, useState } from 'react'; -import { AuthContext } from '../../components/auth/provider/component'; +import { useEffect, useState } from 'react'; import { errorDictionary } from '../../i18n'; import { API } from '../api'; import { getFetcherForLunatic } from '../api/fetcher'; import { DEFAULT_DATA_URL, DEFAULT_METADATA_URL } from '../constants'; import { environment } from '../read-env-vars'; +import { useAuth } from './useAuth'; import { useConstCallback } from './useConstCallback'; const { API_URL: apiUrl } = environment; @@ -19,17 +19,17 @@ const getErrorMessage = (response, type = 'q') => { }; export const useGetReferentiel = (nomenclatures) => { - const oidcClient = useContext(AuthContext); + const { oidc } = useAuth(); const getReferentiel = useConstCallback((refName) => { const finalUrl = `${apiUrl}/api/nomenclature/${refName}`; - return getFetcherForLunatic(oidcClient.getUser().accessToken)(finalUrl); + return getFetcherForLunatic(oidc.getTokens().accessToken)(finalUrl); }); const getReferentielForVizu = useConstCallback((refName) => { if (nomenclatures && Object.keys(nomenclatures).includes(refName)) { const finalUrl = nomenclatures[refName]; - return getFetcherForLunatic(oidcClient.getUser().accessToken)(finalUrl); + return getFetcherForLunatic(oidc.getTokens().accessToken)(finalUrl); } // No nomenclature, return empty array to lunatic return Promise.resolve([]); @@ -39,48 +39,44 @@ export const useGetReferentiel = (nomenclatures) => { }; export const useAPI = (surveyUnitID, questionnaireID) => { - const oidcClient = useContext(AuthContext); + const { oidc } = useAuth(); const getRequiredNomenclatures = useConstCallback(() => API.getRequiredNomenclatures(apiUrl)(questionnaireID)( - oidcClient.getUser().accessToken + oidc.getTokens().accessToken ) ); const getQuestionnaire = useConstCallback(() => - API.getQuestionnaire(apiUrl)(questionnaireID)( - oidcClient.getUser().accessToken - ) + API.getQuestionnaire(apiUrl)(questionnaireID)(oidc.getTokens().accessToken) ); const getMetadata = useConstCallback(() => - API.getMetadata(apiUrl)(questionnaireID)(oidcClient.getUser().accessToken) + API.getMetadata(apiUrl)(questionnaireID)(oidc.getTokens().accessToken) ); const getSuData = useConstCallback(() => - API.getSuData(apiUrl)(surveyUnitID)(oidcClient.getUser().accessToken) + API.getSuData(apiUrl)(surveyUnitID)(oidc.getTokens().accessToken) ); const getPDF = useConstCallback(() => - API.getDepositProof(apiUrl)(surveyUnitID)(oidcClient.getUser().accessToken) + API.getDepositProof(apiUrl)(surveyUnitID)(oidc.getTokens().accessToken) ); const putSuData = useConstCallback((body) => - API.putSuData(apiUrl)(surveyUnitID)(oidcClient.getUser().accessToken)(body) + API.putSuData(apiUrl)(surveyUnitID)(oidc.getTokens().accessToken)(body) ); const putData = useConstCallback((body) => - API.putData(apiUrl)(surveyUnitID)(oidcClient.getUser().accessToken)(body) + API.putData(apiUrl)(surveyUnitID)(oidc.getTokens().accessToken)(body) ); const putStateData = useConstCallback((body) => - API.putStateData(apiUrl)(surveyUnitID)(oidcClient.getUser().accessToken)( - body - ) + API.putStateData(apiUrl)(surveyUnitID)(oidc.getTokens().accessToken)(body) ); const postParadata = useConstCallback((body) => - API.postParadata(apiUrl)(oidcClient.getUser().accessToken)(body) + API.postParadata(apiUrl)(oidc.getTokens().accessToken)(body) ); return { diff --git a/src/utils/hooks/useAuth.js b/src/utils/hooks/useAuth.js new file mode 100644 index 00000000..c4c14a98 --- /dev/null +++ b/src/utils/hooks/useAuth.js @@ -0,0 +1,35 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { AuthContext } from 'components/auth/provider/component'; +import { decodeJwt } from 'oidc-spa'; +import { useOidc } from 'oidc-spa/react'; +import { useContext, useMemo } from 'react'; +import { OIDC } from 'utils/constants'; +import { environment } from 'utils/read-env-vars'; + +const { AUTH_TYPE } = environment; + +export const useAuth = () => { + if (AUTH_TYPE === OIDC) { + return useOidc(); + } else { + const dummyClient = useContext(AuthContext); + return { oidc: dummyClient }; + } +}; + +export const useUser = () => { + const { oidc } = useAuth(); + + if (!oidc.isUserLoggedIn) { + throw new Error('This hook should be used only on authenticated routes'); + } + + const { idToken } = oidc.getTokens(); + + const user = useMemo(() => { + if (AUTH_TYPE === OIDC) return decodeJwt(idToken); + return { preferred_username: null, sub: '' }; + }, [idToken]); + + return { user }; +}; diff --git a/yarn.lock b/yarn.lock index 17de03b2..9172990e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4629,9 +4629,9 @@ crypto-browserify@^3.11.0: randomfill "^1.0.3" crypto-js@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.1.1.tgz#9e485bcf03521041bd85844786b83fb7619736cf" - integrity sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw== + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== crypto-random-string@^1.0.0: version "1.0.0" @@ -10121,6 +10121,15 @@ oidc-client-ts@^2.3.0: crypto-js "^4.1.1" jwt-decode "^3.1.2" +oidc-spa@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/oidc-spa/-/oidc-spa-2.0.3.tgz#387a6c92b2fbd097d75ca9f3fa4b10f7f5213cdc" + integrity sha512-pZJgjpdVr2CRGKhqQGj6z7qu1INo9/xP6HkkqIata3zXEmFrIK/zv6SCpDdFMfeIVfqQWV946FVbccUqBrUlcA== + dependencies: + jwt-decode "^3.1.2" + oidc-client-ts "^2.3.0" + tsafe "^1.6.5" + on-finished@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"