diff --git a/.env b/.env index c2a0f39..e1fbba4 100644 --- a/.env +++ b/.env @@ -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/" diff --git a/.env.development b/.env.development index 9a1ebdc..234f83b 100644 --- a/.env.development +++ b/.env.development @@ -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" diff --git a/README.md b/README.md index 304c7c1..0828b30 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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="/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="/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="/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="/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 @@ -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" diff --git a/package.json b/package.json index a1a6d57..8974b5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "example-ui-profile", - "version": "0.9.0", + "version": "0.9.1", "license": "MIT", "private": true, "dependencies": { diff --git a/src/App.tsx b/src/App.tsx index c0882ae..44b2778 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( @@ -27,22 +27,22 @@ function App(): React.ReactElement {
- + - - + + - + - + - + diff --git a/src/apiAccessTokens/__mocks__/useApiAccessTokens.ts b/src/apiAccessTokens/__mocks__/useApiAccessTokens.ts index 2b484db..998c2d7 100644 --- a/src/apiAccessTokens/__mocks__/useApiAccessTokens.ts +++ b/src/apiAccessTokens/__mocks__/useApiAccessTokens.ts @@ -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: () => { @@ -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; } diff --git a/src/apiAccessTokens/__tests__/useApiAccessTokens.test.tsx b/src/apiAccessTokens/__tests__/useApiAccessTokens.test.tsx index b8d4fcd..7e1e01b 100644 --- a/src/apiAccessTokens/__tests__/useApiAccessTokens.test.tsx +++ b/src/apiAccessTokens/__tests__/useApiAccessTokens.test.tsx @@ -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, @@ -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
{apiTokenActions.getStatus()}
; }; @@ -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(() => { @@ -85,16 +80,16 @@ 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(); }); }); @@ -102,28 +97,30 @@ 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'); 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 () => { @@ -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(); }); }); }); diff --git a/src/apiAccessTokens/__tests__/useAuthorizedApiRequests.test.tsx b/src/apiAccessTokens/__tests__/useAuthorizedApiRequests.test.tsx index a7328cc..8f65757 100644 --- a/src/apiAccessTokens/__tests__/useAuthorizedApiRequests.test.tsx +++ b/src/apiAccessTokens/__tests__/useAuthorizedApiRequests.test.tsx @@ -3,13 +3,11 @@ import { mount, ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { waitFor } from '@testing-library/react'; import { FetchMock } from 'jest-fetch-mock'; -import { AnyFunction } from '../../common'; import { FetchStatus } from '../useApiAccessTokens'; import useAuthorizedApiRequests, { AuthorizedApiActions, AuthorizedRequest } from '../useAuthorizedApiRequests'; -import { ApiAccessTokenProvider } from '../../components/ApiAccessTokenProvider'; import initMockResponses, { MockResponseProps } from '../../tests/backend.test.helper'; @@ -20,11 +18,13 @@ import { resetAndSetMockApiAccessTokensHookData } from '../__mocks__/useApiAccessTokens'; import { getFetchMockLastCallAuthenticationHeader } from '../../tests/common.test.helper'; -import { setEnv, mockApiTokenResponse } from '../../tests/client.test.helper'; +import { mockApiTokenResponse } from '../../tests/client.test.helper'; +import { configureClient } from '../../client/__mocks__'; type TestProps = { autoFetchProp: boolean; data?: string; + audience?: string; }; type TestResponseData = { something: boolean; @@ -36,12 +36,12 @@ jest.mock('../../apiAccessTokens/useApiAccessTokens'); describe('useAuthorizedApiRequests hook ', () => { let authorizedApiActions: AuthorizedApiActions; let dom: ReactWrapper; - let restoreEnv: AnyFunction; let autoFetch = false; let forceUpdate: React.Dispatch>; const mockApiAccessTokensActions = getMockApiAccessTokensHookData(); const fetchMock: FetchMock = global.fetch; - const testAudience = 'test-audience'; + const config = configureClient(); + const testAudience = config.profileApiTokenAudience; const noDataText = 'NO_DATA'; const requestUrl = 'http://localhost/'; const responseData: TestResponseData = { something: true }; @@ -56,7 +56,7 @@ describe('useAuthorizedApiRequests hook ', () => { const req: Request = async p => { const headers = new Headers(); - headers.append('Authorization', `Bearer ${p.apiTokens[testAudience]}`); + headers.append('Authorization', `Bearer ${p.token}`); const fetchResponse = await fetch(requestUrl, { method: 'POST', headers @@ -69,9 +69,16 @@ describe('useAuthorizedApiRequests hook ', () => { authorizedApiActions = useAuthorizedApiRequests< TestResponseData, TestProps - >(req, autoFetch ? { data: { autoFetchProp: true } } : undefined); + >( + req, + autoFetch + ? { + autoFetchProps: { data: { autoFetchProp: true } }, + audience: testAudience + } + : { audience: testAudience } + ); const data = authorizedApiActions.getData(); - const tokens = authorizedApiActions.getTokens(); const [, setNumber] = useState(0); forceUpdate = setNumber; return ( @@ -82,16 +89,11 @@ describe('useAuthorizedApiRequests hook ', () => {
{authorizedApiActions.getRequestStatus()}
{authorizedApiActions.getStatus()}
{data ? JSON.stringify(data) : noDataText}
-
{tokens ? JSON.stringify(tokens) : noDataText}
); }; - const TestWrapper = (): React.ReactElement => ( - - - - ); + const TestWrapper = (): React.ReactElement => ; const setUpTest = async ( props: { @@ -151,13 +153,9 @@ describe('useAuthorizedApiRequests hook ', () => { }); beforeAll(async () => { - restoreEnv = setEnv({ - REACT_APP_PROFILE_AUDIENCE: testAudience - }); fetchMock.enableMocks(); }); afterAll(() => { - restoreEnv(); fetchMock.disableMocks(); }); afterEach(() => { @@ -188,7 +186,7 @@ describe('useAuthorizedApiRequests hook ', () => { expect(authHeader).toBe(`Bearer ${validTokens[testAudience]}`); expect(requestTracker).toHaveBeenCalledTimes(1); expect(requestTracker).lastCalledWith({ - apiTokens: validTokens, + token: validTokens[testAudience], ...autoFetchProp }); }); @@ -226,7 +224,7 @@ describe('useAuthorizedApiRequests hook ', () => { expect(getDataFromDom()).toEqual(responseData); expect(requestTracker).toHaveBeenCalledTimes(1); expect(requestTracker).lastCalledWith({ - apiTokens: validTokens, + token: validTokens[testAudience], ...firstCallProps }); authorizedApiActions.request(); @@ -237,7 +235,7 @@ describe('useAuthorizedApiRequests hook ', () => { ); expect(requestTracker).toHaveBeenCalledTimes(2); expect(requestTracker).lastCalledWith({ - apiTokens: validTokens + token: validTokens[testAudience] }); }); }); diff --git a/src/apiAccessTokens/useApiAccessTokens.ts b/src/apiAccessTokens/useApiAccessTokens.ts index 519ff59..17715ca 100644 --- a/src/apiAccessTokens/useApiAccessTokens.ts +++ b/src/apiAccessTokens/useApiAccessTokens.ts @@ -1,5 +1,10 @@ import { useEffect, useState, useCallback, createContext } from 'react'; -import { FetchApiTokenOptions, FetchError, JWTPayload } from '../client/index'; +import { + FetchApiTokenOptions, + FetchError, + JWTPayload, + getClientConfig +} from '../client/index'; import { useClient } from '../client/hooks'; export type FetchStatus = @@ -16,19 +21,21 @@ export type ApiAccessTokenActions = { fetch: (options: FetchApiTokenOptions) => Promise; getStatus: () => FetchStatus; getErrorMessage: () => string | undefined; - getTokens: () => JWTPayload | undefined; + getToken: () => string | undefined; }; export const ApiAccessTokenActionsContext = createContext( null ); -export function useApiAccessTokens(): ApiAccessTokenActions { +export function useApiAccessTokens(audience: string): ApiAccessTokenActions { const client = useClient(); - const tokens = client.isAuthenticated() ? client.getApiTokens() : undefined; - const hasTokens = tokens && Object.keys(tokens).length; + const config = getClientConfig(); + const apiToken = client.isAuthenticated() + ? client.getApiToken(audience) + : undefined; const [apiTokens, setApiTokens] = useState( - hasTokens ? tokens : undefined + apiToken ? { [audience]: apiToken } : undefined ); const resolveStatus = (): FetchStatus => { @@ -63,12 +70,13 @@ export function useApiAccessTokens(): ApiAccessTokenActions { async options => { setStatus('loading'); const result = await client.getApiAccessToken(options); - if (result.error) { + if ((result as FetchError).error) { + const resultAsError = result as FetchError; setStatus('error'); setError( - result.message - ? new Error(`${result.message} ${result.status}`) - : result.error + resultAsError.message + ? new Error(`${resultAsError.message} ${resultAsError.status}`) + : resultAsError.error ); } else { setError(undefined); @@ -85,15 +93,12 @@ export function useApiAccessTokens(): ApiAccessTokenActions { if (currentStatus !== 'ready') { return; } - fetchTokens({ - audience: String(window._env_.REACT_APP_API_BACKEND_AUDIENCE), - permission: String(window._env_.REACT_APP_API_BACKEND_PERMISSION), - grantType: String(window._env_.REACT_APP_API_BACKEND_GRANT_TYPE) - }); + + fetchTokens({ audience }); }; autoFetch(); - }, [fetchTokens, currentStatus]); + }, [fetchTokens, currentStatus, audience, config]); return { getStatus: () => status, getErrorMessage: () => { @@ -109,6 +114,6 @@ export function useApiAccessTokens(): ApiAccessTokenActions { return undefined; }, fetch: options => fetchTokens(options), - getTokens: () => apiTokens - } as ApiAccessTokenActions; + getToken: () => (apiTokens ? apiTokens[audience] : undefined) + }; } diff --git a/src/apiAccessTokens/useAuthorizedApiRequests.ts b/src/apiAccessTokens/useAuthorizedApiRequests.ts index 90e25e8..b1dfb8c 100644 --- a/src/apiAccessTokens/useAuthorizedApiRequests.ts +++ b/src/apiAccessTokens/useAuthorizedApiRequests.ts @@ -1,17 +1,20 @@ -import { useState, useContext, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import to from 'await-to-js'; -import { ApiAccessTokenContext } from '../components/ApiAccessTokenProvider'; -import { FetchStatus } from './useApiAccessTokens'; -import { JWTPayload } from '../client'; +import { FetchStatus, useApiAccessTokens } from './useApiAccessTokens'; export type RequestProps

= { data?: P; }; +export type Props

= { + autoFetchProps?: RequestProps

; + audience: string; +}; + export type AuthorizedRequestProps

= { data?: P; - apiTokens: JWTPayload; + token: string; }; export type AuthorizedRequest = ( @@ -24,26 +27,23 @@ export type AuthorizedApiActions = { getRequestStatus: () => FetchStatus; getApiTokenError: () => string | undefined; getRequestError: () => string | undefined; - request: (props?: RequestProps

) => Promise; + request: ( + props?: Omit, 'audience'> + ) => Promise; getData: () => R | undefined; - getTokens: () => JWTPayload | undefined; clear: () => void; }; export default function useAuthorizedApiRequests( authorizedRequest: AuthorizedRequest, - autoFetchProps?: RequestProps

+ props: Props

): AuthorizedApiActions { - const actions = useContext(ApiAccessTokenContext); - if (!actions) { - throw new Error( - 'ApiAccessTokenActions not provided from ApiAccessTokenContext. Provide context in React Components with ApiAccessTokenProvider.' - ); - } + const { autoFetchProps, audience } = props; + const actions = useApiAccessTokens(audience); const { getStatus: getApiAccessTokenStatus, getErrorMessage: getApiTokenErrorMessage, - getTokens + getToken } = actions; const [requestStatus, setRequestStatus] = useState('waiting'); const [result, setResult] = useState(); @@ -81,10 +81,13 @@ export default function useAuthorizedApiRequests( } const requestWrapper: AuthorizedApiActions['request'] = useCallback( - async props => { + async wrapperProps => { setRequestStatus('loading'); const [err, data] = await to( - authorizedRequest({ ...props, apiTokens: getTokens() as JWTPayload }) + authorizedRequest({ + ...wrapperProps, + token: getToken() as string + }) ); if (err) { setRequestStatus('error'); @@ -97,7 +100,7 @@ export default function useAuthorizedApiRequests( setError(undefined); return data as R; }, - [authorizedRequest, getTokens] + [authorizedRequest, getToken] ); useEffect(() => { @@ -117,7 +120,6 @@ export default function useAuthorizedApiRequests( getRequestStatus: () => requestStatus, getApiTokenError: () => getApiTokenErrorMessage(), getData: () => result, - getTokens: () => getTokens(), getRequestError: () => { if (!error) { return undefined; @@ -134,12 +136,12 @@ export default function useAuthorizedApiRequests( setResult(undefined); setError(undefined); }, - request: props => { + request: requestProps => { if (getApiAccessTokenStatus() !== 'loaded') { setError(new Error('Api tokens are not fetched.')); return Promise.resolve({} as R); } - return requestWrapper(props); + return requestWrapper(requestProps); } }; } diff --git a/src/backend/__tests__/backend.hook.test.tsx b/src/backend/__tests__/backend.hook.test.tsx index c809fdf..7cb6987 100644 --- a/src/backend/__tests__/backend.hook.test.tsx +++ b/src/backend/__tests__/backend.hook.test.tsx @@ -4,7 +4,6 @@ import { act } from 'react-dom/test-utils'; import { waitFor } from '@testing-library/react'; import { FetchMock } from 'jest-fetch-mock'; import { setEnv } from '../../tests/client.test.helper'; -import { ApiAccessTokenProvider } from '../../components/ApiAccessTokenProvider'; import { useBackendWithApiTokens, BackendActions } from '../backend'; import { AnyFunction } from '../../common'; @@ -18,13 +17,14 @@ import { FetchStatus } from '../../apiAccessTokens/useApiAccessTokens'; import initMockResponses, { MockResponseProps } from '../../tests/backend.test.helper'; +import { configureClient } from '../../client/__mocks__'; jest.mock('../../apiAccessTokens/useApiAccessTokens'); describe('backend.ts useBackendWithApiTokens hook ', () => { const mockApiAccessTokensActions = getMockApiAccessTokensHookData(); const fetchMock: FetchMock = global.fetch; - const testAudience = 'api-audience'; + configureClient(); const backendUrl = 'https://localhost/'; const validResponseData = { pet_name: 'petName' @@ -43,9 +43,9 @@ describe('backend.ts useBackendWithApiTokens hook ', () => { }; const TestWrapper = (): React.ReactElement => ( - + <> - + ); const setUpTest = async ( @@ -83,7 +83,6 @@ describe('backend.ts useBackendWithApiTokens hook ', () => { beforeAll(async () => { restoreEnv = setEnv({ - REACT_APP_BACKEND_AUDIENCE: testAudience, REACT_APP_BACKEND_URL: backendUrl }); fetchMock.enableMocks(); diff --git a/src/backend/__tests__/backend.test.ts b/src/backend/__tests__/backend.test.ts index bd24d93..5dcc2a3 100644 --- a/src/backend/__tests__/backend.test.ts +++ b/src/backend/__tests__/backend.test.ts @@ -1,7 +1,7 @@ import { FetchMock } from 'jest-fetch-mock'; import { setEnv } from '../../tests/client.test.helper'; import { AnyFunction } from '../../common'; -import { getBackendApiToken, executeAPIAction } from '../backend'; +import { executeAPIAction } from '../backend'; import { getFetchMockLastCallAuthenticationHeader, getFetchMockLastCall @@ -11,11 +11,9 @@ import initMockResponses from '../../tests/backend.test.helper'; describe('Backend.ts ', () => { let restoreEnv: AnyFunction; const fetchMock: FetchMock = global.fetch; - const testAudience = 'api-audience'; const backendUrl = 'https://localhost/'; const responseData = { pet_name: 'petName' }; const usersAPiToken = 'valid-api-token'; - const validAPiTokens = { [testAudience]: usersAPiToken }; const setRequestMockResponse = initMockResponses( fetchMock, backendUrl, @@ -24,7 +22,6 @@ describe('Backend.ts ', () => { beforeAll(async () => { restoreEnv = setEnv({ - REACT_APP_BACKEND_AUDIENCE: testAudience, REACT_APP_BACKEND_URL: backendUrl }); fetchMock.enableMocks(); @@ -39,15 +36,9 @@ describe('Backend.ts ', () => { fetchMock.resetMocks(); }); - it('getBackendApiToken() returns api token or undefined if api token is not set', async () => { - const token = getBackendApiToken({}); - expect(token).toBeUndefined(); - expect(getBackendApiToken(validAPiTokens)).toEqual('valid-api-token'); - }); - it('calling executeApiAction() adds apiToken to headers. Requests is sent to backend with method "GET" when data is not provided', async () => { setRequestMockResponse(); - const res = await executeAPIAction({ apiTokens: validAPiTokens }); + const res = await executeAPIAction({ token: usersAPiToken }); expect(res).toEqual(responseData); const authHeader = getFetchMockLastCallAuthenticationHeader(fetchMock); expect(authHeader).toBe(`Bearer ${usersAPiToken}`); @@ -56,7 +47,7 @@ describe('Backend.ts ', () => { it('calling executeApiAction() with data-property changes method to "PUT" and sends the data with the request', async () => { setRequestMockResponse(); const data = { pet_name: 'bar' }; - await executeAPIAction({ data, apiTokens: validAPiTokens }); + await executeAPIAction({ data, token: usersAPiToken }); const lastCallRequestInit = getFetchMockLastCall(fetchMock)[1]; expect(lastCallRequestInit?.method).toBe('PUT'); expect(lastCallRequestInit?.body).toBe(JSON.stringify(data)); @@ -65,14 +56,14 @@ describe('Backend.ts ', () => { it('executeApiAction() throws an error when request fails', async () => { setRequestMockResponse({ return401: true }); await expect(async () => { - await executeAPIAction({ apiTokens: validAPiTokens }); + await executeAPIAction({ token: usersAPiToken }); }).rejects.toThrow(); }); it('executeApiAction() throws an error when json is malformed', async () => { setRequestMockResponse({ causeException: true }); await expect(async () => { - await executeAPIAction({ apiTokens: validAPiTokens }); + await executeAPIAction({ token: usersAPiToken }); }).rejects.toThrow(); }); }); diff --git a/src/backend/backend.ts b/src/backend/backend.ts index 81529d8..6a22fcb 100644 --- a/src/backend/backend.ts +++ b/src/backend/backend.ts @@ -4,7 +4,7 @@ import useAuthorizedApiRequests, { AuthorizedRequest, AuthorizedApiActions } from '../apiAccessTokens/useAuthorizedApiRequests'; -import { JWTPayload } from '../client'; +import { getClientConfig } from '../client'; // eslint-disable-next-line camelcase export type ReturnData = { pet_name: string }; @@ -13,21 +13,11 @@ type FetchProps = ReturnData | undefined; type Request = AuthorizedRequest; export type BackendActions = AuthorizedApiActions; -export function getBackendApiToken(apiTokens: JWTPayload): string | undefined { - const tokenKey = window._env_.REACT_APP_BACKEND_AUDIENCE; - if (!tokenKey) { - return undefined; - } - return apiTokens && apiTokens[tokenKey]; -} - export const executeAPIAction: Request = async options => { const myHeaders = new Headers(); - myHeaders.append( - 'Authorization', - `Bearer ${getBackendApiToken(options.apiTokens)}` - ); + myHeaders.append('Authorization', `Bearer ${options.token}`); myHeaders.append('Content-Type', 'application/json'); + myHeaders.delete('pragma'); const requestOptions: RequestInit = { method: 'GET', headers: myHeaders @@ -61,6 +51,9 @@ export function useBackendWithApiTokens(): AuthorizedApiActions< FetchProps > { const req: Request = useCallback(async props => executeAPIAction(props), []); - - return useAuthorizedApiRequests(req, {}); + const config = getClientConfig(); + return useAuthorizedApiRequests(req, { + audience: config.exampleApiTokenAudience, + autoFetchProps: {} + }); } diff --git a/src/client/__mocks__/index.ts b/src/client/__mocks__/index.ts index b263857..e5e5507 100644 --- a/src/client/__mocks__/index.ts +++ b/src/client/__mocks__/index.ts @@ -134,7 +134,8 @@ export const matchClientDataWithComponent = ( export const configureClient = ( overrides?: Partial -): ClientConfig => setClientConfig({ ...config.mvpConfig, ...overrides }); +): ClientConfig => + setClientConfig({ ...config.tunnistamoConfig, ...overrides }); export const createEventListeners = ( addEventListener: ListenerSetter @@ -218,7 +219,7 @@ export const mockMutatorCreator = (): MockMutator => { loadProfileRejectPayload; const setUserToSessionStorage = (data: AnyObject | string) => { - const key = getSessionStorageKey(config.mvpConfig); + const key = getSessionStorageKey(config.tunnistamoConfig); sessionStorage.setItem( key, typeof data === 'object' ? JSON.stringify(data) : data diff --git a/src/client/__tests__/index.test.ts b/src/client/__tests__/index.test.ts index 90f39b9..1d3a35e 100644 --- a/src/client/__tests__/index.test.ts +++ b/src/client/__tests__/index.test.ts @@ -11,7 +11,8 @@ import { FetchApiTokenConfiguration, getTokenUri, getClientConfig, - FetchError + FetchError, + JWTPayload } from '../index'; import { configureClient } from '../__mocks__'; import { AnyFunction, AnyObject } from '../../common'; @@ -19,7 +20,7 @@ import { AnyFunction, AnyObject } from '../../common'; describe('Client factory ', () => { let client: ClientFactory; const fetchMock: FetchMock = global.fetch; - configureClient(); + const config = configureClient(); beforeEach(() => { client = createClient(); }); @@ -51,22 +52,18 @@ describe('Client factory ', () => { const storedUser = client.getStoredUser() || {}; expect(storedUser.name).toMatch(user.name); }); - it('getApiTokens returns stored apiTokens. addApiTokens adds and removeApiToken removes', () => { - let apiTokens = client.getApiTokens(); - expect( - typeof apiTokens === 'object' && Object.keys(apiTokens).length === 0 - ).toBeTruthy(); + it('getApiToken returns stored apiTokens. addApiTokens adds and removeApiToken removes', () => { + expect(client.getApiToken('token1')).toBeUndefined(); const token1And2 = { token1: 'token1', token2: 'token2' }; client.addApiTokens(token1And2); - expect(client.getApiTokens()).toEqual(token1And2); const token3 = { token3: 'token3' }; client.addApiTokens(token3); - expect(client.getApiTokens()).toEqual({ ...token1And2, ...token3 }); + expect(client.getApiToken('token1')).toBe(token1And2.token1); + expect(client.getApiToken('token2')).toBe(token1And2.token2); + expect(client.getApiToken('token3')).toBe(token3.token3); client.removeApiToken('token2'); - apiTokens = client.getApiTokens(); - expect(apiTokens.token1).toBe(token1And2.token1); - expect(apiTokens.token2).toBe(undefined); - expect(apiTokens.token3).toBe(token3.token3); + expect(client.getApiToken('token2')).toBeUndefined(); + expect(client.getApiToken('token1')).toBe(token1And2.token1); }); }); describe('isInitialized and isAuthenticated reflect status changes ', () => { @@ -185,27 +182,35 @@ describe('Client factory ', () => { beforeAll(() => fetchMock.enableMocks()); afterAll(() => fetchMock.disableMocks()); afterEach(() => fetchMock.resetMocks()); + const createOkResponse = (responseBody: string | AnyObject) => ({ + status: 200, + body: + typeof responseBody === 'string' + ? responseBody + : JSON.stringify(responseBody) + }); + const apiTokenResponseBody = { + access_token: 'returned-accessToken', + data: true + }; + const apiTokenResponse = createOkResponse(apiTokenResponseBody); + const fetchConfig: FetchApiTokenConfiguration = { uri: getTokenUri({ ...getClientConfig(), realm: 'realm-value' }), accessToken: 'accessToken-value', - audience: 'audience-value', - permission: 'permission-value', - grantType: 'grantType-value' + audience: 'audience-value' }; let lastRequest: Request; - it('where access token is added to headers and given options are in body', async () => { - const responseData = { - status: 200, - body: JSON.stringify({ access_token: 'returned-accessToken' }) - }; + it('where access token is added to headers and audience is in the body. GrantType and permission are not added to body, if not set', async () => { fetchMock.mockIf(fetchConfig.uri, req => { lastRequest = req; - return Promise.resolve(responseData); + return Promise.resolve(apiTokenResponse); }); - + config.apiGrantType = ''; + config.apiPermission = ''; await client.fetchApiToken(fetchConfig); const requestData = new URLSearchParams(await lastRequest.text()); expect( @@ -214,24 +219,34 @@ describe('Client factory ', () => { ?.includes(fetchConfig.accessToken) ).toBe(true); expect(requestData.get('audience')).toBe(fetchConfig.audience); - expect(requestData.get('permission')).toBe(fetchConfig.permission); - expect(requestData.get('grant_type')).toBe(fetchConfig.grantType); + expect(requestData.get('permission')).toBeNull(); + expect(requestData.get('grant_type')).toBeNull(); + }); + it('where grantType and permission are added to body, if set', async () => { + fetchMock.mockIf(fetchConfig.uri, req => { + lastRequest = req; + return Promise.resolve(apiTokenResponse); + }); + config.apiGrantType = 'apiGrantType'; + config.apiPermission = 'apiPermission'; + await client.fetchApiToken(fetchConfig); + const requestData = new URLSearchParams(await lastRequest.text()); + expect(requestData.get('audience')).toBe(fetchConfig.audience); + expect(requestData.get('permission')).toBe(config.apiPermission); + expect(requestData.get('grant_type')).toBe(config.apiGrantType); }); it('and data from server is returned to caller and stored to client', async () => { - const responseBody = { - access_token: 'returned-accessToken', - data: true - }; - const responseData = { - status: 200, - body: JSON.stringify(responseBody) - }; - fetchMock.mockIf(fetchConfig.uri, () => Promise.resolve(responseData)); + fetchMock.mockIf(fetchConfig.uri, () => + Promise.resolve(apiTokenResponse) + ); const returnedData = await client.fetchApiToken(fetchConfig); - expect(returnedData).toEqual(responseBody); - const storedData = client.getApiTokens(); - expect(storedData).toEqual(returnedData); + const assumedTokenData = { + [fetchConfig.audience]: apiTokenResponseBody.access_token + }; + expect(returnedData).toEqual(assumedTokenData); + const storedData = client.getApiToken(fetchConfig.audience); + expect(storedData).toEqual(assumedTokenData[fetchConfig.audience]); }); it('and server side error is handled', async () => { const responseData = { @@ -256,10 +271,7 @@ describe('Client factory ', () => { } }); it('and json parse error is handled', async () => { - const responseData = { - status: 200, - body: '{a:invalidjson}' - }; + const responseData = createOkResponse('{a:invalidjson}'); fetchMock.mockIf(fetchConfig.uri, () => Promise.resolve(responseData)); const response = (await client.fetchApiToken(fetchConfig)) as FetchError; expect(response.error).toBeDefined(); @@ -268,5 +280,90 @@ describe('Client factory ', () => { expect(response.error.message).toBeDefined(); } }); + it('if apiToken response includes multiple tokens, all tokens are stored and not fetched again.', async () => { + const responseBody = { + [config.exampleApiTokenAudience]: 'exampleApiToken', + [config.profileApiTokenAudience]: 'profileApiToken' + }; + fetchMock.mockIf(fetchConfig.uri, () => + Promise.resolve(createOkResponse(responseBody)) + ); + + const exampleTokenConfig = { + ...fetchConfig, + audience: config.exampleApiTokenAudience + }; + const exampleApiTokens = await client.fetchApiToken(exampleTokenConfig); + + const assumedTokenData = { + [exampleTokenConfig.audience]: responseBody[exampleTokenConfig.audience] + }; + + expect(exampleApiTokens).toEqual(assumedTokenData); + const storedData = client.getApiToken(exampleTokenConfig.audience); + expect(storedData).toEqual(assumedTokenData[exampleTokenConfig.audience]); + expect(fetchMock).toHaveBeenCalledTimes(1); + const profileTokenConfig = { + ...fetchConfig, + audience: config.profileApiTokenAudience + }; + + const profileApiTokens = await client.fetchApiToken(profileTokenConfig); + expect(profileApiTokens).toEqual({ + [profileTokenConfig.audience]: responseBody[profileTokenConfig.audience] + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('Api tokens can also be an object with an "access_token" property. In this case there cannot be multiple tokens in the response.', async () => { + const exampleApiTokenBody = { + access_token: 'exampleApiToken' + }; + fetchMock.mockIf(fetchConfig.uri, () => + Promise.resolve(createOkResponse(exampleApiTokenBody)) + ); + + const exampleTokenConfig = { + ...fetchConfig, + audience: config.exampleApiTokenAudience + }; + const exampleApiTokens = (await client.fetchApiToken( + exampleTokenConfig + )) as JWTPayload; + + expect(exampleApiTokens[exampleTokenConfig.audience]).toEqual( + exampleApiTokenBody.access_token + ); + expect(client.getApiToken(exampleTokenConfig.audience)).toEqual( + exampleApiTokenBody.access_token + ); + + // get another token + const profileApiTokenBody = { + access_token: 'profileApiToken' + }; + fetchMock.mockIf(fetchConfig.uri, () => + Promise.resolve(createOkResponse(profileApiTokenBody)) + ); + + const profileTokenConfig = { + ...fetchConfig, + audience: config.profileApiTokenAudience + }; + expect(client.getApiToken(profileTokenConfig.audience)).toBeUndefined(); + const profileApiTokens = (await client.fetchApiToken( + profileTokenConfig + )) as JWTPayload; + + expect(profileApiTokens[profileTokenConfig.audience]).toEqual( + profileApiTokenBody.access_token + ); + expect(client.getApiToken(profileTokenConfig.audience)).toEqual( + profileApiTokenBody.access_token + ); + // previous apiToken still exists + expect(client.getApiToken(exampleTokenConfig.audience)).toEqual( + exampleApiTokenBody.access_token + ); + }); }); }); diff --git a/src/client/__tests__/oidc-react.test.ts b/src/client/__tests__/oidc-react.test.ts index d5bc5d3..8d664db 100644 --- a/src/client/__tests__/oidc-react.test.ts +++ b/src/client/__tests__/oidc-react.test.ts @@ -1,5 +1,8 @@ import to from 'await-to-js'; import { UserManager } from 'oidc-client'; +import { waitFor } from '@testing-library/react'; +import { FetchMock } from 'jest-fetch-mock'; + import { EventListeners, configureClient, @@ -22,7 +25,7 @@ import { getHttpPollerMockData } from '../__mocks__/http-poller'; describe('Oidc client ', () => { let client: Client; - configureClient(); + const clientConfig = configureClient(); const mockMutator = mockMutatorGetterOidc(); let eventListeners: EventListeners; let instance: UserManager; @@ -104,13 +107,20 @@ describe('Oidc client ', () => { triggerEvent('accessTokenExpiring'); expect(eventListeners.getCallCount(ClientEvent.TOKEN_EXPIRING)).toBe(1); }); - it('userLoaded triggers CLIENT_AUTH_SUCCESS event', async () => { - expect(eventListeners.getCallCount(ClientEvent.CLIENT_AUTH_SUCCESS)).toBe( - 0 - ); - triggerEvent('userLoaded'); - expect(eventListeners.getCallCount(ClientEvent.CLIENT_AUTH_SUCCESS)).toBe( - 1 + it('userLoaded triggers USER_CHANGED event', async () => { + mockMutator.setUser(mockMutator.createValidUserData()); + await to(client.init()); + const validUserData = { + profile: { + ...mockMutator.getTokenParsed() + }, + access_token: 'new_token_for_USER_CHANGED' + }; + expect(eventListeners.getCallCount(ClientEvent.USER_CHANGED)).toBe(0); + triggerEvent('userLoaded', validUserData); + expect(eventListeners.getCallCount(ClientEvent.USER_CHANGED)).toBe(1); + expect((client.getUserTokens() as AnyObject).accessToken).toBe( + validUserData.access_token ); }); }); @@ -122,6 +132,17 @@ describe('Oidc client ', () => { clearTests(); }); + beforeAll(async () => { + const fetchMock: FetchMock = global.fetch; + fetchMock.enableMocks(); + }); + afterAll(() => { + fetchMock.disableMocks(); + }); + afterEach(() => { + fetchMock.resetMocks(); + }); + it('and returns always same promise and can be called multiple times ', async () => { expect(client.getStatus()).toBe(ClientStatus.NONE); const promise1 = client.handleCallback(); @@ -137,6 +158,48 @@ describe('Oidc client ', () => { expect(eventListeners.getCallCount(ClientEvent.AUTHORIZED)).toBe(1); expect(eventListeners.getCallCount(ClientEvent.UNAUTHORIZED)).toBe(0); }); + it('apiToken renewal is triggered after user is logged in and renewed', async () => { + const initialToken = 'initialToken'; + const renewedToken = 'initialToken'; + client.addApiTokens({ + [clientConfig.exampleApiTokenAudience]: initialToken, + [clientConfig.profileApiTokenAudience]: initialToken + }); + fetchMock.doMock(async req => { + const urlParams = await req.text(); + const audience = new URLSearchParams(urlParams).get( + 'audience' + ) as string; + return new Promise(resolve => + setTimeout(async () => { + resolve({ + status: 200, + body: JSON.stringify({ + [audience]: renewedToken + }) + }); + // eslint-disable-next-line no-magic-numbers + }, 20) + ); + }); + await client.handleCallback(); + expect(fetchMock).toHaveBeenCalledTimes(0); + expect(fetchMock.mock.results).toHaveLength(0); + triggerEvent('userLoaded', mockMutator.createValidUserData()); + await waitFor(() => { + expect(fetchMock.mock.results).toHaveLength(2); + expect(client.getApiToken(clientConfig.exampleApiTokenAudience)).toBe( + renewedToken + ); + expect(client.getApiToken(clientConfig.profileApiTokenAudience)).toBe( + renewedToken + ); + }); + triggerEvent('userLoaded', mockMutator.createValidUserData()); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + }); it('failure results in UNAUTHORIZED status', async () => { expect(client.getStatus()).toBe(ClientStatus.NONE); mockMutator.setClientInitPayload(undefined, { error: 1 }); @@ -154,7 +217,7 @@ describe('Oidc client ', () => { }); describe('setting autoSignIn=false ', () => { beforeEach(() => { - setClientConfig({ ...config.mvpConfig, autoSignIn: false }); + setClientConfig({ ...config.tunnistamoConfig, autoSignIn: false }); initTests(); }); afterEach(() => { diff --git a/src/client/index.ts b/src/client/index.ts index 11a92e2..51cc6c3 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -34,9 +34,9 @@ export type Client = { getApiAccessToken: ( options: FetchApiTokenOptions ) => Promise; - getApiTokens: () => JWTPayload; + getApiToken: (audience: string) => string | undefined; addApiTokens: (newToken: JWTPayload) => JWTPayload; - removeApiToken: (name: string) => JWTPayload; + removeApiToken: (audience: string) => JWTPayload; getUserTokens: () => Record | undefined; }; @@ -50,9 +50,7 @@ export const ClientStatus = { export type ClientStatusId = typeof ClientStatus[keyof typeof ClientStatus]; export type FetchApiTokenOptions = { - grantType: string; audience: string; - permission: string; }; export type FetchApiTokenConfiguration = FetchApiTokenOptions & { @@ -73,7 +71,7 @@ export const ClientEvent = { STATUS_CHANGE: 'STATUS_CHANGE', AUTHORIZATION_TERMINATED: 'AUTHORIZATION_TERMINATED', LOGGING_OUT: 'LOGGING_OUT', - CLIENT_AUTH_SUCCESS: 'CLIENT_AUTH_SUCCESS', + USER_CHANGED: 'USER_CHANGED', ...ClientStatus } as const; @@ -149,14 +147,26 @@ export interface ClientConfig { * path prefix for this config type */ path: string; - /** - * does the server, this config is for, provide api tokens - */ - hasApiTokenSupport: boolean; /** * label of this config shown in the UI */ label: string; + /** + * api token audience for example API + */ + exampleApiTokenAudience: string; + /** + * api token audience for profile API + */ + profileApiTokenAudience: string; + /** + * grantType sent to the api token server when getting tokens + */ + apiGrantType: string; + /** + * permission sent to the api token server when getting tokens + */ + apiPermission: string; } type EventHandlers = { @@ -178,7 +188,7 @@ export type ClientFactory = { fetchApiToken: ( options: FetchApiTokenConfiguration ) => Promise; - getApiTokens: Client['getApiTokens']; + getApiToken: Client['getApiToken']; addApiTokens: Client['addApiTokens']; removeApiToken: Client['removeApiToken']; } & EventHandlers; @@ -262,25 +272,61 @@ export function createClient(): ClientFactory { return true; }; - const getApiTokens: ClientFactory['getApiTokens'] = () => tokenStorage; + const getApiToken: ClientFactory['getApiToken'] = audience => + tokenStorage[audience]; + const addApiTokens: ClientFactory['addApiTokens'] = newToken => { Object.assign(tokenStorage, newToken); return tokenStorage; }; - const removeApiToken: ClientFactory['removeApiToken'] = name => { - delete tokenStorage[name]; + + const removeApiToken: ClientFactory['removeApiToken'] = audience => { + delete tokenStorage[audience]; return tokenStorage; }; + const saveReturnedApiTokens = ( + tokenData: JWTPayload, + audience: string + ): JWTPayload => { + const isSingleTokenResponse = !!tokenData['access_token']; + const storageValue = isSingleTokenResponse + ? tokenData['access_token'] + : tokenData[audience]; + + const storageData = { [audience]: storageValue } as JWTPayload; + addApiTokens(storageData); + if (!isSingleTokenResponse) { + Object.keys(tokenData).forEach(currentKey => { + if (currentKey === audience) { + return; + } + const token = tokenData[currentKey]; + if (token) { + addApiTokens({ [currentKey]: token }); + } + }); + } + return storageData; + }; + const fetchApiToken: ClientFactory['fetchApiToken'] = async options => { + const currentToken = getApiToken(options.audience); + if (currentToken) { + return Promise.resolve({ [options.audience]: currentToken }); + } const myHeaders = new Headers(); myHeaders.append('Authorization', `Bearer ${options.accessToken}`); myHeaders.append('Content-Type', 'application/x-www-form-urlencoded'); const urlencoded = new URLSearchParams(); - urlencoded.append('grant_type', options.grantType); urlencoded.append('audience', options.audience); - urlencoded.append('permission', options.permission); + if (config.apiGrantType) { + urlencoded.append('grant_type', config.apiGrantType); + } + if (config.apiPermission) { + urlencoded.append('permission', config.apiPermission); + } const requestOptions = { method: 'POST', @@ -312,9 +358,7 @@ export function createClient(): ClientFactory { message: 'Returned data is not valid json' } as FetchError; } - const jwt = json as JWTPayload; - addApiTokens(jwt); - return jwt; + return saveReturnedApiTokens(json, options.audience); }; return { @@ -329,7 +373,7 @@ export function createClient(): ClientFactory { isInitialized, isAuthenticated, fetchApiToken, - getApiTokens, + getApiToken, addApiTokens, removeApiToken }; diff --git a/src/client/oidc-react.ts b/src/client/oidc-react.ts index 4d87c26..a61fda8 100644 --- a/src/client/oidc-react.ts +++ b/src/client/oidc-react.ts @@ -41,9 +41,9 @@ function bindEvents( } ): void { const { onAuthChange, setError, eventTrigger } = eventFunctions; - manager.events.addUserLoaded((): void => - eventTrigger(ClientEvent.CLIENT_AUTH_SUCCESS) - ); + manager.events.addUserLoaded((user): void => { + eventTrigger(ClientEvent.USER_CHANGED, (user as unknown) as ClientUser); + }); manager.events.addUserUnloaded((): boolean => onAuthChange(false)); manager.events.addUserSignedOut((): boolean => onAuthChange(false)); manager.events.addUserSessionChanged((): boolean => onAuthChange(false)); @@ -282,6 +282,21 @@ export function createOidcClient(): Client { }); }; + const renewApiTokenForAudience = async (audience: string) => { + if (!clientFunctions.getApiToken(audience)) { + return Promise.resolve(null); + } + clientFunctions.removeApiToken(audience); + return getApiAccessToken({ + audience + }); + }; + + const renewApiTokens = async () => { + await renewApiTokenForAudience(clientConfig.exampleApiTokenAudience); + await renewApiTokenForAudience(clientConfig.profileApiTokenAudience); + }; + const getUserTokens: Client['getUserTokens'] = () => { if (!isAuthenticated()) { return undefined; @@ -349,6 +364,19 @@ export function createOidcClient(): Client { clientFunctions.addListener(ClientEvent.UNAUTHORIZED, () => { userSessionValidityPoller.stop(); }); + clientFunctions.addListener(ClientEvent.USER_CHANGED, user => { + if (isAuthenticated()) { + // if not authenticated, user just logged in + // and user is stored in callback handler + setStoredUser(user as ClientUser); + renewApiTokens().catch(() => { + // Catch handler should exist, + // but renewal errors are irrelevant. + // Tokens are anyway loaded when needed + // and renewal removes them before fetching. + }); + } + }); return client; } diff --git a/src/components/AccessTokenForm.tsx b/src/components/AccessTokenForm.tsx deleted file mode 100644 index 93a89da..0000000 --- a/src/components/AccessTokenForm.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; - -import { FetchApiTokenOptions } from '../client'; -import styles from './styles.module.css'; - -const AccessTokenForm = (props: { - options: FetchApiTokenOptions; - onOptionChange: (newOptions: FetchApiTokenOptions) => void; -}): React.ReactElement => { - const { options } = props; - const onChange = ( - target: 'audience' | 'permission' | 'grantType', - value: string - ): void => { - options[target] = value; - props.onOptionChange({ ...options }); - }; - return ( -

-

Asetukset:

-
- -
-
- -
-
- -
-
- ); -}; - -export default AccessTokenForm; diff --git a/src/components/AccessTokenOutput.tsx b/src/components/AccessTokenOutput.tsx index f4a74c8..54b0908 100644 --- a/src/components/AccessTokenOutput.tsx +++ b/src/components/AccessTokenOutput.tsx @@ -3,17 +3,26 @@ import React from 'react'; import styles from './styles.module.css'; const AccessTokenOutput = (props: { - accessToken?: Record; + accessToken?: string; + audience: string; }): React.ReactElement | null => { - const { accessToken } = props; + const { accessToken, audience } = props; if (!accessToken) { return null; } return (
-

Haettu token:

- - {JSON.stringify(accessToken, null, 2)} +

Audience

+ + {audience} + +

Token

+ + {accessToken}
); diff --git a/src/components/ApiAccessTokenProvider.tsx b/src/components/ApiAccessTokenProvider.tsx deleted file mode 100644 index 796fb12..0000000 --- a/src/components/ApiAccessTokenProvider.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, { FC } from 'react'; -import { - useApiAccessTokens, - ApiAccessTokenActions -} from '../apiAccessTokens/useApiAccessTokens'; - -export interface ApiAccessTokenContextProps { - readonly actions: ApiAccessTokenActions; -} - -export const ApiAccessTokenContext = React.createContext( - null -); - -export const ApiAccessTokenProvider: FC = ({ children }) => { - const actions = useApiAccessTokens(); - return ( - - {children} - - ); -}; diff --git a/src/components/ClientGetUser.tsx b/src/components/ClientGetUser.tsx index 7b93240..15005ad 100644 --- a/src/components/ClientGetUser.tsx +++ b/src/components/ClientGetUser.tsx @@ -38,9 +38,7 @@ const ClientGetUser = (): React.ReactElement => { return (

- {status === 'error' - ? 'Profiilin lataus epäonnistui' - : 'Profiilin tiedot'} + {status === 'error' ? 'User infon lataus epäonnistui' : 'User info'}

{profileDataOrError && ( diff --git a/src/components/ConfigChecker.tsx b/src/components/ConfigChecker.tsx index 18d05e0..7b4b090 100644 --- a/src/components/ConfigChecker.tsx +++ b/src/components/ConfigChecker.tsx @@ -39,9 +39,9 @@ const ConfigChecker = ( }; if (activeConfig !== configFromRoute) { const swapped = - activeConfig.path === config.mvpConfig.path - ? config.plainSuomiFiConfig - : config.mvpConfig; + activeConfig.path === config.tunnistamoConfig.path + ? config.keycloakConfig + : config.tunnistamoConfig; return (
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6b61c83..58aad58 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -118,11 +118,14 @@ const Header = (): React.ReactElement => { /> ); - // cannot not handle null/undefined as children. - // That is why if..else cannot be used in - const links = currentConfig.hasApiTokenSupport - ? [frontPageLink, accessTokenLink, userTokenLink, profileLink, backendLink] - : [frontPageLink, userTokenLink, userInfoLink]; + const links = [ + frontPageLink, + accessTokenLink, + userTokenLink, + userInfoLink, + profileLink, + backendLink + ]; return ( { const client = useClient(); + const config = getClientConfig(); const [selectedToken, changeSelectedToken] = useState(); const [selectedId, changeSelectedId] = useState(); - const apiTokens = client.getApiTokens(); + const apiTokens = { + [config.exampleApiTokenAudience]: client.getApiToken( + config.exampleApiTokenAudience + ), + [config.profileApiTokenAudience]: client.getApiToken( + config.profileApiTokenAudience + ) + }; const userTokens = client.getUserTokens(); - const config = getClientConfig(); // Keycloak server returns object with also other data than tokens. // 20 is a (pretty) safe length of non-token property value. // Later on tokens may be filted with prop names and config.audience. diff --git a/src/config.ts b/src/config.ts index b105a0f..36eb740 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,12 +20,20 @@ function envValueToBoolean( } function createConfigFromEnv( - source: 'OIDC' | 'PLAIN_SUOMIFI' + source: 'OIDC' | 'KEYCLOAK' ): Partial { const url = String(window._env_[`REACT_APP_${source}_URL`]); const realm = String(window._env_[`REACT_APP_${source}_REALM`]); const tokenExchangePath = window._env_[`REACT_APP_${source}_TOKEN_EXCHANGE_PATH`]; + const exampleApiTokenAudience = + window._env_[`REACT_APP_${source}_EXAMPLE_API_TOKEN_AUDIENCE`]; + const profileApiTokenAudience = + window._env_[`REACT_APP_${source}_PROFILE_API_TOKEN_AUDIENCE`]; + const scope = window._env_[`REACT_APP_${source}_SCOPE`]; + const apiGrantType = window._env_[`REACT_APP_${source}_API_TOKEN_GRANT_TYPE`]; + const apiPermission = + window._env_[`REACT_APP_${source}_API_TOKEN_PERMISSION`]; return { realm, url, @@ -35,7 +43,7 @@ function createConfigFromEnv( logoutPath: window._env_[`REACT_APP_${source}_LOGOUT_PATH`] || '/', silentAuthPath: window._env_[`REACT_APP_${source}_SILENT_AUTH_PATH`], responseType: window._env_[`REACT_APP_${source}_RESPONSE_TYPE`], - scope: window._env_[`REACT_APP_${source}_SCOPE`], + scope, autoSignIn: envValueToBoolean( window._env_[`REACT_APP_${source}_AUTO_SIGN_IN`], true @@ -49,43 +57,50 @@ function createConfigFromEnv( false ), tokenExchangePath, - hasApiTokenSupport: Boolean(tokenExchangePath) + exampleApiTokenAudience, + profileApiTokenAudience, + apiGrantType, + apiPermission }; } -const mvpConfig = { +const tunnistamoConfig = { ...createConfigFromEnv('OIDC'), - path: '/helsinkimvp', - label: 'Helsinki-profiili MVP' + path: '/tunnistamo', + label: 'Tunnistamo' } as ClientConfig; const uiConfig: { profileUIUrl: string } = { profileUIUrl: String(window._env_.REACT_APP_PROFILE_UI_URL) }; -const plainSuomiFiConfig = { - ...createConfigFromEnv('PLAIN_SUOMIFI'), - path: '/plainsuomifi', - label: 'pelkkä Suomi.fi autentikaatio' +const keycloakConfig = { + ...createConfigFromEnv('KEYCLOAK'), + path: '/helsinkitunnistus', + label: 'Helsinki-Tunnistus' } as ClientConfig; const isCallbackUrl = (route: string): boolean => - route === mvpConfig.callbackPath || route === plainSuomiFiConfig.callbackPath; + route === tunnistamoConfig.callbackPath || + route === keycloakConfig.callbackPath; const getConfigFromRoute = (route: string): ClientConfig | undefined => { if (route.length < 2) { return undefined; } - if (route.includes(mvpConfig.path) || route === mvpConfig.callbackPath) { - return mvpConfig; + if ( + route.includes(tunnistamoConfig.path) || + route === tunnistamoConfig.callbackPath + ) { + return tunnistamoConfig; } - return plainSuomiFiConfig; + return keycloakConfig; }; export default { - mvpConfig, + tunnistamoConfig, ui: uiConfig, - plainSuomiFiConfig, + keycloakConfig, isCallbackUrl, getConfigFromRoute }; diff --git a/src/pages/ApiAccessTokens.tsx b/src/pages/ApiAccessTokens.tsx index aae7ce5..24a813f 100644 --- a/src/pages/ApiAccessTokens.tsx +++ b/src/pages/ApiAccessTokens.tsx @@ -1,47 +1,34 @@ -import React, { useState } from 'react'; -import { Button } from 'hds-react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import PageContent from '../components/PageContent'; -import AccessTokenForm from '../components/AccessTokenForm'; import AccessTokenOutput from '../components/AccessTokenOutput'; -import { FetchApiTokenOptions } from '../client'; +import { getClientConfig } from '../client'; import LoginInfo from '../components/LoginInfo'; import AuthenticatingInfo from '../components/AuthenticatingInfo'; import WithAuth from '../client/WithAuth'; import { useApiAccessTokens } from '../apiAccessTokens/useApiAccessTokens'; -const AuthenticatedContent = (): React.ReactElement => { - const { getStatus, getTokens, fetch, getErrorMessage } = useApiAccessTokens(); +const TokenForm = ({ + audience, + onCompletion +}: { + audience: string; + onCompletion: (audience: string) => void; +}): React.ReactElement => { + const { getStatus, getToken, getErrorMessage } = useApiAccessTokens(audience); + const completionReportedRef = useRef(false); const status = getStatus(); const isLoading = status === 'loading'; - const canLoad = status === 'loaded' || status === 'ready'; - const tokens = status === 'loaded' ? getTokens() : undefined; - const [options, setOptions]: [ - FetchApiTokenOptions, - (newOptions: FetchApiTokenOptions) => void - ] = useState({ - audience: window._env_.REACT_APP_API_BACKEND_AUDIENCE || '', - permission: window._env_.REACT_APP_API_BACKEND_PERMISSION || '', - grantType: window._env_.REACT_APP_API_BACKEND_GRANT_TYPE || '' - }); - const onSubmit = async (): Promise => { - if (isLoading) { - return; + const token = status === 'loaded' ? getToken() : undefined; + const isComplete = status === 'loaded' || status === 'error'; + + useEffect(() => { + if (isComplete && !completionReportedRef.current) { + onCompletion(audience); + completionReportedRef.current = true; } - await fetch(options); - }; - const onOptionChange = (newOptions: FetchApiTokenOptions): void => { - setOptions(newOptions); - }; + }, [isComplete, audience, onCompletion]); return ( - -

API Access tokenin haku

-

- Jos käytössä on Tunnistamon endPoint, ei asetuksilla ole merkitystä. -

- - +
{status === 'error' && (

@@ -51,11 +38,44 @@ const AuthenticatedContent = (): React.ReactElement => { )} {isLoading && (

- Haetaan... + Haetaan api tokenia...
)} - - + +
+ ); +}; + +const TokenFetcher = ({ + audiences +}: { + audiences: string[]; +}): React.ReactElement => { + const [readyCount, updateReadyCount] = useState(0); + const onCompletion = useCallback( + audience => { + const index = audiences.findIndex(aud => aud === audience); + if (index > -1) { + updateReadyCount(n => n + 1); + } + }, + [audiences] + ); + return ( + <> + {audiences.map((audience, index) => { + if (index <= readyCount) { + return ( + + ); + } + return null; + })} + ); }; @@ -64,8 +84,26 @@ const UnauthenticatedContent = (): React.ReactElement => ( ); +const AuthenticatedContent = (): React.ReactElement => { + const config = getClientConfig(); + return ( + +

API Access tokeneiden haku

+ +
+ ); +}; const ApiAccessTokens = (): React.ReactElement => - WithAuth(AuthenticatedContent, UnauthenticatedContent, AuthenticatingInfo); + WithAuth( + () => , + UnauthenticatedContent, + AuthenticatingInfo + ); export default ApiAccessTokens; diff --git a/src/pages/BackendData.tsx b/src/pages/BackendData.tsx index fe98f33..0eabc69 100644 --- a/src/pages/BackendData.tsx +++ b/src/pages/BackendData.tsx @@ -2,15 +2,12 @@ import React from 'react'; import PageContent from '../components/PageContent'; import LoginInfo from '../components/LoginInfo'; import AuthenticatingInfo from '../components/AuthenticatingInfo'; -import { ApiAccessTokenProvider } from '../components/ApiAccessTokenProvider'; import WithAuth from '../client/WithAuth'; import BackendDataEditor from '../components/BackendDataEditor'; const BackendData = (): React.ReactElement => ( - - {WithAuth(BackendDataEditor, LoginInfo, AuthenticatingInfo)} - + {WithAuth(BackendDataEditor, LoginInfo, AuthenticatingInfo)} ); diff --git a/src/pages/ConfigSelector.tsx b/src/pages/ConfigSelector.tsx index 666d5ee..8c987d5 100644 --- a/src/pages/ConfigSelector.tsx +++ b/src/pages/ConfigSelector.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useHistory } from 'react-router-dom'; -import { Button } from 'hds-react'; +import { Button, Logo } from 'hds-react'; import PageContent from '../components/PageContent'; import config from '../config'; @@ -16,18 +16,23 @@ const ConfigSelector = (): React.ReactElement => { `${str.charAt(0).toUpperCase()}${str.substr(1)}`; return ( +

Valitse kirjautumistapa

- Voit kirjautua Helsinki-profiili MVP:n tai pelkän Suomi.fi:n kautta. + Tällä sivulla esitellään Helsinki-profiilin palvelukokonaisuutta + esimerkkisovelluksen (Example App) avulla. +

+

+ Voit kirjautua Tunnistamo ja Helsinki Tunnistus -palveluiden kautta. Valitse ensin kumpaa käytät ja voit sen jälkeen kirjautua sisään.

Kirjautumistapaa voi vaihtaa myöhemmin palaamalla tähän näkymään.

- -
diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 3e1fc57..1d144e0 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -6,17 +6,20 @@ import ReduxConsumer from '../components/ReduxConsumer'; import WithAuthDemo from '../components/WithAuthDemo'; import ClientConsumer from '../components/ClientConsumer'; import { getClientConfig } from '../client'; +import { useClient } from '../client/hooks'; const Index = (): React.ReactElement => { const currentConfig = getClientConfig(); const clientContext = useContext(ClientContext); + const client = useClient(); return ( {!!clientContext && clientContext.client ? ( <>

Client-demo

- Kirjautumistapasi on {currentConfig.label}. + Olet {client.isAuthenticated() ? 'kirjautunut' : 'kirjautumassa'}{' '} + {currentConfig.label} -palvelun kautta.

Tässä demossa näytetään kirjautumisikkuna ja komponentteja, jotka @@ -26,7 +29,7 @@ const Index = (): React.ReactElement => { Voit kirjautua sisään / ulos alla olevasta komponentista tai headerista.

-

Voit myös kirjatua ulos toisessa ikkunassa.

+

Voit myös kirjautua ulos toisessa ikkunassa.

diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 0637c2d..3b49ad2 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -3,15 +3,10 @@ import PageContent from '../components/PageContent'; import Profile from '../components/Profile'; import LoginInfo from '../components/LoginInfo'; import AuthenticatingInfo from '../components/AuthenticatingInfo'; -import { ApiAccessTokenProvider } from '../components/ApiAccessTokenProvider'; import WithAuth from '../client/WithAuth'; const ProfilePage = (): React.ReactElement => ( - - - {WithAuth(Profile, LoginInfo, AuthenticatingInfo)} - - + {WithAuth(Profile, LoginInfo, AuthenticatingInfo)} ); export default ProfilePage; diff --git a/src/pages/PlainSuomiFiUserInfo.tsx b/src/pages/UserInfo.tsx similarity index 80% rename from src/pages/PlainSuomiFiUserInfo.tsx rename to src/pages/UserInfo.tsx index edf05a6..85c7130 100644 --- a/src/pages/PlainSuomiFiUserInfo.tsx +++ b/src/pages/UserInfo.tsx @@ -5,10 +5,10 @@ import LoginInfo from '../components/LoginInfo'; import AuthenticatingInfo from '../components/AuthenticatingInfo'; import WithAuth from '../client/WithAuth'; -const PlainSuomiFiUserInfo = (): React.ReactElement => ( +const UserInfo = (): React.ReactElement => ( {WithAuth(ClientGetUser, LoginInfo, AuthenticatingInfo)} ); -export default PlainSuomiFiUserInfo; +export default UserInfo; diff --git a/src/profile/__tests__/profile.hook.test.tsx b/src/profile/__tests__/profile.hook.test.tsx index f0ef196..58e1fe8 100644 --- a/src/profile/__tests__/profile.hook.test.tsx +++ b/src/profile/__tests__/profile.hook.test.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { waitFor } from '@testing-library/react'; @@ -13,10 +13,6 @@ import { setEnv, logoutUser } from '../../tests/client.test.helper'; -import { - ApiAccessTokenContext, - ApiAccessTokenProvider -} from '../../components/ApiAccessTokenProvider'; import { ProfileData, ProfileActions, @@ -28,74 +24,34 @@ import { mockProfileResponse } from '../../tests/profile.test.helper'; import { AnyObject, AnyFunction } from '../../common'; -import { - FetchStatus, - ApiAccessTokenActions -} from '../../apiAccessTokens/useApiAccessTokens'; +import { FetchStatus } from '../../apiAccessTokens/useApiAccessTokens'; describe('Profile.ts useProfileWithApiTokens hook ', () => { configureClient({ tokenExchangePath: '/token-exchange/', autoSignIn: true }); const fetchMock: FetchMock = global.fetch; const mockMutator = mockMutatorGetterOidc(); const client = getClient(); - const testAudience = 'api-audience'; const profileBackendUrl = 'https://localhost/profileGraphql/'; let profileActions: ProfileActions; - let apiTokenActions: ApiAccessTokenActions; let dom: ReactWrapper; let restoreEnv: AnyFunction; const ProfileHookTester = (): React.ReactElement => { profileActions = useProfileWithApiTokens(); - return
{profileActions.getRequestStatus()}
; - }; - - const ApiTokenHookTester = (): React.ReactElement => { - apiTokenActions = useContext( - ApiAccessTokenContext - ) as ApiAccessTokenActions; - return
{apiTokenActions.getStatus()}
; + return ( +
+
{profileActions.getRequestStatus()}
; +
{profileActions.getApiTokenStatus()}
; +
+ ); }; const TestWrapper = (): React.ReactElement => ( - - + <> - + ); - type ErrorCatcherProps = React.PropsWithChildren<{ - callback: (err: Error) => void; - }>; - class ErrorCatcher extends React.Component< - ErrorCatcherProps, - { error?: Error } - > { - constructor(props: ErrorCatcherProps) { - super(props); - this.state = { error: undefined }; - } - - componentDidCatch(error: Error): void { - // eslint-disable-next-line no-console - console.log(`Note: following error is expected: ${error.message}`); - this.setState({ error }); - this.props.callback(error); - } - - render(): React.ReactElement { - return ( -
- {this.state.error ? ( -
{this.state.error.message}
- ) : ( - this.props.children - )} -
- ); - } - } - const setUser = async (user: AnyObject): Promise => setUpUser(user, mockMutator, client); @@ -122,7 +78,6 @@ describe('Profile.ts useProfileWithApiTokens hook ', () => { beforeAll(async () => { restoreEnv = setEnv({ - REACT_APP_PROFILE_AUDIENCE: testAudience, REACT_APP_PROFILE_BACKEND_URL: profileBackendUrl }); fetchMock.enableMocks(); @@ -159,12 +114,6 @@ describe('Profile.ts useProfileWithApiTokens hook ', () => { return el && el.length ? (el.text() as FetchStatus) : undefined; }; - const getErrorMessage = (): string | undefined => { - dom.update(); - const el = dom.find('#bound-error').at(0); - return el && el.length ? el.text() : undefined; - }; - it('depends on apiAccessToken hook and changes with it', async () => { await act(async () => { await setUpTest({ @@ -235,21 +184,4 @@ describe('Profile.ts useProfileWithApiTokens hook ', () => { expect(profileActions.getData()).toBeUndefined(); }); }); - - it('throws an error when rendered without apiToken context', async () => { - let errorCatched: Error | undefined; - // notify console viewer that error is ok - // eslint-disable-next-line no-console - console.log('Note: Error will be thrown...'); - dom = mount( - { - errorCatched = err; - }}> - - - ); - expect(errorCatched).toBeDefined(); - expect(getErrorMessage()).toBeDefined(); - }); }); diff --git a/src/profile/__tests__/profile.test.ts b/src/profile/__tests__/profile.test.ts index b0623d5..bbad083 100644 --- a/src/profile/__tests__/profile.test.ts +++ b/src/profile/__tests__/profile.test.ts @@ -1,7 +1,6 @@ import { FetchMock } from 'jest-fetch-mock'; import { convertQueryToData, - getProfileApiToken, getProfileData, getProfileGqlClient, ProfileQueryResult @@ -9,7 +8,6 @@ import { import { getClient } from '../../client/oidc-react'; import { mockMutatorGetterOidc } from '../../client/__mocks__/oidc-react-mock'; import { - setUpUser, clearApiTokens, logoutUser, setEnv @@ -22,39 +20,35 @@ import { } from '../../tests/profile.test.helper'; import { configureClient } from '../../client/__mocks__'; import { FetchError } from '../../client'; -import { AnyObject, AnyFunction } from '../../common'; +import { AnyFunction } from '../../common'; import { GraphQLClientError } from '../../graphql/graphqlClient'; describe('Profile.ts', () => { - configureClient(); + const config = configureClient(); const fetchMock: FetchMock = global.fetch; const mockMutator = mockMutatorGetterOidc(); const client = getClient(); let restoreEnv: AnyFunction; - const testAudience = 'api-audience'; + const testAudience = config.profileApiTokenAudience; const profileBackendUrl = 'https://localhost/profileGraphql/'; let lastRequest: Request; const validAPiToken = { [testAudience]: 'valid-api-token' }; - const setUser = async (user: AnyObject): Promise => - setUpUser(user, mockMutator, client); - const setValidApiToken = (): string => { client.addApiTokens(validAPiToken); - return getProfileApiToken(validAPiToken) as string; + return validAPiToken[testAudience]; }; const isApiTokenInRequest = (req: Request): boolean => { const { headers } = req; const authHeader = headers.get('Authorization'); - const profileToken = getProfileApiToken(validAPiToken); + const profileToken = validAPiToken[testAudience]; return !!(authHeader && authHeader.includes(`Bearer ${profileToken}`)); }; beforeAll(async () => { restoreEnv = setEnv({ - REACT_APP_PROFILE_AUDIENCE: testAudience, REACT_APP_PROFILE_BACKEND_URL: profileBackendUrl }); fetchMock.enableMocks(); @@ -76,14 +70,6 @@ describe('Profile.ts', () => { clearApiTokens(client); }); - it('getProfileApiToken() returns api token or undefined if api token is not set', async () => { - await setUser({}); - const token = getProfileApiToken({}); - expect(token).toBeUndefined(); - const tokenValue = setValidApiToken(); - expect(getProfileApiToken(validAPiToken)).toEqual(tokenValue); - }); - it('convertQueryToData() extracts actual profile data from graphql response or return undefined', async () => { const email = 'email@dom.com'; const emailDataTree = { emails: { edges: [{ node: { email } }] } }; diff --git a/src/profile/profile.ts b/src/profile/profile.ts index 97bde9b..bb74504 100644 --- a/src/profile/profile.ts +++ b/src/profile/profile.ts @@ -15,7 +15,7 @@ import useAuthorizedApiRequests, { AuthorizedRequest, AuthorizedApiActions } from '../apiAccessTokens/useAuthorizedApiRequests'; -import { JWTPayload } from '../client'; +import { getClientConfig } from '../client'; let profileGqlClient: GraphQLClient; @@ -77,7 +77,7 @@ export async function getProfileData( if (!client) { return { error: new Error( - 'getProfileGqlClient returned undefined. Missing ApiToken for env.REACT_APP_PROFILE_AUDIENCE or missing env.REACT_APP_PROFILE_BACKEND_URL ' + 'getProfileGqlClient returned undefined. Missing ApiToken for env.REACT_APP__PROFILE_API_TOKEN_AUDIENCE or missing env.REACT_APP_PROFILE_BACKEND_URL ' ) }; } @@ -109,14 +109,6 @@ export async function getProfileData( return result; } -export function getProfileApiToken(apiTokens: JWTPayload): string | undefined { - const tokenKey = window._env_.REACT_APP_PROFILE_AUDIENCE; - if (!tokenKey) { - return undefined; - } - return apiTokens && apiTokens[tokenKey]; -} - export async function clearGraphQlClient(): Promise { const client = getProfileGqlClient(); if (client) { @@ -126,7 +118,7 @@ export async function clearGraphQlClient(): Promise { } const executeAPIAction: Request = async options => { - const result = await getProfileData(getProfileApiToken(options.apiTokens)); + const result = await getProfileData(options.token); const resultAsError = result as GraphQLClientError; if (resultAsError.error) { throw resultAsError.error; @@ -138,5 +130,9 @@ const executeAPIAction: Request = async options => { export function useProfileWithApiTokens(): ProfileActions { const req: Request = useCallback(async props => executeAPIAction(props), []); - return useAuthorizedApiRequests(req, {}); + const config = getClientConfig(); + return useAuthorizedApiRequests(req, { + audience: config.profileApiTokenAudience, + autoFetchProps: {} + }); } diff --git a/src/tests/client.test.helper.ts b/src/tests/client.test.helper.ts index b2e848a..88f1c8f 100644 --- a/src/tests/client.test.helper.ts +++ b/src/tests/client.test.helper.ts @@ -2,6 +2,7 @@ import { FetchMock } from 'jest-fetch-mock'; import { Client, FetchApiTokenOptions, + JWTPayload, getClientConfig, getTokenUri } from '../client'; @@ -20,13 +21,26 @@ export const mockApiTokenResponse = ( delay?: number; requestCallback?: AnyFunction; returnError?: boolean; + additionalTokenAudience?: string; } = {} ): AnyObject => { - const { audience, uri, delay, requestCallback, returnError } = options; + const { + audience, + uri, + delay, + requestCallback, + returnError, + additionalTokenAudience + } = options; const fetchMock: FetchMock = global.fetch; const tokenKey = - audience || window._env_.REACT_APP_PROFILE_AUDIENCE || 'unknown'; - const tokens = { [tokenKey]: 'apiToken' }; + audience || + window._env_.REACT_APP_OIDC_PROFILE_API_TOKEN_AUDIENCE || + 'unknown'; + const tokens: JWTPayload = { [tokenKey]: 'apiToken' }; + if (additionalTokenAudience) { + tokens[additionalTokenAudience] = 'additionalToken'; + } const responseData = returnError ? { status: 401, body: JSON.stringify({ error: true }) } : { @@ -52,12 +66,9 @@ export const mockApiTokenResponse = ( }; export const clearApiTokens = (client: Client): void => { - const apiTokens = client.getApiTokens(); - if (apiTokens) { - Object.keys(apiTokens).forEach(key => { - client.removeApiToken(key); - }); - } + const config = getClientConfig(); + client.removeApiToken(config.exampleApiTokenAudience); + client.removeApiToken(config.profileApiTokenAudience); }; export const logoutUser = (client: Client): void => { @@ -80,11 +91,9 @@ export const setUpUser = async ( }; export const createApiTokenFetchPayload = ( - overrides?: FetchApiTokenOptions + overrides?: Partial ): FetchApiTokenOptions => ({ audience: 'audience', - grantType: 'grantType', - permission: 'permission', ...overrides });