diff --git a/src/app/lib/uasApi/index.test.ts b/src/app/lib/uasApi/index.test.ts index a530ae7ab72..16c15ed2165 100644 --- a/src/app/lib/uasApi/index.test.ts +++ b/src/app/lib/uasApi/index.test.ts @@ -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(); diff --git a/src/app/lib/uasApi/index.ts b/src/app/lib/uasApi/index.ts index cdb990374cd..262d4e2d4f4 100644 --- a/src/app/lib/uasApi/index.ts +++ b/src/app/lib/uasApi/index.ts @@ -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'; @@ -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}`; @@ -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(), }; diff --git a/src/app/lib/uasApi/tokenRefresh/refreshToken.ts b/src/app/lib/uasApi/tokenRefresh/refreshToken.ts new file mode 100644 index 00000000000..df4f212dc19 --- /dev/null +++ b/src/app/lib/uasApi/tokenRefresh/refreshToken.ts @@ -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 => { + 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; diff --git a/src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts b/src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts new file mode 100644 index 00000000000..1f559f9d04d --- /dev/null +++ b/src/app/lib/uasApi/tokenRefresh/tokenManager.test.ts @@ -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'; + +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(); + }); +}); diff --git a/src/app/lib/uasApi/tokenRefresh/tokenManager.ts b/src/app/lib/uasApi/tokenRefresh/tokenManager.ts new file mode 100644 index 00000000000..704de64fb29 --- /dev/null +++ b/src/app/lib/uasApi/tokenRefresh/tokenManager.ts @@ -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 => { + 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;