Skip to content

Commit

Permalink
feat: keycloak support for auth service and provider
Browse files Browse the repository at this point in the history
* refactor: add some new env-variables to the docker file

* feat: support Helsinki-Profile Keycloak

KK-1097 KK-1127.

Use the `oidc-client-ts` for TypeScript instead of the `oidc-client` for
JavaScript.

Configure the login callback page to use the url_state instead of state
for the next url handling.

Configure the authService to support a (Helsinki-Profile) Keycloak.

* refactor: update the silent_renew

* refactor: use appconfig to config api tokens client

* fix: reset auth state when auth token has expired

* fix: app configuration should support current Tunnistamo by default

* chore: add an env file example for a local use of test keycloak

* docs: local keycloak usage

* fix: fetchApiToken should have error handling

* refactor: upgrade the silent renew html oidc-client
  • Loading branch information
nikomakela authored May 2, 2024
1 parent 3a8e9ff commit edb6872
Show file tree
Hide file tree
Showing 12 changed files with 282 additions and 70 deletions.
11 changes: 11 additions & 0 deletions .env.local.keycloak-example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
REACT_APP_OIDC_SERVER_TYPE=KEYCLOAK
REACT_APP_OIDC_RETURN_TYPE="code"
REACT_APP_OIDC_AUTHORITY=https://tunnistus.test.hel.ninja/auth/realms/helsinki-tunnistus/
REACT_APP_OIDC_CLIENT_ID="kukkuu-admin-ui-dev"
REACT_APP_OIDC_KUKKUU_API_CLIENT_ID="kukkuu-api-dev"
REACT_APP_OIDC_SCOPE="openid profile"
REACT_APP_OIDC_AUDIENCES=kukkuu-api-dev
# REACT_APP_API_URI=https://kukkuu.api.test.hel.ninja/graphql
REACT_APP_API_URI=http://localhost:8081/graphql
REACT_APP_SENTRY_DSN=
REACT_APP_FEATURE_FLAG_EXTERNAL_TICKET_SYSTEM_SUPPORT=true
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,13 @@ FROM appbase AS staticbuilder
# ===================================

ARG REACT_APP_API_URI
ARG REACT_APP_OIDC_SERVER_TYPE
ARG REACT_APP_OIDC_RETURN_TYPE
ARG REACT_APP_OIDC_AUTHORITY
ARG REACT_APP_OIDC_CLIENT_ID
ARG REACT_APP_OIDC_KUKKUU_API_CLIENT_ID
ARG REACT_APP_OIDC_SCOPE
ARG REACT_APP_OIDC_AUDIENCES
ARG REACT_APP_KUKKUU_API_OIDC_SCOPE
ARG REACT_APP_ENVIRONMENT
ARG REACT_APP_SENTRY_DSN
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,36 @@ and execute the following four commands inside your docker container:

To make Kukkuu Admin use the local Tunnistamo set `REACT_APP_OIDC_AUTHORITY="http://tunnistamo-backend:8000"` for example in file `.env.local`.

#### Using the Helsinki-Profile Keycloak instead of Tunnistamo

> It is planned that the Tunnistamo will be replaced with Helsinki-Profile Keycloak during the summer of 2024.
There is an [example of Keycloak environment variables](./.env.local.keycloak-example) that can be used, when a local Kukkuu Admin UI is wanted to be connected to the Helsinki-Profile Keycloak of a test environment.

The example file should include some what the following variables, that are telling the app to change the behavior of the authorization provider a bit, compared to how it is with Tunnistamo.

- `REACT_APP_OIDC_SERVER_TYPE=KEYCLOAK` is to add some parameters to the token-request that the Keycloak service needs. As a comparison, by default it is working as `REACT_APP_OIDC_SERVER_TYPE=TUNNISTAMO`).
- `REACT_APP_OIDC_RETURN_TYPE=code` is to use authorization code flow instead of deprecated (and even removed from `oidc-client-ts`) implicit flow.
- `REACT_APP_OIDC_AUTHORITY` tells where the authorization service is located and who the issuer of the JWT is.
- `REACT_APP_OIDC_CLIENT_ID` is the unique client id that is used when the client is configured to auth service.
- `REACT_APP_OIDC_SCOPE="openid profile"` tells that the Kukkuu Admin UI needs the openid and profile information to be included in the JWT.
- `REACT_APP_OIDC_AUDIENCES=kukkuu-api-dev` means that when the authorization is given, the access is needed to these clients too, so the api-tokens needs to be generated.
- `REACT_APP_OIDC_KUKKUU_API_CLIENT_ID` is used collect the proper auth token for communication between the Admin UI and the API.

