Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HAI-3348 Display error message if user does not belong to allowed AD groups #1067

Merged
merged 2 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
10 changes: 10 additions & 0 deletions src/common/utils/toStringArray.ts
Original file line number Diff line number Diff line change
@@ -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 [];
}
}
49 changes: 49 additions & 0 deletions src/domain/auth/components/ADGroupsError.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex
as="article"
flexDirection="column"
alignItems="center"
textAlign="center"
mx="auto"
mt={{ base: 'var(--spacing-xl)', md: 'var(--spacing-4-xl)' }}
gap={{ base: 'var(--spacing-m)', md: 'var(--spacing-4-xl)' }}
px="var(--spacing-s)"
>
<MainHeading>{t('authentication:noADPermissionHeading')}</MainHeading>
<Box
as="p"
maxWidth="612px"
fontSize={{ base: 'var(--fontsize-body-m)', md: 'var(--fontsize-body-l)' }}
>
<Trans
i18nKey="authentication:noADPermissionText"
components={{
a: (
<Box
as={Link}
href="#"
fontSize={{ base: 'var(--fontsize-body-m)', md: 'var(--fontsize-body-l)' }}
onClick={logout}
>
Kirjaudu ulos
</Box>
),
}}
/>
</Box>
</Flex>
);
}
72 changes: 67 additions & 5 deletions src/domain/auth/components/OidcCallback.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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: (
<I18nextProvider i18n={i18n}>
<MemoryRouter initialEntries={[LOGIN_CALLBACK_PATH]}>
<Routes>
<Route path="/" element={<div>Home page</div>} />
<Route path={LOGIN_CALLBACK_PATH} element={<OidcCallback />} />
</Routes>
</MemoryRouter>
Expand Down Expand Up @@ -59,9 +61,69 @@ describe('<OidcCallback />', () => {
).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;
});
});
});
32 changes: 28 additions & 4 deletions src/domain/auth/components/OidcCallback.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -52,9 +62,22 @@ const AuthError = ({ errorText }: Readonly<AuthErrorProps>) => {
const OidcCallback = () => {
const { t } = useTranslation();
const [authenticationError, setAuthenticationError] = useState<AuthenticationError | null>(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) {
Expand All @@ -74,6 +97,7 @@ const OidcCallback = () => {
{authenticationError === 'unknown' && (
<AuthError errorText={t('authentication:genericError')} />
)}
{authenticationError === 'adGroupsError' && <ADGroupsError />}
</>
</LoginCallbackHandler>
);
Expand Down
10 changes: 10 additions & 0 deletions src/domain/auth/hasAllowedADGroups.ts
Original file line number Diff line number Diff line change
@@ -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));
}
7 changes: 6 additions & 1 deletion src/domain/auth/testUtils/renderWithLoginProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -35,6 +36,7 @@ export function renderWithLoginProvider({
state,
returnUser,
placeUserToStorage = true,
userADGroups,
errorType,
children,
}: RenderWithLoginProviderProps) {
Expand All @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/domain/auth/testUtils/userTestUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +23,7 @@ export function createUser(placeUserToStorage = true): User {
name: 'Test User',
email: '[email protected]',
amr: ['validAmr'],
ad_groups,
},
refresh_token: 'refresh_token',
scope: 'openid profile',
Expand Down
4 changes: 3 additions & 1 deletion src/locales/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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. <a>Kirjaudu ulos</a> Haitattomasta ja yritä uudelleen suomi.fi-tunnistautumisen kautta."
},
"common": {
"increment": "Lisää",
Expand Down