Skip to content

Commit

Permalink
Merge pull request #53 from City-of-Helsinki/HP-1778-keycloak
Browse files Browse the repository at this point in the history
HP-1778: update keycloak login
  • Loading branch information
NikoHelle authored Jun 14, 2023
2 parents 595af61 + 3321c8a commit 746631e
Show file tree
Hide file tree
Showing 36 changed files with 724 additions and 532 deletions.
39 changes: 21 additions & 18 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,27 @@ REACT_APP_OIDC_AUTO_SILENT_RENEW="true"
REACT_APP_OIDC_LOGGING="false"
REACT_APP_OIDC_SILENT_AUTH_PATH="/silent_renew.html"
REACT_APP_OIDC_CALLBACK_PATH="/callback"
REACT_APP_API_BACKEND_GRANT_TYPE="urn:ietf:params:oauth:grant-type:uma-ticket"
REACT_APP_API_BACKEND_PERMISSION="https://api.hel.fi/auth/exampleapp-api#readwrite"
REACT_APP_API_BACKEND_AUDIENCE="exampleapp-backend"
REACT_APP_OIDC_TOKEN_EXCHANGE_PATH="/api-tokens/"
REACT_APP_OIDC_EXAMPLE_API_TOKEN_AUDIENCE="https://api.hel.fi/auth/exampleappdev"
REACT_APP_OIDC_PROFILE_API_TOKEN_AUDIENCE="https://api.hel.fi/auth/helsinkiprofiledev"
REACT_APP_OIDC_API_TOKEN_GRANT_TYPE=""
REACT_APP_OIDC_API_TOKEN_PERMISSION=""
REACT_APP_KEYCLOAK_EXAMPLE_API_TOKEN_AUDIENCE="exampleapp-api-dev"
REACT_APP_KEYCLOAK_PROFILE_API_TOKEN_AUDIENCE="profile-api-dev"
REACT_APP_KEYCLOAK_API_TOKEN_GRANT_TYPE="urn:ietf:params:oauth:grant-type:uma-ticket"
REACT_APP_KEYCLOAK_API_TOKEN_PERMISSION="#access"
REACT_APP_KEYCLOAK_URL="https://tunnistus.dev.hel.ninja/auth"
REACT_APP_KEYCLOAK_CLIENT_ID="exampleapp-ui-dev"
REACT_APP_KEYCLOAK_SCOPE="openid profile"
REACT_APP_KEYCLOAK_REALM="helsinki-tunnistus"
REACT_APP_KEYCLOAK_LOGOUT_PATH="/"
REACT_APP_KEYCLOAK_RESPONSE_TYPE="code"
REACT_APP_KEYCLOAK_SILENT_AUTH_PATH="/silent_renew.html"
REACT_APP_KEYCLOAK_CALLBACK_PATH="/callback_kc/"
REACT_APP_KEYCLOAK_AUTO_SIGN_IN="false"
REACT_APP_KEYCLOAK_LOGGING="false"
REACT_APP_KEYCLOAK_AUTO_SILENT_RENEW="true"
REACT_APP_KEYCLOAK_TOKEN_EXCHANGE_PATH="/realms/helsinki-tunnistus/protocol/openid-connect/token"
REACT_APP_BACKEND_URL="https://example-api.dev.hel.ninja/api/v1/myuserdata/"
REACT_APP_PROFILE_BACKEND_URL="https://profiili-api.test.kuva.hel.ninja/graphql/"
REACT_APP_PROFILE_AUDIENCE="https://api.hel.fi/auth/helsinkiprofile"
REACT_APP_PROFILE_UI_URL="https://profiili.test.kuva.hel.ninja"
REACT_APP_PLAIN_SUOMIFI_URL="https://tunnistus.dev.hel.ninja/auth"
REACT_APP_PLAIN_SUOMIFI_CLIENT_ID="exampleapp-ui"
REACT_APP_PLAIN_SUOMIFI_SCOPE="openid profile"
REACT_APP_PLAIN_SUOMIFI_REALM="helsinki-tunnistus"
REACT_APP_PLAIN_SUOMIFI_LOGOUT_PATH="/"
REACT_APP_PLAIN_SUOMIFI_RESPONSE_TYPE="code"
REACT_APP_PLAIN_SUOMIFI_SILENT_AUTH_PATH="/silent_renew.html"
REACT_APP_PLAIN_SUOMIFI_CALLBACK_PATH="/callback_kc/"
REACT_APP_PLAIN_SUOMIFI_AUTO_SIGN_IN="false"
REACT_APP_PLAIN_SUOMIFI_LOGGING="false"
REACT_APP_PLAIN_SUOMIFI_AUTO_SILENT_RENEW="true"
REACT_APP_PLAIN_SUOMIFI_TOKEN_EXCHANGE_PATH=""
REACT_APP_BACKEND_AUDIENCE="https://api.hel.fi/auth/exampleapp"
REACT_APP_BACKEND_URL="https://example-api.dev.hel.ninja/api/v1/myuserdata/"
4 changes: 2 additions & 2 deletions .env.development
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
REACT_APP_OIDC_URL="https://tunnistamo.dev.hel.ninja"
REACT_APP_OIDC_CLIENT_ID="exampleapp-ui"
REACT_APP_OIDC_CLIENT_ID="exampleapp-ui-dev"
REACT_APP_PROFILE_BACKEND_URL="https://profile-api.dev.hel.ninja/graphql/"
REACT_APP_OIDC_SCOPE="openid profile email https://api.hel.fi/auth/helsinkiprofile https://api.hel.fi/auth/exampleapp"
REACT_APP_OIDC_SCOPE="openid profile email https://api.hel.fi/auth/helsinkiprofiledev https://api.hel.fi/auth/exampleappdev"
REACT_APP_OIDC_CALLBACK_PATH="/callback/"
REACT_APP_PROFILE_UI_URL="https://profiili.dev.hel.ninja"
92 changes: 73 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Example-profile-ui

