Skip to content

Commit

Permalink
feat: use authorization flow and HDS login handler
Browse files Browse the repository at this point in the history
KK-1097 KK-1126.

NOTE: At this point, the (local) Tunnistamo response type is changed
from implicit flow to the authorization code flow.

- Install the latest  version of the HDS-react.
- Use the login provider that the HDS offers.
- Get rid of the old OIDC implementation

refactor: remove needless code from the auth folder

refactor: remove the CRA service worker
The app is no longer CRA app, but a Vite app.

refactor: update react-helmet-async

refactor: remove the old auth state for redux

fix: route titles usage

fix: logout profile state handling and use api token from hds login
provider

refactor: user loading in profile page

refactor: deprecate profile reducer in favor of guardian provider

refactor: add refetch profile

fix: profile uses the useProfile at right time

fix: the error with helmet while login process

refactor: use a text callback handler to prevent flickering

fix: login process redirect to profile page

feat: unauthorized will redirect to profile if user has logged in

fix: follow the hook rules with HOCs

refactor: navigate to unauthorized when no permission to AppRoute

fix: profileChildDetail rendering with login nextPath

fix: remove react strict mode for better experience with hds login

fix: login issues in child route

chore: upgrade vitest

refactor: configurations for both server types

refactor: change all the react-router imports to react-router-dom

refactor: upgrade the react-helsinki-headless-cms -plugin

feat: audiences can be configured with env variables

The default OIDC server should be set to Tunnistamo, while the Keycloak
version of the Kukkuu UI cannot be published yet.

chore: add comment about React.Strict and HANDLING_LOGIN_CALLBACK

The HDS Login component is triggering HANDLING_LOGIN_CALLBACK during the
login process, since it is not working well with the React.Strict mode.

refactor: add oidc return type as configurable

refactor: remove UI reducer and redux-oidc

refactor: set code as default type since the implicit flow is not
supported
  • Loading branch information
nikomakela committed Apr 30, 2024
1 parent 6ca1209 commit e055eac
Show file tree
Hide file tree
Showing 99 changed files with 1,947 additions and 1,464 deletions.
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ VITE_APPLICATION_NAME=$npm_package_name
VITE_ELIGIBLE_CITIES=helsinki,helsingfors
VITE_OIDC_AUTHORITY=
VITE_OIDC_CLIENT_ID="https://api.hel.fi/auth/kukkuu-ui"
VITE_OIDC_KUKKUU_API_CLIENT_ID="https://api.hel.fi/auth/kukkuu"
VITE_OIDC_SCOPE="openid profile https://api.hel.fi/auth/kukkuu"
VITE_OIDC_RETURN_TYPE="code"
VITE_OIDC_SERVER_TYPE=TUNNISTAMO
VITE_OIDC_AUDIENCES=
VITE_VERSION=$npm_package_version
VITE_FEATURE_FLAG_SHOW_CORONAVIRUS_INFO=false
VITE_ADMIN_TICKET_VALIDATION_URL=https://kukkuu-admin-ui.test.hel.ninja/check-ticket-validity
Expand Down
2 changes: 1 addition & 1 deletion .env.development
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
VITE_ORIGIN=http://localhost:3000
VITE_API_URI=https://kukkuu.api.test.hel.ninja/graphql
VITE_OIDC_AUTHORITY=https://tunnistamo.test.kuva.hel.ninja
VITE_OIDC_AUTHORITY=https://tunnistamo.test.kuva.hel.ninja/
VITE_CMS_URI=https://kukkuu.hkih.stage.geniem.io/graphql
2 changes: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
VITE_ORIGIN=
VITE_ORIGIN=http://localhost:3000
VITE_CMS_URI=https://kukkuu.hkih.stage.geniem.io/graphql
VITE_OIDC_AUTHORITY="https://tunnistamo.test.kuva.hel.ninja"
1 change: 0 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
src/domain/api/generatedTypes/graphql.tsx
src/domain/headlessCms/graphql/__generated__.ts
src/serviceWorker.ts
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,14 @@ ARG VITE_ORIGIN
ARG VITE_ADMIN_TICKET_VALIDATION_URL
ARG VITE_API_URI
ARG VITE_CMS_URI
ARG VITE_OIDC_AUTHORITY
ARG VITE_ENVIRONMENT
ARG VITE_OIDC_SERVER_TYPE
ARG VITE_OIDC_RETURN_TYPE
ARG VITE_OIDC_AUTHORITY
ARG VITE_OIDC_CLIENT_ID
ARG VITE_OIDC_KUKKUU_API_CLIENT_ID
ARG VITE_OIDC_SCOPE
ARG VITE_OIDC_AUDIENCES
ARG VITE_FEATURE_FLAG_SHOW_CORONAVIRUS_INFO
ARG VITE_SENTRY_DSN
ARG VITE_BUILDTIME
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ Go to https://github.com/settings/developers/ and add a new app with the followi

