Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/lib/uasApi/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import uasApiRequest from './index';

jest.mock('js-cookie');
jest.mock('../utilities/getEnvConfig');
jest.mock('./tokenRefresh/tokenManager', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue(undefined),
}));

global.fetch = jest.fn();

Expand Down
9 changes: 7 additions & 2 deletions src/app/lib/uasApi/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import isLive from '#app/lib/utilities/isLive';
import getAuthHeaders from './getAuthHeaders';
import { activityTypes } from './uasUtility';
import ensureTokens from './tokenRefresh/tokenManager';

export type UasMethod = 'POST' | 'DELETE' | 'GET';

Expand All @@ -22,7 +23,7 @@ interface UasRequestOptions {
}

const getUasHost = () =>
isLive() ? 'activity.api.bbc.co.uk' : 'activity.test.api.bbc.co.uk';
isLive() ? 'activity.api.bbc.com' : 'activity.test.api.bbc.com';

const buildUrl = (activityType: string, globalId?: string) => {
const base = `https://${getUasHost()}/my/${activityType}`;
Expand Down Expand Up @@ -58,7 +59,11 @@ const uasApiRequest = async (
validateRequest(method, { body, globalId }, activityType);

const url = buildUrl(activityType, method !== 'POST' ? globalId : undefined);

try {
await ensureTokens();
} catch (error) {
throw new Error(`Error while ensuring tokens: ${error}`);
}
const headers: HeadersInit = {
...getAuthHeaders(),
};
Expand Down
29 changes: 29 additions & 0 deletions src/app/lib/uasApi/tokenRefresh/refreshToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import isLive from '#app/lib/utilities/isLive';

const getSessionUrl = (): string => {
return isLive()
? 'https://session.bbc.com/session'
: 'https://session.test.bbc.com/session';
};

const getRefreshTokenFetchOptions = (): RequestInit => ({
credentials: 'include',
// headers: {
// 'Content-Type': 'application/json',
// },
});

const refreshTokens = async (): Promise<Response> => {
const url = getSessionUrl();
const options = getRefreshTokenFetchOptions();

const response = await fetch(url, options);

if (!response.ok) {
throw new Error(`Token refresh failed with status code ${response.status}`);
}

return response;
};

export default refreshTokens;
136 changes: 136 additions & 0 deletions src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Cookie from 'js-cookie';
import onClient from '#app/lib/utilities/onClient';
import refreshTokens from './refreshToken';
import ensureTokens, { validateToken } from './tokenManager';

Check failure on line 4 in src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts

View workflow job for this annotation

GitHub Actions / cypress-run (22.x)

Module '"./tokenManager"' has no exported member 'validateToken'. Did you mean to use 'import validateToken from "./tokenManager"' instead?

Check failure on line 4 in src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts

View workflow job for this annotation

GitHub Actions / cypress-run (22.x)

Module '"./tokenManager"' has no exported member 'validateToken'. Did you mean to use 'import validateToken from "./tokenManager"' instead?

Check failure on line 4 in src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts

View workflow job for this annotation

GitHub Actions / build (22.x)

Module '"./tokenManager"' has no exported member 'validateToken'. Did you mean to use 'import validateToken from "./tokenManager"' instead?

Check failure on line 4 in src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts

View workflow job for this annotation

GitHub Actions / cypress-run (22.x)

Module '"./tokenManager"' has no exported member 'validateToken'. Did you mean to use 'import validateToken from "./tokenManager"' instead?

jest.mock('js-cookie');
jest.mock('#app/lib/utilities/onClient');
jest.mock('./refreshToken');

const mockCookieGet = Cookie.get as jest.Mock;
const mockOnClient = onClient as jest.Mock;
const mockRefreshTokens = refreshTokens as jest.Mock;

const ONE_HOUR_FROM_NOW = Math.floor(Date.now() / 1000) + 3600;
const ONE_HOUR_AGO = Math.floor(Date.now() / 1000) - 3600;
const FOUR_MINUTES_FROM_NOW = Math.floor(Date.now() / 1000) + 4 * 60;

const createTestToken = (expSeconds: number): string => {
const payload = btoa(JSON.stringify({ exp: expSeconds }));
return `header.${payload}.signature`;
};

describe('validateToken', () => {
it('returns false for a non-JWT string', () => {
expect(validateToken('not-a-jwt')).toBe(false);
});

it('returns false for a token with no exp claim', () => {
const payload = btoa(JSON.stringify({ sub: 'user123' }));
expect(validateToken(`header.${payload}.sig`)).toBe(false);
});

it('returns false for an expired token', () => {
expect(validateToken(createTestToken(ONE_HOUR_AGO))).toBe(false);
});

it('returns false for a token expiring within the 5-minute buffer', () => {
expect(validateToken(createTestToken(FOUR_MINUTES_FROM_NOW))).toBe(false);
});

it('returns true for a token with expiry well beyond the buffer', () => {
expect(validateToken(createTestToken(ONE_HOUR_FROM_NOW))).toBe(true);
});
});

describe('ensureTokens', () => {
beforeEach(() => {
jest.clearAllMocks();
mockOnClient.mockReturnValue(true);
mockRefreshTokens.mockResolvedValue(undefined);
});

it('does nothing when not running on the client', async () => {
mockOnClient.mockReturnValue(false);
await ensureTokens();
expect(mockRefreshTokens).not.toHaveBeenCalled();
});

it('does not refresh when both tokens are present and ckns_id is valid', async () => {
const validToken = createTestToken(ONE_HOUR_FROM_NOW);
mockCookieGet.mockImplementation((name: string) => {
if (name === 'ckns_id') return validToken;
if (name === 'ckns_atkn') return 'valid-access-token';
return undefined;
});

await ensureTokens();

expect(mockRefreshTokens).not.toHaveBeenCalled();
});

it('triggers refresh when ckns_id cookie is missing', async () => {
mockCookieGet.mockImplementation((name: string) => {
if (name === 'ckns_atkn') return 'valid-access-token';
return undefined;
});

await ensureTokens();

expect(mockRefreshTokens).toHaveBeenCalledTimes(1);
});

it('triggers refresh when ckns_atkn cookie is missing', async () => {
const validToken = createTestToken(ONE_HOUR_FROM_NOW);
mockCookieGet.mockImplementation((name: string) => {
if (name === 'ckns_id') return validToken;
return undefined;
});

await ensureTokens();

expect(mockRefreshTokens).toHaveBeenCalledTimes(1);
});

it('triggers refresh when ckns_id is expired', async () => {
const expiredToken = createTestToken(ONE_HOUR_AGO);
mockCookieGet.mockImplementation((name: string) => {
if (name === 'ckns_id') return expiredToken;
if (name === 'ckns_atkn') return 'valid-access-token';
return undefined;
});

await ensureTokens();

expect(mockRefreshTokens).toHaveBeenCalledTimes(1);
});

it('triggers refresh when ckns_id expires within the buffer window', async () => {
const soonExpiringToken = createTestToken(FOUR_MINUTES_FROM_NOW);
mockCookieGet.mockImplementation((name: string) => {
if (name === 'ckns_id') return soonExpiringToken;
if (name === 'ckns_atkn') return 'valid-access-token';
return undefined;
});

await ensureTokens();

expect(mockRefreshTokens).toHaveBeenCalledTimes(1);
});

it('includes the original error message when the refresh request fails', async () => {
mockCookieGet.mockReturnValue(undefined);
mockRefreshTokens.mockRejectedValue(new Error('Network error'));

await expect(ensureTokens()).rejects.toThrow(
'Error while ensuring tokens: Network error',
);
});

it('does not throw when refresh succeeds', async () => {
mockCookieGet.mockReturnValue(undefined);
mockRefreshTokens.mockResolvedValue(undefined);

await expect(ensureTokens()).resolves.toBeUndefined();
});
});
81 changes: 81 additions & 0 deletions src/app/lib/uasApi/tokenRefresh/tokenManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Cookie from 'js-cookie';
import onClient from '#app/lib/utilities/onClient';
import refreshTokens from './refreshToken';

const TOKEN_COOKIE_NAME = 'ckns_id';
const AUTH_TOKEN_COOKIE_NAME = 'ckns_atkn';
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiry
const TOKEN_EXPIRY_TIMESTAMP = 'tkn-exp';

const decodeBase64JsonString = encodedString => {
try {
const decodedValue = window.atob(encodedString);
return JSON.parse(decodedValue);
} catch (error) {
return null;
}
};

export const getDecodedToken = (token?: string) => {
const decodedString = decodeURIComponent(token || '');

return decodeBase64JsonString(decodedString);
};

// const decodeTokenExpiry = (token: string): number | null => {
// try {
// const parts = token.split('.');
// if (parts.length < 2 || !parts[1]) return null;
// const paddedPayload = parts[1]
// .replace(/-/g, '+')
// .replace(/_/g, '/')
// .padEnd(Math.ceil(parts[1].length / 4) * 4, '=');
// const payload = JSON.parse(atob(paddedPayload));
// console.log('Decoding token expiry for token:', payload.exp * 1000);
// return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
// } catch {
// return null;
// }
// };

export const isTokenValidFor = (durationMs: number, token?: string) => {
if (!token) return false;

const { [TOKEN_EXPIRY_TIMESTAMP]: tokenExpiry } =
getDecodedToken(token) || {};

const earlyExpiryDate = new Date(tokenExpiry - durationMs);
console.log(
`Checking token validity: now=${Date.now()}, tokenExpiry=${tokenExpiry}, earlyExpiryDate=${earlyExpiryDate.getTime()}`,
);
return Date.now() < earlyExpiryDate.getTime();
};

// export const validateToken = (token: string): boolean => {
// // const expiryMs = decodeTokenExpiry(token);
// return isTokenValidFor(TOKEN_EXPIRY_BUFFER_MS, token);
// // if (expiryMs === null) return false;
// // return Date.now() < expiryMs - TOKEN_EXPIRY_BUFFER_MS;
// };

const hasValidTokens = (): boolean => {
const idToken = Cookie.get(TOKEN_COOKIE_NAME);
const atknToken = Cookie.get(AUTH_TOKEN_COOKIE_NAME);
if (!idToken || !atknToken) return false;
return isTokenValidFor(TOKEN_EXPIRY_BUFFER_MS, idToken);
};

export const ensureTokens = async (): Promise<void> => {
if (!onClient()) return;
if (hasValidTokens()) return;
console.log('Tokens are invalid or expired', hasValidTokens());
try {
await refreshTokens();
} catch (error) {
throw new Error(
`Error while ensuring tokens: ${error instanceof Error ? error.message : String(error)}`,
);
}
};

export default ensureTokens;
Loading