Example UI application handles logins to OIDC provider and loads Helsinki Profile. There are two types of logins: Helsinki-Profiili MVP and plain Suomi.fi. User chooses one on the index page.
Example UI application handles logins to OIDC provider and loads Helsinki Profile. There are two types of logins: Tunnistamo and Helsinki-tunnistus. User chooses one on the index page.

App uses [oidc-react.js](https://github.com/IdentityModel/oidc-client-js/wiki) for all calls to the OIDC provider. Library is wrapped with "client" (client/index.ts) to unify connections to Tunnistamo, Keycloak server and Profiili API.

Expand All @@ -10,20 +10,20 @@ Included in this demo app:
- hooks for easy usage with React
- redux store listening a client
- HOC component listening a client and showing different content for authorized and unauthorized users.
- getting API token and using it to get Profile (only when using Helsinki-Profiili MVP ).
- getting API token and using it to get Profile

Client dispatches events and trigger changes which then trigger re-rendering of the components using the client.

## Config

Configs are in .env -files. Default endpoint for Helsinki-Profiili is Tunnistamo. For Suomi.fi authentication, it is plain Keycloak.
Configs are in .env -files.

Tunnistamo does not support silent login checks (it uses only sessionStorage) so REACT_APP_OIDC_AUTO_SIGN_IN must be 'false'. It renews access tokens so REACT_APP_OIDC_SILENT_AUTH_PATH must be changed to '/' to prevent errors for unknown redirect url.

Config can also be overridden for command line:

```bash
REACT_APP_OIDC_URL=https://foo.bar yarn start
REACT_APP_OIDC_URL="https://foo.bar"
```

### Environment variables
Expand All @@ -35,44 +35,84 @@ actual used variables when running the app. App is not using CRA's default `proc
Note that running built application locally you need to generate also `public/env-config.js` file. It can be done with
`yarn update-runtime-env`. By default it's generated for development environment if no `NODE_ENV` is set.

### Config for Helsinki-Profiili MVP
### Config for Tunnistamo

Settings when using Helsinki-Profiili MVP authentication:
Settings when using Tunnistamo authentication:

```bash
REACT_APP_OIDC_URL="<SERVER_URL>/auth"
REACT_APP_OIDC_REALM="helsinki-tunnistus"
REACT_APP_OIDC_REALM=""
REACT_APP_OIDC_SCOPE="profile"
REACT_APP_OIDC_CLIENT_ID="exampleapp-ui"
```

### Config for plain Suomi.fi
### Config for Helsinki-tunnistus

Settings when using plain Suomi.fi authentication:
Settings when using Helsinki-tunnistus authentication:

```bash
REACT_APP_PLAIN_SUOMIFI_URL="<SERVER_URL>/auth"
REACT_APP_PLAIN_SUOMIFI_REALM="helsinki-tunnistus"
REACT_APP_PLAIN_SUOMIFI_SCOPE="profile"
REACT_APP_PLAIN_SUOMIFI_CLIENT_ID="exampleapp-ui"
REACT_APP_KEYCLOAK_URL="<SERVER_URL>/auth"
REACT_APP_KEYCLOAK_REALM="helsinki-tunnistus"
REACT_APP_KEYCLOAK_SCOPE="profile"
REACT_APP_KEYCLOAK_CLIENT_ID="exampleapp-ui"
```

Keys are the same, but with "\_OIDC\_" replaced by "\_PLAIN_SUOMIFI\_".
Keys are the same, but with "\_OIDC\_" replaced by "\_KEYCLOAK\_".

### Config for getting Profile data (Helsinki-Profiili MVP only)
### Config for getting Profile data

#### Tunnistamo

Use same config as above with Tunnistamo and add

```bash
REACT_APP_OIDC_CLIENT_ID="exampleapp-ui"
REACT_APP_OIDC_SCOPE="openid profile email https://api.hel.fi/auth/helsinkiprofile"
REACT_APP_OIDC_PROFILE_API_TOKEN_AUDIENCE="https://api.hel.fi/auth/helsinkiprofiledev"
```

Tunnistamo does not use these, so leave them empty:

```bash
REACT_APP_OIDC_API_TOKEN_GRANT_TYPE=""
REACT_APP_OIDC_API_TOKEN_PERMISSION=""
```

#### Helsinki-tunnistus

Use same config as above with Helsinki-tunnistus and add

```bash
REACT_APP_KEYCLOAK_SCOPE="openid profile email"
REACT_APP_KEYCLOAK_PROFILE_API_TOKEN_AUDIENCE="https://api.hel.fi/auth/helsinkiprofiledev"
REACT_APP_KEYCLOAK_API_TOKEN_GRANT_TYPE="api token grant type in Helsinki-Tunnistus"
REACT_APP_KEYCLOAK_API_TOKEN_PERMISSION="api token permission in Helsinki-Tunnistus"
```

### Config for getting Example backend data

#### Tunnistamo

When getting api tokens, the Tunnistamo request does not need any props. But audiences are needed when getting the correct token in UI. Note that `REACT_APP_OIDC_SCOPE` must have scopes for the api token audiences when using Tunnistamo.

```bash
REACT_APP_OIDC_EXAMPLE_API_TOKEN_AUDIENCE="api token audience in Tunnistamo"
```

Tunnistamo does not use these, so leave them empty:

```bash
REACT_APP_OIDC_API_TOKEN_GRANT_TYPE=""
REACT_APP_OIDC_API_TOKEN_PERMISSION=""
```

Profile BE url and audience are configured in main .env and there is no need to change them
#### Helsinki-tunnistus

This server uses the audience, grant type and permission.

```bash
REACT_APP_PROFILE_BACKEND_URL="<PROFILE_API_SERVER_URL>/graphql/"
REACT_APP_PROFILE_AUDIENCE="https://api.hel.fi/auth/helsinkiprofile"
REACT_APP_KEYCLOAK_EXAMPLE_API_TOKEN_AUDIENCE="example api token audience in Helsinki-Tunnistus"
REACT_APP_KEYCLOAK_API_TOKEN_GRANT_TYPE="api token grant type in Helsinki-Tunnistus"
REACT_APP_KEYCLOAK_API_TOKEN_PERMISSION="api token permission in Helsinki-Tunnistus"
```

## Docker
Expand Down Expand Up @@ -134,3 +174,17 @@ as `window._env_` object.

Generation uses `react-scripts` internals, so values come from either environment variables or files (according
[react-scripts documentation](https://create-react-app.dev/docs/adding-custom-environment-variables/#what-other-env-files-can-be-used)).

## Logging in locally with Keycloak and using non-chromium browser

Firefox and Safari are stricter with third-party cookies and therefore session checks in iframes fail with Firefox and Safari, when using localhost with Keycloak. Login works, but session checks fail immediately. There are no known issues with Tunnistamo.

Third party cookies are not an issue, when service is deployed and servers have same top level domains like \*.hel.ninja. The problem occurs locally, because http://localhost:3000 is communicating with https://\*.dev.hel.ninja.

More info about Firefox:
https://developer.mozilla.org/en-US/docs/Web/Privacy/Storage_Access_Policy/Errors/CookiePartitionedForeign

Issue can be temporarily resolved with:
https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning#disable_dynamic_state_partitioning

With Safari, go to "Settings" -> "Privacy" -> uncheck "Prevent cross-site tracking"
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "example-ui-profile",
"version": "0.9.0",
"version": "0.9.1",
"license": "MIT",
"private": true,
"dependencies": {
Expand Down
18 changes: 9 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ import config from './config';
import Index from './pages/Index';
import Tokens from './pages/Tokens';
import Header from './components/Header';
import PlainSuomiFiUserInfo from './pages/PlainSuomiFiUserInfo';
import UserInfo from './pages/UserInfo';
import ApiAccessTokens from './pages/ApiAccessTokens';
import ProfilePage from './pages/ProfilePage';
import BackendData from './pages/BackendData';
import LogOut from './pages/LogOut';

function App(): React.ReactElement {
const plainSuomiFiPath = config.plainSuomiFiConfig.path;
const mvpPath = config.mvpConfig.path;
const keycloakPath = config.keycloakConfig.path;
const tunnistamoPath = config.tunnistamoConfig.path;
return (
<ConfigChecker>
<HandleCallback>
Expand All @@ -27,22 +27,22 @@ function App(): React.ReactElement {
<PageContainer>
<Header />
<Switch>
<Route path={[plainSuomiFiPath, mvpPath]} exact>
<Route path={[keycloakPath, tunnistamoPath]} exact>
<Index />
</Route>
<Route path={['/:anyPath/userTokens']} exact>
<Tokens />
</Route>
<Route path={[`${plainSuomiFiPath}/userinfo`]} exact>
<PlainSuomiFiUserInfo />
<Route path={[`/:anyPath/userinfo`]} exact>
<UserInfo />
</Route>
<Route path={[`${mvpPath}/apiAccessTokens`]} exact>
<Route path={[`/:anyPath/apiAccessTokens`]} exact>
<ApiAccessTokens />
</Route>
<Route path={[`${mvpPath}/backend`]} exact>
<Route path={[`/:anyPath/backend`]} exact>
<BackendData />
</Route>
<Route path={[`${mvpPath}/profile`]} exact>
<Route path={[`/:anyPath/profile`]} exact>
<ProfilePage />
</Route>
<Route path={['/authError']} exact>
Expand Down
7 changes: 5 additions & 2 deletions src/apiAccessTokens/__mocks__/useApiAccessTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function resetAndSetMockApiAccessTokensHookData(
Object.assign(mockApiAccessTokensHookData, data);
}

export function useApiAccessTokens(): ApiAccessTokenActions {
export function useApiAccessTokens(audience: string): ApiAccessTokenActions {
return {
getStatus: () => mockApiAccessTokensHookData.status,
getErrorMessage: () => {
Expand All @@ -56,6 +56,9 @@ export function useApiAccessTokens(): ApiAccessTokenActions {
return undefined;
},
fetch: options => Promise.resolve(options),
getTokens: () => mockApiAccessTokensHookData.apiTokens
getToken: () =>
mockApiAccessTokensHookData.apiTokens
? mockApiAccessTokensHookData.apiTokens[audience]
: undefined
} as ApiAccessTokenActions;
}
40 changes: 19 additions & 21 deletions src/apiAccessTokens/__tests__/useApiAccessTokens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ import {
mockApiTokenResponse,
logoutUser,
clearApiTokens,
createApiTokenFetchPayload,
setEnv
createApiTokenFetchPayload
} from '../../tests/client.test.helper';
import { AnyFunction, AnyObject } from '../../common';
import { AnyObject } from '../../common';
import {
useApiAccessTokens,
ApiAccessTokenActions,
Expand All @@ -26,13 +25,13 @@ describe('useApiAccessTokens hook ', () => {
const fetchMock: FetchMock = global.fetch;
const mockMutator = mockMutatorGetterOidc();
const client = getClient();
const testAudience = 'test-audience';
const config = configureClient();
const testAudience = config.profileApiTokenAudience;
let apiTokenActions: ApiAccessTokenActions;
let dom: ReactWrapper;
let restoreEnv: AnyFunction;

const HookTester = (): React.ReactElement => {
apiTokenActions = useApiAccessTokens();
apiTokenActions = useApiAccessTokens(testAudience);
return <div id="api-token-status">{apiTokenActions.getStatus()}</div>;
};

Expand All @@ -51,14 +50,10 @@ describe('useApiAccessTokens hook ', () => {
};

beforeAll(async () => {
restoreEnv = setEnv({
REACT_APP_PROFILE_AUDIENCE: testAudience
});
fetchMock.enableMocks();
await client.init();
});
afterAll(() => {
restoreEnv();
fetchMock.disableMocks();
});
afterEach(() => {
Expand All @@ -85,45 +80,47 @@ describe('useApiAccessTokens hook ', () => {
await act(async () => {
await setUpTest();
await waitFor(() => expect(getApiTokenStatus()).toBe('unauthorized'));
expect(apiTokenActions.getTokens()).toBeUndefined();
expect(apiTokenActions.getToken()).toBeUndefined();
expect(apiTokenActions.getStatus() === 'unauthorized');
const tokens = mockApiTokenResponse();
const tokens = mockApiTokenResponse({ audience: testAudience });
await setUser({});
await waitFor(() => expect(getApiTokenStatus()).toBe('loading'));
await waitFor(() => expect(getApiTokenStatus()).toBe('loaded'));
expect(apiTokenActions.getTokens()).toEqual(tokens);
expect(apiTokenActions.getToken()).toEqual(tokens[testAudience]);
logoutUser(client);
await waitFor(() => expect(getApiTokenStatus()).toBe('unauthorized'));
expect(apiTokenActions.getTokens()).toBeUndefined();
expect(apiTokenActions.getToken()).toBeUndefined();
});
});

it('can be controlled with actions', async () => {
await act(async () => {
await setUpTest();
await waitFor(() => expect(getApiTokenStatus()).toBe('unauthorized'));
expect(apiTokenActions.getTokens()).toBeUndefined();
expect(apiTokenActions.getToken()).toBeUndefined();
expect(apiTokenActions.getStatus() === 'unauthorized');
mockApiTokenResponse({ returnError: true });
await setUser({});
await waitFor(() => expect(getApiTokenStatus()).toBe('error'));
expect(apiTokenActions.getTokens()).toBeUndefined();
const tokens = mockApiTokenResponse();
apiTokenActions.fetch(createApiTokenFetchPayload());
expect(apiTokenActions.getToken()).toBeUndefined();
const tokens = mockApiTokenResponse({ audience: testAudience });
apiTokenActions.fetch(
createApiTokenFetchPayload({ audience: testAudience })
);
await waitFor(() => expect(getApiTokenStatus()).toBe('loaded'));
expect(apiTokenActions.getTokens()).toEqual(tokens);
expect(apiTokenActions.getToken()).toEqual(tokens[testAudience]);
});
});

it('api token is auto fetched when user is authorized', async () => {
await act(async () => {
const tokens = mockApiTokenResponse({ audience: testAudience });
await setUpTest({
user: {}
});
const tokens = mockApiTokenResponse();
await waitFor(() => expect(getApiTokenStatus()).toBe('loading'));
await waitFor(() => expect(getApiTokenStatus()).toBe('loaded'));
expect(apiTokenActions.getTokens()).toEqual(tokens);
expect(apiTokenActions.getToken()).toEqual(tokens[testAudience]);
});
});
it('api tokens are cleared when user logs out', async () => {
Expand All @@ -135,6 +132,7 @@ describe('useApiAccessTokens hook ', () => {
await waitFor(() => expect(getApiTokenStatus()).toBe('loaded'));
logoutUser(client);
await waitFor(() => expect(getApiTokenStatus()).toBe('unauthorized'));
expect(apiTokenActions.getToken()).toBeUndefined();
});
});
});
Loading

0 comments on commit 746631e

Please sign in to comment.