Save. You'll need the created **Client ID** and **Client Secret** for configuring tunnistamo in the next step.

### Login provider configurations

Set the environment variables so that the OIDC client gets configured properly:

The configuration constants are [here](./src/domain/auth/constants.ts).
An example of a full working configuration can be seen [here](./src/domain/auth/README.md).

### Install local tunnistamo

Clone https://github.com/City-of-Helsinki/tunnistamo/.
Expand Down
17 changes: 8 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-testcafe": "^0.2.1",
"formik": "^2.4.5",
"hds-core": "^3.4.0",
"hds-react": "^3.4.0",
"hds-core": "^3.7.0",
"hds-react": "^3.7.0",
"html-react-parser": "^4.2.2",
"i18next": "^23.5.1",
"i18next-browser-languagedetector": "^7.1.0",
Expand All @@ -102,18 +102,17 @@
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"react-helsinki-headless-cms": "1.0.0-alpha253",
"react-helmet-async": "^2.0.4",
"react-helsinki-headless-cms": "^1.0.0-alpha273",
"react-i18next": "^13.2.2",
"react-modal": "^3.16.1",
"react-qrcode-logo": "^2.9.0",
"react-redux": "^8.1.2",
"react-router": "^6.16.0",
"react-router-dom": "^6.16.0",
"react-router": "^6.22.3",
"react-router-dom": "^6.22.3",
"react-toastify": "^9.1.3",
"react-transition-group": "^4.4.5",
"redux": "^4.2.1",
"redux-oidc": "^4.0.0-beta1",
"redux-persist": "^6.0.0",
"sass": "^1.67.0",
"testcafe": "^3.3.0",
Expand All @@ -123,8 +122,8 @@
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-svgr": "^4.0.0",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^0.34.4",
"vitest-sonar-reporter": "^0.4.1",
"vitest": "^1.5.0",
"vitest-sonar-reporter": "^2.0.0",
"yup": "^1.2.0"
},
"engines": {
Expand Down
32 changes: 19 additions & 13 deletions public/silent_renew.html
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
<!doctype html>
<html>
<head> </head>
<!DOCTYPE html>
<html lang="en">

<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/oidc-client/1.11.5/oidc-client.min.js"></script>
<script>
var mgr = new Oidc.UserManager();
mgr.signinSilentCallback().catch((error) => {
console.error('silent_renew.html error', error);
});
</script>
</body>
</html>
<head>
<title>Silent renewal</title>
</head>

<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/oidc-client-ts/2.2.2/browser/oidc-client-ts.min.js"
integrity="sha512-pt8b5O4w5Y9/xZpIhPN8Soo/YbC95SxHn0P/Mu39iYB2Ih/09TMS3Id5XPqve2f8DPC6voXOzgQNojCuqO6A4w=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
var mgr = new oidc.UserManager({});
mgr.signinSilentCallback().catch(error => {
console.error('silent_renew.html error', error);
});
</script>
</body>

</html>
2 changes: 1 addition & 1 deletion src/common/route/utils/getPathname.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default function getPathname(pathname: string, locale: string) {

// NOTE: When using new React-Router v6,
// having the locale in the pathname is highly recommended.
// Using the WithLocalRoute HOC this is also overridden
// Using the WithLocaleRoute HOC this is also overridden
// in the browserRouter creation.
if (locale === SUPPORT_LANGUAGES.FI) {
return basePathname;
Expand Down
16 changes: 11 additions & 5 deletions src/common/test/TestProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { ApolloClient } from '@apollo/client/core/ApolloClient';
import { useApolloClient } from '@apollo/client/react/hooks/useApolloClient';

import { store } from '../../domain/app/state/AppStore';
import ProfileProvider from '../../domain/profile/ProfileProvider';
import KukkuuHDSLoginProvider from '../../domain/auth/KukkuuHDSLoginProvider';

type Props = {
children: ReactElement | ReactNode;
Expand All @@ -24,11 +26,15 @@ const TestProviders = (props: Props) => {
return (
<Provider store={store}>
<MockedProvider mocks={mocks}>
<RHHCConfigProviderWithMockedApolloClient {...props}>
<HelmetProvider>
<BrowserRouter>{children}</BrowserRouter>
</HelmetProvider>
</RHHCConfigProviderWithMockedApolloClient>
<KukkuuHDSLoginProvider>
<ProfileProvider>
<RHHCConfigProviderWithMockedApolloClient {...props}>
<HelmetProvider>
<BrowserRouter>{children}</BrowserRouter>
</HelmetProvider>
</RHHCConfigProviderWithMockedApolloClient>
</ProfileProvider>
</KukkuuHDSLoginProvider>
</MockedProvider>
</Provider>
);
Expand Down
3 changes: 3 additions & 0 deletions src/common/translation/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
"label": "Session expired",
"message": "Your session has expired. Please log in again."
}
},
"loggingIn": {
"text": "Logging in..."
}
},
"CHILD_RELATIONSHIP_OPTIONS": {
Expand Down
3 changes: 3 additions & 0 deletions src/common/translation/i18n/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
"label": "Istuntosi on vanhentunut",
"message": "Istuntosi on vanhentunut. Ole hyvä ja kirjaudu sisään uudelleen"
}
},
"loggingIn": {
"text": "Kirjaudutaan sisään..."
}
},
"CHILD_RELATIONSHIP_OPTIONS": {
Expand Down
3 changes: 3 additions & 0 deletions src/common/translation/i18n/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
"label": "Din session har gått ut",
"message": "Din session har gått ut. Försök att logga in igen."
}
},
"loggingIn": {
"text": "Loggar in..."
}
},
"CHILD_RELATIONSHIP_OPTIONS": {
Expand Down
17 changes: 7 additions & 10 deletions src/domain/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import profileQuery from '../../profile/queries/ProfileQuery';
import client from '../client';

vi.mock(
'../../auth/state/AuthenticationSelectors',
async (importOriginal: any) => {
const mod = await importOriginal();
return {
...mod,
apiTokenSelector: () => 'foo',
};
}
);
vi.mock('../../auth/kukkuuApiUtils', async (importOriginal: any) => {
const mod = await importOriginal();
return {
...mod,
getKukkuuApiTokenFromStorage: () => 'foo',
};
});

const jsonData = {
data: {
Expand Down
34 changes: 11 additions & 23 deletions src/domain/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import {
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import * as Sentry from '@sentry/browser';
import {
removeApiTokensFromStorage,
removeUserReferenceFromStorage,
} from 'hds-react';

import i18n from '../../common/translation/i18n/i18nInit';
import { apiTokenSelector } from '../auth/state/AuthenticationSelectors';
import { store } from '../app/state/AppStore';
import { showExpiredSessionPrompt } from '../app/state/ui/UIActions';
import { fetchTokenError } from '../auth/state/BackendAuthenticationActions';
import { getCurrentLanguage } from '../../common/translation/TranslationUtils';
import { logoutTunnistamo } from '../auth/authenticate';
import { getKukkuuApiTokenFromStorage } from '../auth/kukkuuApiUtils';
import { flushAllState } from '../auth/reduxState/utils';

const httpLink = createHttpLink({
uri: import.meta.env.VITE_API_URI,
Expand All @@ -40,26 +41,13 @@ const errorLink = onError(({ graphQLErrors, networkError }) => {
console.error(errorMessage);
}

// If JWT is expired it means that we want people to log in again.
if (
// TODO: This first check is just to maintain compatibility with old backend
// versions, it can be removed later.
message === 'Invalid Authorization header. JWT has expired.' ||
errorCode === 'AUTHENTICATION_EXPIRED_ERROR'
) {
store.dispatch(showExpiredSessionPrompt());

// Clear old token in favor of avoiding Apollo loop
store.dispatch(
fetchTokenError({ message: 'Token expired', name: 'fetchTokenError' })
);
}

if (errorCode === 'AUTHENTICATION_ERROR') {
// It is not possible to recover from AUTHENTICATION_ERROR at least with the
// same API token, so to minimize further problems it is probably best to just
// log the user out completely.
logoutTunnistamo();
removeApiTokensFromStorage();
removeUserReferenceFromStorage();
flushAllState({ keepUserFormData: true });
}
});
}
Expand All @@ -70,11 +58,11 @@ const errorLink = onError(({ graphQLErrors, networkError }) => {
});

const authLink = setContext((_, { headers }) => {
const token = apiTokenSelector(store.getState());
const kukkuuApiToken = getKukkuuApiTokenFromStorage();
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : null,
authorization: kukkuuApiToken ? `Bearer ${kukkuuApiToken}` : null,
'accept-language': getCurrentLanguage(i18n),
},
};
Expand Down
68 changes: 65 additions & 3 deletions src/domain/app/AppConfig.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import i18n from '../../common/translation/i18n/i18nInit';

class AppConfig {
static get origin() {
return getEnvOrError(import.meta.env.VITE_ORIGIN, 'VITE_ORIGIN');
const originUrl = getEnvOrError(import.meta.env.VITE_ORIGIN, 'VITE_ORIGIN');
return new URL(originUrl).origin;
}

/**
Expand All @@ -11,15 +13,68 @@ class AppConfig {
return new URL(this.origin).hostname;
}

static get ApiUrl() {
static get apiUrl() {
return getEnvOrError(import.meta.env.VITE_API_URI, 'VITE_API_URI');
}

static get oidcAuthority() {
return getEnvOrError(
const origin = getEnvOrError(
import.meta.env.VITE_OIDC_AUTHORITY,
'VITE_OIDC_AUTHORITY'
);
return new URL(origin).href;
}

/**
* The audiences used in the OIDC.
*
* @example
* // In Tunnistamo it can be left undefined.
* ["https://api.hel.fi/auth/kukkuu"]
* // In Keycloak:
* [
'kukkuu-api-test',
'profile-api-test',
]
*/
static get oidcAudiences() {
return getEnvAsList(import.meta.env.VITE_OIDC_AUDIENCES);
}

static get oidcClientId() {
return getEnvOrError(
import.meta.env.VITE_OIDC_CLIENT_ID,
'VITE_OIDC_CLIENT_ID'
);
}

static get oidcScope() {
return getEnvOrError(import.meta.env.VITE_OIDC_SCOPE, 'VITE_OIDC_SCOPE,');
}

static get oidcReturnType() {
// "code" for authorization code flow.
return import.meta.env.VITE_OIDC_RETURN_TYPE ?? 'code';
}

static get oidcKukkuuApiClientId() {
return getEnvOrError(
import.meta.env.VITE_OIDC_KUKKUU_API_CLIENT_ID,
'VITE_OIDC_KUKKUU_API_CLIENT_ID'
);
}

/**
* NOTE: The oidcServerType is not an OIDC client attribute.
* It's purely used to help to select a configuration for the LoginProvider.
* */
static get oidcServerType(): 'KEYCLOAK' | 'TUNNISTAMO' {
const oidcServerType =
import.meta.env.VITE_OIDC_SERVER_TYPE ?? 'TUNNISTAMO';
if (!['KEYCLOAK', 'TUNNISTAMO'].includes(oidcServerType)) {
throw new Error(`Invalid OIDC server type: ${oidcServerType}`);
}
return oidcServerType;
}

static get cmsUri() {
Expand Down Expand Up @@ -55,4 +110,11 @@ function getEnvOrError(variable?: string, name?: string) {
return variable;
}

function getEnvAsList(variable?: string) {
if (!variable) {
return undefined;
}
return variable.split(',').map((e) => e.trim());
}

export default AppConfig;

0 comments on commit e055eac

Please sign in to comment.