diff --git a/.env b/.env index 95e86688b..799e92c23 100644 --- a/.env +++ b/.env @@ -12,6 +12,8 @@ REACT_APP_FEATURE_PUBLIC_HANKKEET=1 REACT_APP_FEATURE_HANKE=1 REACT_APP_FEATURE_CABLE_REPORT_PAPER_DECISION=1 REACT_APP_FEATURE_INFORMATION_REQUEST=1 +REACT_APP_USE_AD_FILTER=0 +REACT_APP_ALLOWED_AD_GROUPS="" REACT_APP_MAINTENANCE_TEXT_FI="" REACT_APP_MAINTENANCE_TEXT_SV="" REACT_APP_MAINTENANCE_TEXT_EN="" diff --git a/src/common/utils/toStringArray.ts b/src/common/utils/toStringArray.ts new file mode 100644 index 000000000..ff7e93a8f --- /dev/null +++ b/src/common/utils/toStringArray.ts @@ -0,0 +1,10 @@ +/** + * Converts an unknown value to an array of strings. + */ +export default function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((item) => typeof item === 'string'); + } else { + return []; + } +} diff --git a/src/domain/auth/components/ADGroupsError.tsx b/src/domain/auth/components/ADGroupsError.tsx new file mode 100644 index 000000000..74cb03420 --- /dev/null +++ b/src/domain/auth/components/ADGroupsError.tsx @@ -0,0 +1,49 @@ +import { Box, Flex } from '@chakra-ui/react'; +import { Link, useOidcClient } from 'hds-react'; +import MainHeading from '../../../common/components/mainHeading/MainHeading'; +import { Trans, useTranslation } from 'react-i18next'; + +export default function ADGroupsError() { + const { t } = useTranslation(); + const oidcClient = useOidcClient(); + + function logout() { + oidcClient.logout(); + } + + return ( + + {t('authentication:noADPermissionHeading')} + + + Kirjaudu ulos + + ), + }} + /> + + + ); +} diff --git a/src/domain/auth/components/OidcCallback.test.tsx b/src/domain/auth/components/OidcCallback.test.tsx index 5ba51364e..d6af15ecc 100644 --- a/src/domain/auth/components/OidcCallback.test.tsx +++ b/src/domain/auth/components/OidcCallback.test.tsx @@ -1,6 +1,6 @@ import { Routes, Route, MemoryRouter } from 'react-router-dom'; import { I18nextProvider } from 'react-i18next'; -import { cleanup, waitFor } from '@testing-library/react'; +import { cleanup } from '@testing-library/react'; import OidcCallback from './OidcCallback'; import { LOGIN_CALLBACK_PATH } from '../constants'; import i18n from '../../../locales/i18nForTests'; @@ -9,15 +9,17 @@ import { renderWithLoginProvider, } from '../testUtils/renderWithLoginProvider'; -function getWrapper({ state, returnUser, errorType }: RenderWithLoginProviderProps) { +function getWrapper({ state, returnUser, errorType, userADGroups }: RenderWithLoginProviderProps) { return renderWithLoginProvider({ state, returnUser, errorType, + userADGroups, children: ( + Home page} /> } /> @@ -59,9 +61,69 @@ describe('', () => { ).toBeInTheDocument(); }); - it('should redirect user after successful login', async () => { - getWrapper({ state: 'NO_SESSION', returnUser: true, errorType: 'RENEWAL_FAILED' }); + it('should redirect user to home page after successful login', async () => { + const { findByText } = getWrapper({ + state: 'NO_SESSION', + returnUser: true, + errorType: undefined, + }); - await waitFor(() => expect(window.location.pathname).toEqual('/')); + expect(await findByText('Home page')).toBeInTheDocument(); + }); + + describe('helsinki AD login', () => { + it('should show AD group error message when user does not have required AD groups', async () => { + const OLD_ENV = { ...window._env_ }; + window._env_ = { + ...OLD_ENV, + REACT_APP_USE_AD_FILTER: '1', + REACT_APP_ALLOWED_AD_GROUPS: 'test_group_2;test_group_3', + }; + const { findByText } = getWrapper({ + state: 'NO_SESSION', + returnUser: true, + errorType: undefined, + userADGroups: ['test_group'], + }); + + expect(await findByText('Ei käyttöoikeutta Haitaton-asiointiin')).toBeInTheDocument(); + window._env_ = OLD_ENV; + }); + + it('should redirect user to home page when user has required AD groups', async () => { + const OLD_ENV = { ...window._env_ }; + window._env_ = { + ...OLD_ENV, + REACT_APP_USE_AD_FILTER: '1', + REACT_APP_ALLOWED_AD_GROUPS: 'test_group;test_group_2;test_group_3', + }; + const { findByText } = getWrapper({ + state: 'NO_SESSION', + returnUser: true, + errorType: undefined, + userADGroups: ['test_group'], + }); + + expect(await findByText('Home page')).toBeInTheDocument(); + window._env_ = OLD_ENV; + }); + + it('should redirect user to home page when REACT_APP_USE_AD_FILTER is not in use even when user does not have required AD groups', async () => { + const OLD_ENV = { ...window._env_ }; + window._env_ = { + ...OLD_ENV, + REACT_APP_USE_AD_FILTER: '0', + REACT_APP_ALLOWED_AD_GROUPS: 'test_group_2', + }; + const { findByText } = getWrapper({ + state: 'NO_SESSION', + returnUser: true, + errorType: undefined, + userADGroups: ['test_group'], + }); + + expect(await findByText('Home page')).toBeInTheDocument(); + window._env_ = OLD_ENV; + }); }); }); diff --git a/src/domain/auth/components/OidcCallback.tsx b/src/domain/auth/components/OidcCallback.tsx index b8f7a191b..b53d370ae 100644 --- a/src/domain/auth/components/OidcCallback.tsx +++ b/src/domain/auth/components/OidcCallback.tsx @@ -1,12 +1,22 @@ import { useState } from 'react'; import { Flex } from '@chakra-ui/react'; import { Trans, useTranslation } from 'react-i18next'; -import { Link as HDSLink, LoginCallbackHandler, OidcClientError } from 'hds-react'; +import { + Link as HDSLink, + LoginCallbackHandler, + OidcClientError, + User, + useOidcClient, +} from 'hds-react'; import Text from '../../../common/components/text/Text'; import { useLocalizedRoutes } from '../../../common/hooks/useLocalizedRoutes'; import Link from '../../../common/components/Link/Link'; +import ADGroupsError from './ADGroupsError'; +import hasAllowedADGroups from '../hasAllowedADGroups'; +import { useNavigate } from 'react-router-dom'; +import toStringArray from '../../../common/utils/toStringArray'; -type AuthenticationError = 'permissionDeniedByUserError' | 'unknown'; +type AuthenticationError = 'permissionDeniedByUserError' | 'unknown' | 'adGroupsError'; type AuthErrorProps = { errorText: string; @@ -52,9 +62,22 @@ const AuthError = ({ errorText }: Readonly) => { const OidcCallback = () => { const { t } = useTranslation(); const [authenticationError, setAuthenticationError] = useState(null); + const navigate = useNavigate(); + const oidcClient = useOidcClient(); - function onSuccess() { - window.location.pathname = '/'; + function onSuccess(user: User) { + const { ad_groups } = user.profile; + const useADFilter = window._env_.REACT_APP_USE_AD_FILTER === '1'; + const amr = oidcClient.getAmr(); + const helsinkiADUsed = amr?.includes('helsinkiad'); + + // Check if user has required AD groups (when login is done with AD and AD filtering is enabled). + if (helsinkiADUsed && useADFilter && !hasAllowedADGroups(toStringArray(ad_groups))) { + setAuthenticationError('adGroupsError'); + return; + } + + navigate('/', { replace: true }); } function onError(error?: OidcClientError) { @@ -74,6 +97,7 @@ const OidcCallback = () => { {authenticationError === 'unknown' && ( )} + {authenticationError === 'adGroupsError' && } ); diff --git a/src/domain/auth/hasAllowedADGroups.ts b/src/domain/auth/hasAllowedADGroups.ts new file mode 100644 index 000000000..ee55fe298 --- /dev/null +++ b/src/domain/auth/hasAllowedADGroups.ts @@ -0,0 +1,10 @@ +/** + * Checks if the user belongs to any of the allowed Active Directory (AD) groups. + * + * @param {string[]} ad_groups - An array of AD group names the user belongs to. + * @returns {boolean} - Returns `true` if the user belongs to at least one of the allowed AD groups, otherwise `false`. + */ +export default function hasAllowedADGroups(ad_groups: string[]): boolean { + const ALLOWED_AD_GROUPS = window._env_?.REACT_APP_ALLOWED_AD_GROUPS?.split(';') ?? []; + return ad_groups.some((group) => ALLOWED_AD_GROUPS.includes(group)); +} diff --git a/src/domain/auth/testUtils/renderWithLoginProvider.tsx b/src/domain/auth/testUtils/renderWithLoginProvider.tsx index 528700a80..cd6fe3f6a 100644 --- a/src/domain/auth/testUtils/renderWithLoginProvider.tsx +++ b/src/domain/auth/testUtils/renderWithLoginProvider.tsx @@ -27,6 +27,7 @@ export type RenderWithLoginProviderProps = { state: OidcClientState; returnUser: boolean; placeUserToStorage?: boolean; + userADGroups?: string[]; errorType?: 'SIGNIN_ERROR' | 'INVALID_OR_EXPIRED_USER' | 'RENEWAL_FAILED'; children?: React.ReactNode; }; @@ -35,6 +36,7 @@ export function renderWithLoginProvider({ state, returnUser, placeUserToStorage = true, + userADGroups, errorType, children, }: RenderWithLoginProviderProps) { @@ -45,10 +47,13 @@ export function renderWithLoginProvider({ connect: (targetBeacon) => { beacon = targetBeacon; beacon.addListener(triggerForAllOidcClientSignals, (signal) => { - const user = createUser(placeUserToStorage); + const user = createUser(placeUserToStorage, userADGroups); const oidcClient = signal.context as OidcClient; jest.spyOn(oidcClient, 'getState').mockReturnValue(state); jest.spyOn(oidcClient, 'getUser').mockReturnValue(user); + jest + .spyOn(oidcClient, 'getAmr') + .mockReturnValue(userADGroups === undefined ? ['suomi_fi'] : ['helsinkiad']); if (!returnUser) { jest.spyOn(oidcClient, 'handleCallback').mockRejectedValue(handleError); } else { diff --git a/src/domain/auth/testUtils/userTestUtil.ts b/src/domain/auth/testUtils/userTestUtil.ts index 920a4e612..16b870480 100644 --- a/src/domain/auth/testUtils/userTestUtil.ts +++ b/src/domain/auth/testUtils/userTestUtil.ts @@ -5,7 +5,7 @@ const client_id = 'test-client'; const tokenExpirationTimeInSeconds = 3600; -export function createUser(placeUserToStorage = true): User { +export function createUser(placeUserToStorage = true, ad_groups?: string[]): User { const nowAsSeconds = Math.round(Date.now() / 1000); const expires_in = tokenExpirationTimeInSeconds; const expires_at = nowAsSeconds + expires_in; @@ -23,6 +23,7 @@ export function createUser(placeUserToStorage = true): User { name: 'Test User', email: 'test.user@mail.com', amr: ['validAmr'], + ad_groups, }, refresh_token: 'refresh_token', scope: 'openid profile', diff --git a/src/locales/fi.json b/src/locales/fi.json index f97067a1b..3bdf07c97 100644 --- a/src/locales/fi.json +++ b/src/locales/fi.json @@ -10,7 +10,9 @@ "loggingIn": "Kirjaudutaan sisään...", "loggingOut": "Kirjaudutaan ulos...", "loggingInErrorLabel": "Kirjautumisessa tapahtui virhe", - "logoutError": "Uloskirjautumisessa tapahtui virhe" + "logoutError": "Uloskirjautumisessa tapahtui virhe", + "noADPermissionHeading": "Ei käyttöoikeutta Haitaton-asiointiin", + "noADPermissionText": "Sinulla ei ole oikeutta AD-tunnistautumiseen. Kirjaudu ulos Haitattomasta ja yritä uudelleen suomi.fi-tunnistautumisen kautta." }, "common": { "increment": "Lisää",