Example configuration when a local Kukkuu API is used with a local Kukkuu Admin UI and Helsinki-Profile Keycloak from the test environment:

```shell
REACT_APP_OIDC_SERVER_TYPE=KEYCLOAK
REACT_APP_OIDC_RETURN_TYPE="code"
REACT_APP_OIDC_AUTHORITY=https://tunnistus.test.hel.ninja/auth/realms/helsinki-tunnistus/
REACT_APP_OIDC_CLIENT_ID="kukkuu-admin-ui-dev"
REACT_APP_OIDC_KUKKUU_API_CLIENT_ID="kukkuu-api-dev"
REACT_APP_OIDC_SCOPE="openid profile"
REACT_APP_OIDC_AUDIENCES=kukkuu-api-dev
# REACT_APP_API_URI=https://kukkuu.api.test.hel.ninja/graphql
REACT_APP_API_URI=http://localhost:8081/graphql
```

#### Install Kukkuu API locally

Clone the repository (https://github.com/City-of-Helsinki/kukkuu). Follow the instructions for running kukkuu with docker. Before running `docker-compose up` set the following settings in kukkuu roots `docker-compose.env.yaml`:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"history": "^5.3.0",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"oidc-client": "^1.11.5",
"oidc-client-ts": "^3.0.1",
"prettier": "^2.8.8",
"prop-types": "^15.8.1",
"query-string": "^8.1.0",
Expand Down
31 changes: 19 additions & 12 deletions public/silent_renew.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
<!DOCTYPE html>
<html>
<head></head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/oidc-client/1.10.1/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>
<html lang="en">

<head>
<title>Silent renewal</title>
</head>

<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/oidc-client-ts/3.0.1/browser/oidc-client-ts.min.js"
integrity="sha512-dbp16seDDFaTwxhmIRipIY43lyMA70TDsc0zBODkVoM2LmD+UI8ndMbW8Qospq5+st97jIiaGCg2/vl0lBDBqQ=="
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>
1 change: 1 addition & 0 deletions src/api/apolloClient/handleApolloError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const handleApolloError: ErrorHandler = ({
) {
// If JWT is expired it means that we want people to log in again. We don't need to log this to sentry.
console.error('JWT expired');
authService.resetAuthState();
} else if (errorCode === 'PERMISSION_DENIED_ERROR') {
// Most permission errors happen when user authentication
// expires or when the user accesses the application before
Expand Down
95 changes: 95 additions & 0 deletions src/domain/application/AppConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
class AppConfig {
static get apiUrl() {
return getEnvOrError(process.env.REACT_APP_API_URI, 'REACT_APP_API_URI');
}

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

/**
* The audiences used in the OIDC.
*
* @example
* // In Tunnistamo it can be left as undefined,
* // because it is not included in the request done bythe OIDC client.
* ["https://api.hel.fi/auth/kukkuu"]
* // In Keycloak:
* [
'kukkuu-api-test',
'profile-api-test',
]
*/
static get oidcAudience() {
return process.env.REACT_APP_OIDC_AUDIENCES;
}

static get oidcClientId() {
return getEnvOrError(
process.env.REACT_APP_OIDC_CLIENT_ID,
'REACT_APP_OIDC_CLIENT_ID'
);
}

static get oidcScope() {
return getEnvOrError(
process.env.REACT_APP_OIDC_SCOPE,
'REACT_APP_OIDC_SCOPE,'
);
}

static get oidcReturnType() {
// "code" for authorization code flow.
return process.env.REACT_APP_OIDC_RETURN_TYPE ?? 'code';
}

static get oidcKukkuuApiClientId() {
return (
process.env.REACT_APP_OIDC_KUKKUU_API_CLIENT_ID ?? this.oidcKukkuuAPIScope
);
}

static get oidcKukkuuApiTokensUrl() {
return this.oidcServerType === 'KEYCLOAK'
? `${this.oidcAuthority}protocol/openid-connect/token`
: `${this.oidcAuthority}api-tokens/`;
}

static get oidcKukkuuAPIScope() {
return getEnvOrError(
process.env.REACT_APP_KUKKUU_API_OIDC_SCOPE,
'REACT_APP_KUKKUU_API_OIDC_SCOPE'
);
}

/**
* 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 =
process.env.REACT_APP_OIDC_SERVER_TYPE ?? 'TUNNISTAMO';
if (oidcServerType === 'KEYCLOAK' || oidcServerType === 'TUNNISTAMO') {
return oidcServerType;
}
throw new Error(`Invalid OIDC server type: ${oidcServerType}`);
}
}

// Accept both variable and name so that variable can be correctly replaced
// by build.
// process.env.VAR => value
// process.env["VAR"] => no value
// Name is used to make debugging easier.
function getEnvOrError(variable?: string, name?: string) {
if (!variable) {
throw Error(`Environment variable with name ${name} was not found`);
}
return variable;
}

export default AppConfig;
4 changes: 2 additions & 2 deletions src/domain/authentication/CallbackPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useTranslate, useDataProvider, Loading } from 'react-admin';
import * as Sentry from '@sentry/browser';
import type { User } from 'oidc-client';
import type { User } from 'oidc-client-ts';
import { useNavigate, useLocation } from 'react-router-dom';

import authService from './authService';
Expand Down Expand Up @@ -37,7 +37,7 @@ function CallBackPage() {
if (role === 'none') {
navigate('/unauthorized', { replace: true });
} else {
navigate(getRedirectPath(user.state?.path, pathname), {
navigate(getRedirectPath(user.url_state, pathname), {
replace: true,
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`authService fetchApiToken should call axios.get with the right arguments 1`] = `
exports[`authService fetchApiToken should call axios with the right arguments 1`] = `
Array [
"https://tunnistamo.test.kuva.hel.ninja/api-tokens/",
Object {
"baseURL": "https://tunnistamo.test.kuva.hel.ninja",
"baseURL": "https://tunnistamo.test.kuva.hel.ninja/",
"data": Object {},
"headers": Object {
"Accept": "application/json",
"Authorization": "bearer db237bc3-e197-43de-8c86-3feea4c5f886",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
"method": "post",
},
]
`;
Expand Down
21 changes: 12 additions & 9 deletions src/domain/authentication/__tests__/authService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import axios from 'axios';
import dataProvider from '../../../api/dataProvider';
import authService, { API_TOKEN } from '../authService';
import authorizationService from '../authorizationService';
import AppConfig from '../../application/AppConfig';

jest.mock('axios');

describe('authService', () => {
const userManager = authService.userManager;
const oidcUserKey = `oidc.user:${process.env.REACT_APP_OIDC_AUTHORITY}:${process.env.REACT_APP_OIDC_CLIENT_ID}`;
const oidcUserKey = `oidc.user:${AppConfig.oidcAuthority}:${AppConfig.oidcClientId}`;

beforeEach(() => {
jest.spyOn(dataProvider, 'getMyAdminProfile').mockResolvedValue({});
Expand Down Expand Up @@ -37,7 +38,7 @@ describe('authService', () => {
it('should get API_TOKENS from localStorage', () => {
authService.getToken();

expect(localStorage.getItem).toHaveBeenNthCalledWith(2, API_TOKEN);
expect(localStorage.getItem).toHaveBeenNthCalledWith(1, API_TOKEN);
});
});

Expand Down Expand Up @@ -87,12 +88,14 @@ describe('authService', () => {

authService.login(path);

expect(signinRedirect).toHaveBeenNthCalledWith(1, { data: { path } });
expect(signinRedirect).toHaveBeenNthCalledWith(1, {
url_state: path,
});
});
});

describe('endLogin', () => {
axios.get.mockResolvedValue({ data: {} });
axios.mockResolvedValue({ data: {} });
const access_token = 'db237bc3-e197-43de-8c86-3feea4c5f886';
const mockUser = {
name: 'Penelope Krajcik',
Expand Down Expand Up @@ -235,22 +238,22 @@ describe('authService', () => {
};

beforeEach(() => {
axios.get.mockReset();
axios.mockReset();

axios.get.mockResolvedValue({
axios.mockResolvedValue({
data: {
firstToken: '71ffd52c-5985-46d3-b445-490554f4012a',
secondToken: 'de7c2a83-07f2-46bf-8417-8f648adbc7be',
},
});
});

it('should call axios.get with the right arguments', async () => {
it('should call axios with the right arguments', async () => {
expect.assertions(2);
await authService.fetchApiToken(mockUser);

expect(axios.get).toHaveBeenCalledTimes(1);
expect(axios.get.mock.calls[0]).toMatchSnapshot();
expect(axios).toHaveBeenCalledTimes(1);
expect(axios.mock.calls[0]).toMatchSnapshot();
});

it('should call localStorage.setItem with the right arguments', async () => {
Expand Down
Loading

0 comments on commit edb6872

Please sign in to comment.