diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..707d79dd --- /dev/null +++ b/.env.test @@ -0,0 +1,16 @@ +PORT=3001 +REACT_APP_OIDC_AUDIENCES=kukkuu-api-test +REACT_APP_OIDC_AUTHORITY=https://tunnistus.test.hel.ninja/auth/realms/helsinki-tunnistus/ +REACT_APP_OIDC_CLIENT_ID=kukkuu-admin-ui-test +REACT_APP_OIDC_KUKKUU_API_CLIENT_ID=kukkuu-api-test +REACT_APP_OIDC_RETURN_TYPE=code +REACT_APP_OIDC_SCOPE="openid profile email" +REACT_APP_OIDC_SERVER_TYPE=KEYCLOAK +REACT_APP_KUKKUU_API_OIDC_SCOPE=https://api.hel.fi/auth/kukkuu +REACT_APP_API_URI=https://kukkuu.api.test.hel.ninja/graphql +REACT_APP_SENTRY_DSN=https://c89ee3f57dc94ffd940d1df1a353b97f@sentry.hel.ninja/55 +REACT_APP_IS_TEST_ENVIRONMENT=0 + +BROWSER_TESTS_UID= +BROWSER_TESTS_PWD= +BROWSER_TESTS_ENV_URL=http://localhost:3001 diff --git a/.gitignore b/.gitignore index b860646f..2b96428d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,7 @@ yarn-error.log* screenshots report -# don't store any .env files in version control +# don't store these .env files in version control .env .env.development -.env.test .env.production diff --git a/src/domain/authentication/__tests__/__snapshots__/authService.test.js.snap b/src/domain/authentication/__tests__/__snapshots__/authService.test.js.snap index f2f30834..f97daa49 100644 --- a/src/domain/authentication/__tests__/__snapshots__/authService.test.js.snap +++ b/src/domain/authentication/__tests__/__snapshots__/authService.test.js.snap @@ -2,10 +2,14 @@ exports[`authService fetchApiToken should call axios with the right arguments 1`] = ` Array [ - "https://tunnistamo.test.kuva.hel.ninja/api-tokens/", + "https://tunnistus.test.hel.ninja/auth/realms/helsinki-tunnistus/protocol/openid-connect/token", Object { - "baseURL": "https://tunnistamo.test.kuva.hel.ninja/", - "data": Object {}, + "baseURL": "https://tunnistus.test.hel.ninja/auth/realms/helsinki-tunnistus/", + "data": Object { + "audience": "kukkuu-api-test", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "permission": "#access", + }, "headers": Object { "Accept": "application/json", "Authorization": "bearer db237bc3-e197-43de-8c86-3feea4c5f886", diff --git a/src/domain/authentication/__tests__/authService.test.js b/src/domain/authentication/__tests__/authService.test.js index eebad461..48e824a5 100644 --- a/src/domain/authentication/__tests__/authService.test.js +++ b/src/domain/authentication/__tests__/authService.test.js @@ -1,18 +1,41 @@ import axios from 'axios'; -import dataProvider from '../../../api/dataProvider'; -import authService, { API_TOKEN } from '../authService'; +import authService, { API_TOKEN, REFRESH_TOKEN } from '../authService'; import authorizationService from '../authorizationService'; import AppConfig from '../../application/AppConfig'; +import dataProvider from '../../../api/dataProvider'; jest.mock('axios'); +const testProjectId = btoa('ProjectNode:234'); + +const getOidcUserKey = () => + `oidc.user:${AppConfig.oidcAuthority}:${AppConfig.oidcClientId}`; + describe('authService', () => { const userManager = authService.userManager; - const oidcUserKey = `oidc.user:${AppConfig.oidcAuthority}:${AppConfig.oidcClientId}`; beforeEach(() => { - jest.spyOn(dataProvider, 'getMyAdminProfile').mockResolvedValue({}); + jest.spyOn(dataProvider, 'getMyAdminProfile').mockResolvedValue({ + data: { + id: btoa('AdminNode:123'), + projects: { + edges: [ + { + node: { + id: testProjectId, + year: 2023, + name: 'test project 2023', + myPermissions: { + publish: true, + manageEventGroups: true, + }, + }, + }, + ], + }, + }, + }); }); afterEach(() => { @@ -35,10 +58,22 @@ describe('authService', () => { }); describe('getToken', () => { - it('should get API_TOKENS from localStorage', () => { + it('should get API_TOKEN from localStorage', () => { + const origCallCount = localStorage.getItem.mock.calls.length; authService.getToken(); - expect(localStorage.getItem).toHaveBeenNthCalledWith(1, API_TOKEN); + expect(localStorage.getItem).toHaveBeenLastCalledWith(API_TOKEN); + expect(localStorage.getItem).toHaveBeenCalledTimes(origCallCount + 1); + }); + }); + + describe('getRefreshToken', () => { + it('should get REFRESH_TOKEN from localStorage', () => { + const origCallCount = localStorage.getItem.mock.calls.length; + authService.getRefreshToken(); + + expect(localStorage.getItem).toHaveBeenLastCalledWith(REFRESH_TOKEN); + expect(localStorage.getItem).toHaveBeenCalledTimes(origCallCount + 1); }); }); @@ -62,7 +97,7 @@ describe('authService', () => { const invalidUser = JSON.stringify({}); jest.spyOn(authService, 'getToken').mockReturnValue(apiTokens); - sessionStorage.setItem(oidcUserKey, invalidUser); + sessionStorage.setItem(getOidcUserKey(), invalidUser); expect(authService.isAuthenticated()).toBe(false); }); @@ -75,7 +110,7 @@ describe('authService', () => { }); jest.spyOn(authService, 'getToken').mockReturnValue(apiToken); - localStorage.setItem(oidcUserKey, validUser); + localStorage.setItem(getOidcUserKey(), validUser); expect(authService.isAuthenticated()).toBe(true); }); @@ -97,9 +132,11 @@ describe('authService', () => { describe('endLogin', () => { axios.mockResolvedValue({ data: {} }); const access_token = 'db237bc3-e197-43de-8c86-3feea4c5f886'; + const refresh_token = 'ec3510ee-11be-46d1-8b6a-ab97cb29b169'; const mockUser = { name: 'Penelope Krajcik', access_token, + refresh_token, }; it('should call signinRedirectCallback from oidc', () => { @@ -150,26 +187,46 @@ describe('authService', () => { expect(authService.fetchApiToken).toHaveBeenNthCalledWith(1, mockUser); }); - it('should set the user in localStorage before the function returns', async () => { - expect.assertions(1); + it('should set the tokens and project ID in localStorage before the function returns', async () => { + expect.assertions(4); jest .spyOn(userManager, 'signinRedirectCallback') .mockResolvedValue(mockUser); - jest.spyOn(authService, 'fetchApiToken'); + jest.spyOn(authService, 'queryApiTokensEndpoint').mockResolvedValue({ + data: { + access_token, + refresh_token, + }, + }); await authService.endLogin(); - expect(localStorage.setItem).toHaveBeenCalledTimes(1); + expect(localStorage.setItem).toHaveBeenCalledTimes(3); + expect(localStorage.setItem).toHaveBeenNthCalledWith( + 1, + API_TOKEN, + access_token + ); + expect(localStorage.setItem).toHaveBeenNthCalledWith( + 2, + REFRESH_TOKEN, + refresh_token + ); + expect(localStorage.setItem).toHaveBeenNthCalledWith( + 3, + 'projectId', + testProjectId + ); }); }); describe('renewToken', () => { - it('should call signinSilent from oidc', () => { + it('should call signinSilent from oidc', async () => { const signinSilent = jest .spyOn(userManager, 'signinSilent') .mockResolvedValue(); - authService.renewToken(); + await authService.renewToken(); expect(signinSilent).toHaveBeenCalledTimes(1); }); @@ -196,14 +253,18 @@ describe('authService', () => { }); it('should remove the tokens from localStorage', async () => { - expect.assertions(1); + expect.assertions(2); jest.spyOn(userManager, 'signoutRedirect').mockResolvedValue(undefined); - const apiTokens = 'a8d56df4-7ae8-4fbf-bf73-f366cd6fc479'; + const apiToken = 'a8d56df4-7ae8-4fbf-bf73-f366cd6fc479'; + const refreshToken = '347ed60c-88e4-4c08-ab25-9807be7666f4'; + + localStorage.setItem(API_TOKEN, apiToken); + localStorage.setItem(REFRESH_TOKEN, refreshToken); - localStorage.setItem(API_TOKEN, apiTokens); await authService.logout(); expect(localStorage.getItem(API_TOKEN)).toBeNull(); + expect(localStorage.getItem(REFRESH_TOKEN)).toBeNull(); }); it('should call clearStaleState', async () => { diff --git a/src/domain/authentication/authService.ts b/src/domain/authentication/authService.ts index cbe8f0c3..a9c95e0a 100644 --- a/src/domain/authentication/authService.ts +++ b/src/domain/authentication/authService.ts @@ -9,6 +9,7 @@ import AppConfig from '../application/AppConfig'; const origin = window.location.origin; export const API_TOKEN = 'apiToken'; +export const REFRESH_TOKEN = 'refreshToken'; export type ApiTokenClientProps = { url: string; @@ -35,13 +36,9 @@ export class AuthService { scope: AppConfig.oidcScope, redirect_uri: `${origin}/callback`, post_logout_redirect_uri: `${origin}/`, - // TODO: The silent renew support needs to be added to the React-admin authProvider as well. - // More about this: - // - https://marmelab.com/blog/2020/07/02/manage-your-jwt-react-admin-authentication-in-memory.html - // - https://marmelab.com/react-admin/addRefreshAuthToAuthProvider.html - // - https://marmelab.com/react-admin/addRefreshAuthToDataProvider.html - automaticSilentRenew: false, - // silent_redirect_uri: `${origin}/silent_renew.html`, + automaticSilentRenew: true, + silent_redirect_uri: `${origin}/silent_renew.html`, + revokeTokensOnSignout: true, }; if (!settings.automaticSilentRenew) { @@ -77,6 +74,7 @@ export class AuthService { // Public methods this.getUser = this.getUser.bind(this); this.getToken = this.getToken.bind(this); + this.getRefreshToken = this.getRefreshToken.bind(this); this.isAuthenticated = this.isAuthenticated.bind(this); this.login = this.login.bind(this); this.endLogin = this.endLogin.bind(this); @@ -112,6 +110,10 @@ export class AuthService { return localStorage.getItem(API_TOKEN); } + public getRefreshToken(): string | null { + return localStorage.getItem(REFRESH_TOKEN); + } + public getUserStorageKey(): string { return `oidc.user:${AppConfig.oidcAuthority}:${AppConfig.oidcClientId}`; } @@ -147,12 +149,22 @@ export class AuthService { return user; } - public renewToken(): Promise { - return this.userManager.signinSilent(); + public async renewToken(): Promise { + const user = await this.userManager.signinSilent(); + if (user) { + localStorage.setItem(API_TOKEN, user.access_token); + if (user.refresh_token) { + localStorage.setItem(REFRESH_TOKEN, user.refresh_token); + } else { + localStorage.removeItem(REFRESH_TOKEN); + } + } + return user; } public resetAuthState() { localStorage.removeItem(API_TOKEN); + localStorage.removeItem(REFRESH_TOKEN); projectService.clear(); this.userManager.clearStaleState(); authorizationService.clear(); @@ -163,32 +175,64 @@ export class AuthService { await this.userManager.signoutRedirect(); } + /** + * Query the API tokens endpoint with the given access token. + * @param accessToken The access token to use for the API tokens endpoint query. + * @returns For Tunnistamo should return a dictionary with the API identifiers + * as the keys and the API tokens as the values here as data (See [1]). + * For Keycloak should return access_token, token_type, and optionally + * expires_in, refresh_token and scope here as data (See [2, 3]). + * + * [1] Tunnistamo "Obtaining the API tokens": + * https://github.com/City-of-Helsinki/tunnistamo/blob/r211109/tokens.rst#obtaining-the-api-tokens + * + * [2] OIDC 1.0 "Authentication > Token Endpoint > Successful Token Response": + * https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse + * + * [3] OAuth 2.0 "Issuing an Access Token > Successful Response": + * https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 + */ + private async queryApiTokensEndpoint(accessToken: string) { + return axios(this.apiTokensClientConfig.url, { + method: 'post', + baseURL: AppConfig.oidcAuthority, + headers: { + Authorization: `bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + data: + this.authServerType === 'KEYCLOAK' + ? { + audience: this.audience, + ...this.apiTokensClientConfig.queryProps, + } + : {}, + }); + } + private async fetchApiToken(user: User): Promise { const accessToken = user.access_token; try { - const { data } = await axios(this.apiTokensClientConfig.url, { - method: 'post', - baseURL: AppConfig.oidcAuthority, - headers: { - Authorization: `bearer ${accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', - }, - data: - this.authServerType === 'KEYCLOAK' - ? { - audience: this.audience, - ...this.apiTokensClientConfig.queryProps, - } - : {}, - }); - + const { data } = await this.queryApiTokensEndpoint(accessToken); const apiToken = this.authServerType === 'KEYCLOAK' ? data.access_token : data[AppConfig.oidcKukkuuApiClientId]; + // NOTE: Currently only supporting refresh tokens with Keycloak. + // Tunnistamo does not return a refresh token from the API tokens + // endpoint, but Keycloak does from the token endpoint. + const refreshToken = + (this.authServerType === 'KEYCLOAK' ? data.refresh_token : null) ?? + user.refresh_token; + localStorage.setItem(API_TOKEN, apiToken); + if (!refreshToken) { + localStorage.removeItem(REFRESH_TOKEN); + } else { + localStorage.setItem(REFRESH_TOKEN, refreshToken); + } } catch (error) { // eslint-disable-next-line no-console console.error('Failed to fetch API token', error); diff --git a/src/domain/authentication/authorizationService.ts b/src/domain/authentication/authorizationService.ts index 6d698bf4..f7f1d9ef 100644 --- a/src/domain/authentication/authorizationService.ts +++ b/src/domain/authentication/authorizationService.ts @@ -72,10 +72,11 @@ export class AuthorizationService { const projects = ProjectList((data as any)?.projects).items; const role = projects.length > 0 ? 'admin' : 'none'; const projectPermissions = getProjectPermissions(projects); - projectService.setDefaultProjectId(projects); this.setPermissionStorage({ role, projects: projectPermissions }); - } catch (e) { + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to fetch user role/permissions', error); this.setPermissionStorage({ role: 'none' }); } } diff --git a/src/setupTests.ts b/src/setupTests.ts index 05082ed9..b17c4760 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -11,4 +11,4 @@ jest.mock('react-admin', () => ({ ...jest.requireActual('react-admin'), })); -dotenv.config({ path: '.env.example' }); +dotenv.config({ path: '.env.test' });