Skip to content

Commit

Permalink
feat: add silent token refreshing with Keycloak only
Browse files Browse the repository at this point in the history
after this change the tokens will be refreshed automatically before the
refresh token expires even if the user does nothing

also:
 - set predictable environment variables for tests using .env.test to
   make tests run the same locally and in CI/CD pipeline

refs KK-1156
  • Loading branch information
karisal-anders committed Jun 11, 2024
1 parent ade1782 commit f05ef57
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 51 deletions.
16 changes: 16 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -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://[email protected]/55
REACT_APP_IS_TEST_ENVIRONMENT=0

BROWSER_TESTS_UID=
BROWSER_TESTS_PWD=
BROWSER_TESTS_ENV_URL=http://localhost:3001
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
95 changes: 78 additions & 17 deletions src/domain/authentication/__tests__/authService.test.js
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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);
});
});

Expand All @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
});
Expand All @@ -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 () => {
Expand Down
96 changes: 70 additions & 26 deletions src/domain/authentication/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}`;
}
Expand Down Expand Up @@ -147,12 +149,22 @@ export class AuthService {
return user;
}

public renewToken(): Promise<User | null> {
return this.userManager.signinSilent();
public async renewToken(): Promise<User | null> {
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();
Expand All @@ -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<void> {
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);
Expand Down
5 changes: 3 additions & 2 deletions src/domain/authentication/authorizationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ jest.mock('react-admin', () => ({
...jest.requireActual('react-admin'),
}));

dotenv.config({ path: '.env.example' });
dotenv.config({ path: '.env.test' });

0 comments on commit f05ef57

Please sign in to comment.