From b46bda9eb1f44386e8eb7041ed8e0b3786e8ebcf Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 7 May 2026 00:08:33 -0300 Subject: [PATCH 1/4] Add secure HTTP client and authentication provider --- src/sdkClient/sdkLifecycle.ts | 3 +- src/services/__tests__/authProvider.spec.ts | 169 ++++++++++++++++++ .../__tests__/secureSplitHttpClient.spec.ts | 85 +++++++++ src/services/authProvider.ts | 86 +++++++++ src/services/secureSplitHttpClient.ts | 42 +++++ src/services/splitApi.ts | 31 ++-- src/services/types.ts | 4 + src/sync/streaming/AuthClient/index.ts | 7 +- src/sync/streaming/AuthClient/types.ts | 16 +- src/sync/streaming/SSEClient/index.ts | 4 +- src/sync/streaming/SSEClient/types.ts | 4 +- src/sync/streaming/__tests__/dataMocks.ts | 1 + src/sync/streaming/pushManager.ts | 4 +- src/utils/Backoff.ts | 21 +++ src/utils/__tests__/Backoff.spec.ts | 106 +++++++---- src/utils/jwt/types.ts | 2 +- 16 files changed, 516 insertions(+), 69 deletions(-) create mode 100644 src/services/__tests__/authProvider.spec.ts create mode 100644 src/services/__tests__/secureSplitHttpClient.spec.ts create mode 100644 src/services/authProvider.ts create mode 100644 src/services/secureSplitHttpClient.ts diff --git a/src/sdkClient/sdkLifecycle.ts b/src/sdkClient/sdkLifecycle.ts index 1322460c..4c51467c 100644 --- a/src/sdkClient/sdkLifecycle.ts +++ b/src/sdkClient/sdkLifecycle.ts @@ -7,7 +7,7 @@ const COOLDOWN_TIME_IN_MILLIS = 1000; * Creates an Sdk client, i.e., a base client with status, init, flush and destroy interface */ export function sdkLifecycleFactory(params: ISdkFactoryContext, isSharedClient?: boolean): { init(): void; flush(): Promise; destroy(): Promise } { - const { sdkReadinessManager, syncManager, storage, settings, telemetryTracker, impressionsTracker, platform } = params; + const { sdkReadinessManager, syncManager, storage, settings, telemetryTracker, impressionsTracker, platform, splitApi } = params; let hasInit = false; let lastActionTime = 0; @@ -68,6 +68,7 @@ export function sdkLifecycleFactory(params: ISdkFactoryContext, isSharedClient?: // Stop background jobs syncManager && syncManager.stop(); + splitApi && splitApi.stop(); return __flush().then(() => { // Cleanup storage diff --git a/src/services/__tests__/authProvider.spec.ts b/src/services/__tests__/authProvider.spec.ts new file mode 100644 index 00000000..54b76f06 --- /dev/null +++ b/src/services/__tests__/authProvider.spec.ts @@ -0,0 +1,169 @@ +import { authProviderFactory } from '../authProvider'; +import { Backoff } from '../../utils/Backoff'; + +// Speed up backoff for tests +Backoff.__TEST__BASE_MILLIS = 10; +Backoff.__TEST__MAX_MILLIS = 50; + +function toBase64Url(str: string) { + return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function makeJwt(expInSeconds = 3600) { + const now = Math.floor(Date.now() / 1000); + const header = toBase64Url(JSON.stringify({ alg: 'HS256' })); + const payload = toBase64Url(JSON.stringify({ + iat: now, exp: now + expInSeconds, 'x-ably-capability': '{"ch":["subscribe"]}' + })); + return `${header}.${payload}.sig`; +} + +function mockFetchAuth(expInSeconds = 3600) { + return jest.fn(() => Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ token: makeJwt(expInSeconds), pushEnabled: true, connDelay: 60 }), + text: () => Promise.resolve('') + })); +} + +function networkError(statusCode?: number) { + const err: any = new Error('fetch failed'); + err.statusCode = statusCode; + return err; +} + +describe('authProviderFactory', () => { + + test('credential() fetches and caches token', async () => { + const fetchAuth = mockFetchAuth(); + const provider = authProviderFactory(fetchAuth); + + const cred = await provider.credential(); + expect(cred.token).toContain('.'); + expect(fetchAuth).toHaveBeenCalledTimes(1); + + // Second call returns cached + const cred2 = await provider.credential(); + expect(cred2).toBe(cred); + expect(fetchAuth).toHaveBeenCalledTimes(1); + }); + + test('credential() deduplicates concurrent calls', async () => { + const fetchAuth = mockFetchAuth(); + const provider = authProviderFactory(fetchAuth); + + const [cred1, cred2] = await Promise.all([provider.credential(), provider.credential()]); + expect(cred1).toBe(cred2); + expect(fetchAuth).toHaveBeenCalledTimes(1); + }); + + test('invalidate() clears cache, next call fetches fresh', async () => { + const fetchAuth = mockFetchAuth(); + const provider = authProviderFactory(fetchAuth); + + await provider.credential(); + provider.invalidate(); + + await provider.credential(); + expect(fetchAuth).toHaveBeenCalledTimes(2); + }); + + test('credential() refetches when token is expired', async () => { + const fetchAuth = mockFetchAuth(); + const provider = authProviderFactory(fetchAuth); + + // Manually inject expired credential via invalidate + fetch cycle + await provider.credential(); + expect(fetchAuth).toHaveBeenCalledTimes(1); + + // Simulate expiration by invalidating and mocking expired response + provider.invalidate(); + await provider.credential(); + expect(fetchAuth).toHaveBeenCalledTimes(2); + }); + + test('4xx errors reject immediately without retry', async () => { + const fetchAuth = jest.fn(() => Promise.reject(networkError(401))); + const provider = authProviderFactory(fetchAuth); + + await expect(provider.credential()).rejects.toThrow('fetch failed'); + expect(fetchAuth).toHaveBeenCalledTimes(1); + }); + + test('retries on non-4xx errors with backoff', async () => { + let callCount = 0; + const fetchAuth = jest.fn(() => { + callCount++; + if (callCount < 3) return Promise.reject(networkError()); + return Promise.resolve({ + ok: true, status: 200, + json: () => Promise.resolve({ token: makeJwt(), pushEnabled: true, connDelay: 60 }), + text: () => Promise.resolve('') + }); + }); + + const provider = authProviderFactory(fetchAuth); + const cred = await provider.credential(); + + expect(cred.token).toContain('.'); + expect(fetchAuth).toHaveBeenCalledTimes(3); + }); + + test('stop() does not throw in any state', async () => { + const fetchAuth = mockFetchAuth(); + const provider = authProviderFactory(fetchAuth); + + // Before any credential() call + expect(() => provider.stop()).not.toThrow(); + + // After credential is cached + await provider.credential(); + expect(() => provider.stop()).not.toThrow(); + + // After invalidate + provider.invalidate(); + expect(() => provider.stop()).not.toThrow(); + + // While fetch is in-flight + const fetchAuth2 = jest.fn(() => new Promise(() => {})); // never resolves + const provider2 = authProviderFactory(fetchAuth2 as any); + provider2.credential(); + expect(() => provider2.stop()).not.toThrow(); + }); + + test('stop() prevents in-flight request from rejecting or rescheduling', async () => { + let rejectFetch: (err: any) => void; + const fetchAuth = jest.fn(() => new Promise((_, reject) => { rejectFetch = reject; })); + const provider = authProviderFactory(fetchAuth as any); + + const promise = provider.credential(); + provider.stop(); + + // Simulate the in-flight fetch failing after stop + rejectFetch!(networkError()); + + // Should resolve (not reject) with last cached credential (undefined in this case), and no retry scheduled + const result = await promise; + expect(result).toEqual(undefined); + expect(fetchAuth).toHaveBeenCalledTimes(1); + }); + + test('stop() cancels pending retries', async () => { + const fetchAuth = jest.fn(() => Promise.reject(networkError())); + const provider = authProviderFactory(fetchAuth); + + const promise = provider.credential(); + // Let first fetch fail and backoff schedule + await new Promise(r => setTimeout(r, 5)); + + provider.stop(); + + // Promise should never resolve/reject after stop (pending timeout cleared) + const result = await Promise.race([ + promise.then(() => 'resolved').catch(() => 'rejected'), + new Promise(r => setTimeout(() => r('timeout'), 100)) + ]); + expect(result).toBe('timeout'); + }); +}); diff --git a/src/services/__tests__/secureSplitHttpClient.spec.ts b/src/services/__tests__/secureSplitHttpClient.spec.ts new file mode 100644 index 00000000..052a2454 --- /dev/null +++ b/src/services/__tests__/secureSplitHttpClient.spec.ts @@ -0,0 +1,85 @@ +import { secureSplitHttpClientFactory } from '../secureSplitHttpClient'; +import { Backoff } from '../../utils/Backoff'; + +// Speed up backoff for tests +Backoff.__TEST__BASE_MILLIS = 10; +Backoff.__TEST__MAX_MILLIS = 50; + +function toBase64Url(str: string) { + return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function makeJwt(expInSeconds = 3600) { + const now = Math.floor(Date.now() / 1000); + const header = toBase64Url(JSON.stringify({ alg: 'HS256' })); + const payload = toBase64Url(JSON.stringify({ + iat: now, exp: now + expInSeconds, 'x-ably-capability': '{"ch":["subscribe"]}' + })); + return `${header}.${payload}.sig`; +} + +const mockSettings = { + core: { authorizationKey: 'sdk-key' }, + log: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, + version: '1.0.0', + runtime: {}, + sync: { requestOptions: undefined } +} as any; + +function mockFetchAuth() { + return jest.fn(() => Promise.resolve({ + ok: true, status: 200, + json: () => Promise.resolve({ token: makeJwt(), pushEnabled: true, connDelay: 60 }), + text: () => Promise.resolve('') + })); +} + +function mockPlatform(fetchImpl: jest.Mock) { + return { getFetch: () => fetchImpl, getOptions: () => undefined }; +} + +describe('secureSplitHttpClientFactory', () => { + + test('injects JWT Authorization header', async () => { + const fetchImpl = jest.fn(() => Promise.resolve({ ok: true, status: 200 })); + const fetchAuth = mockFetchAuth(); + const client = secureSplitHttpClientFactory(mockSettings, mockPlatform(fetchImpl), fetchAuth); + + await client('http://api/configs'); + + const calls = fetchImpl.mock.calls as any[]; + const reqOpts = calls[calls.length - 1][1]; + expect(reqOpts.headers.Authorization).toMatch(/^Bearer .+\..+\..+$/); + }); + + test('retries once on 401 with fresh token', async () => { + let configsCallCount = 0; + const fetchImpl = jest.fn((url: string) => { + if (url.includes('/configs')) { + configsCallCount++; + if (configsCallCount === 1) return Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') }); + } + return Promise.resolve({ ok: true, status: 200 }); + }); + const fetchAuth = mockFetchAuth(); + const client = secureSplitHttpClientFactory(mockSettings, mockPlatform(fetchImpl), fetchAuth); + + await client('http://api/configs'); + + // fetchAuth called twice (initial + after invalidation) + expect(fetchAuth).toHaveBeenCalledTimes(2); + expect(configsCallCount).toBe(2); + }); + + test('does not retry on non-401 errors', async () => { + const fetchImpl = jest.fn((url: string) => { + if (url.includes('/configs')) return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('Server Error') }); + return Promise.resolve({ ok: true, status: 200 }); + }); + const fetchAuth = mockFetchAuth(); + const client = secureSplitHttpClientFactory(mockSettings, mockPlatform(fetchImpl), fetchAuth); + + await expect(client('http://api/configs')).rejects.toThrow(); + expect(fetchAuth).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/authProvider.ts b/src/services/authProvider.ts new file mode 100644 index 00000000..9b812615 --- /dev/null +++ b/src/services/authProvider.ts @@ -0,0 +1,86 @@ +import { IFetchAuth, NetworkError } from './types'; +import { IJwtCredential } from '../sync/streaming/AuthClient/types'; +import { authenticateFactory } from '../sync/streaming/AuthClient'; +import { Backoff } from '../utils/Backoff'; + +const SKEW_SECONDS = 30; +const MAX_RETRIES = 10; +const BACKOFF_BASE = 10000; // 10 seconds +const BACKOFF_MAX = 30000; // 30 seconds + +function isExpired(credential: IJwtCredential): boolean { + return Date.now() / 1000 + SKEW_SECONDS >= credential.expiresAt; +} + +export interface IAuthProvider { + credential(): Promise; + invalidate(): void; + stop(): void; +} + +/** + * Factory of AuthProvider, which provides JWT credentials for authenticated HTTP requests. + * Credentials are fetched lazily on demand, cached in memory, and retried with backoff on failure. + */ +export function authProviderFactory(fetchAuth: IFetchAuth): IAuthProvider { + + const authenticate = authenticateFactory(fetchAuth); + const backoff = new Backoff(fetchCredential, BACKOFF_BASE, BACKOFF_MAX); + + let cachedCredential: IJwtCredential | undefined; + let inFlightPromise: Promise | undefined; + let retryCount = 0; + let stopped = false; + + function fetchCredential(): Promise { + return authenticate().then(credential => { + cachedCredential = credential; + inFlightPromise = undefined; + retryCount = 0; + backoff.reset(); + return credential; + }).catch((error: NetworkError) => { + // Avoid rejected promises and unnecessary retries after stop() + if (stopped) return cachedCredential!; + + if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { + inFlightPromise = undefined; + retryCount = 0; + throw error; + } + + if (retryCount >= MAX_RETRIES) { + inFlightPromise = undefined; + retryCount = 0; + backoff.reset(); + // @TODO: 2-hour cooldown before allowing next attempt + throw error; + } + + retryCount++; + return backoff.scheduleCallAsync(); + }); + } + + return { + credential(): Promise { + if (cachedCredential && !isExpired(cachedCredential)) { + return Promise.resolve(cachedCredential); + } + + return inFlightPromise || (inFlightPromise = fetchCredential()); + }, + + invalidate() { + cachedCredential = undefined; + }, + + stop() { + stopped = true; + cachedCredential = undefined; + inFlightPromise = undefined; + retryCount = 0; + backoff.reset(); + } + }; +} diff --git a/src/services/secureSplitHttpClient.ts b/src/services/secureSplitHttpClient.ts new file mode 100644 index 00000000..b4e9b180 --- /dev/null +++ b/src/services/secureSplitHttpClient.ts @@ -0,0 +1,42 @@ +import { IRequestOptions, IResponse, ISecureSplitHttpClient, IFetchAuth, NetworkError } from './types'; +import { ISettings } from '../types'; +import { IPlatform } from '../sdkFactory/types'; +import { splitHttpClientFactory } from './splitHttpClient'; +import { authProviderFactory } from './authProvider'; + +/** + * Factory of Secure HTTP client, which authenticates requests using a JWT token. + * On 401 responses, invalidates the cached credential and retries once with a fresh token. + * + * @param settings - SDK settings + * @param platform - object containing environment-specific dependencies + * @param fetchAuth - function to fetch auth credentials from the /v2/auth endpoint + */ +export function secureSplitHttpClientFactory(settings: ISettings, platform: Pick, fetchAuth: IFetchAuth): ISecureSplitHttpClient { + + const splitHttpClient = splitHttpClientFactory(settings, platform); + const authProvider = authProviderFactory(fetchAuth); + + function makeRequest(url: string, options: IRequestOptions | undefined, latencyTracker: ((error?: NetworkError) => void) | undefined, logErrorsAsInfo: boolean | undefined, token: string): Promise { + return splitHttpClient(url, { ...options, headers: { ...options?.headers, Authorization: `Bearer ${token}` } }, latencyTracker, logErrorsAsInfo); + } + + const httpClient = function (url: string, options?: IRequestOptions, latencyTracker?: (error?: NetworkError) => void, logErrorsAsInfo?: boolean): Promise { + return authProvider.credential().then(credential => { + return makeRequest(url, options, latencyTracker, logErrorsAsInfo, credential.token) + .catch((error: NetworkError) => { + if (error.statusCode === 401) { + authProvider.invalidate(); + return authProvider.credential().then(newCredential => { + return makeRequest(url, options, latencyTracker, logErrorsAsInfo, newCredential.token); + }); + } + throw error; + }); + }); + }; + + httpClient.stop = () => authProvider.stop(); + + return httpClient; +} diff --git a/src/services/splitApi.ts b/src/services/splitApi.ts index df167388..c4c3ccf9 100644 --- a/src/services/splitApi.ts +++ b/src/services/splitApi.ts @@ -1,7 +1,7 @@ import { IPlatform } from '../sdkFactory/types'; import { ISettings } from '../types'; import { splitHttpClientFactory } from './splitHttpClient'; -import { ISplitApi } from './types'; +import { IFetchAuth, ISecureSplitHttpClient, ISplitApi } from './types'; import { objectAssign } from '../utils/lang/objectAssign'; import { ITelemetryTracker } from '../trackers/types'; import { SPLITS, IMPRESSIONS, IMPRESSIONS_COUNT, EVENTS, TELEMETRY, TOKEN, SEGMENT, MEMBERSHIPS } from '../utils/constants'; @@ -23,7 +23,8 @@ function userKeyToQueryParam(userKey: string) { export function splitApiFactory( settings: ISettings, platform: Pick, - telemetryTracker: ITelemetryTracker + telemetryTracker: ITelemetryTracker, + secureSplitHttpClientFactory?: (settings: ISettings, platform: Pick, fetchAuth: IFetchAuth) => ISecureSplitHttpClient, ): ISplitApi { const urls = settings.urls; @@ -31,6 +32,17 @@ export function splitApiFactory( const SplitSDKImpressionsMode = settings.sync.impressionsMode; const splitHttpClient = splitHttpClientFactory(settings, platform); + function fetchAuth(userMatchingKeys?: string[]) { + let url = `${urls.auth}/v2/auth?s=${settings.sync.flagSpecVersion}`; + if (userMatchingKeys) { // `userMatchingKeys` is undefined in server-side + const queryParams = userMatchingKeys.map(userKeyToQueryParam).join('&'); + if (queryParams) url += '&' + queryParams; + } + return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN)); + } + + const secureSplitHttpClient = secureSplitHttpClientFactory ? secureSplitHttpClientFactory(settings, platform, fetchAuth) : undefined; + return { // @TODO throw errors if health check requests fail, to log them in the Synchronizer getSdkAPIHealthCheck() { @@ -43,14 +55,7 @@ export function splitApiFactory( return splitHttpClient(url).then(() => true).catch(() => false); }, - fetchAuth(userMatchingKeys?: string[]) { - let url = `${urls.auth}/v2/auth?s=${settings.sync.flagSpecVersion}`; - if (userMatchingKeys) { // `userMatchingKeys` is undefined in server-side - const queryParams = userMatchingKeys.map(userKeyToQueryParam).join('&'); - if (queryParams) url += '&' + queryParams; - } - return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN)); - }, + fetchAuth, fetchSplitChanges(since: number, noCache?: boolean, till?: number, rbSince?: number) { const url = `${urls.sdk}/splitChanges?s=${settings.sync.flagSpecVersion}&since=${since}${rbSince ? '&rbSince=' + rbSince : ''}${filterQueryString || ''}${till ? '&till=' + till : ''}`; @@ -64,7 +69,7 @@ export function splitApiFactory( // @TODO support filterQueryString and handle ERROR_TOO_MANY_SETS error fetchConfigs(since: number, noCache?: boolean, till?: number) { const url = `${urls.sdk}/v1/configs?since=${since}${filterQueryString || ''}${till ? '&till=' + till : ''}`; - return splitHttpClient(url, noCache ? noCacheHeaderOptions : undefined); + return (secureSplitHttpClient || splitHttpClient)(url, noCache ? noCacheHeaderOptions : undefined); }, fetchSegmentChanges(since: number, segmentName: string, noCache?: boolean, till?: number) { @@ -149,6 +154,10 @@ export function splitApiFactory( postMetricsUsage(body: string, headers?: Record) { const url = `${urls.telemetry}/v1/metrics/usage`; return splitHttpClient(url, { method: 'POST', body, headers }, telemetryTracker.trackHttp(TELEMETRY), true); + }, + + stop() { + if (secureSplitHttpClient) secureSplitHttpClient.stop(); } }; } diff --git a/src/services/types.ts b/src/services/types.ts index 01595280..e89c3a66 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -33,6 +33,8 @@ export type IHealthCheckAPI = () => Promise export type ISplitHttpClient = (url: string, options?: IRequestOptions, latencyTracker?: (error?: NetworkError) => void, logErrorsAsInfo?: boolean) => Promise +export type ISecureSplitHttpClient = ISplitHttpClient & { stop(): void } + export type IFetchAuth = (userKeys?: string[]) => Promise export type IFetchDefinitionChanges = (since: number, noCache?: boolean, till?: number, rbSince?: number) => Promise @@ -70,6 +72,8 @@ export interface ISplitApi { postTestImpressionsCount: IPostTestImpressionsCount postMetricsConfig: IPostMetricsConfig postMetricsUsage: IPostMetricsUsage + // lifecycle + stop(): void } // Minimal version of EventSource API used by the SDK diff --git a/src/sync/streaming/AuthClient/index.ts b/src/sync/streaming/AuthClient/index.ts index 8869674d..272a6a7e 100644 --- a/src/sync/streaming/AuthClient/index.ts +++ b/src/sync/streaming/AuthClient/index.ts @@ -1,5 +1,5 @@ import { IFetchAuth } from '../../../services/types'; -import { IAuthenticate, IAuthToken } from './types'; +import { IAuthenticate, IJwtCredential } from './types'; import { objectAssign } from '../../../utils/lang/objectAssign'; import { encodeToBase64 } from '../../../utils/base64'; import { decodeJWTtoken } from '../../../utils/jwt'; @@ -16,7 +16,7 @@ export function authenticateFactory(fetchAuth: IFetchAuth): IAuthenticate { * Run authentication requests to Auth Server, and returns a promise that resolves with the decoded JTW token. * @param userKeys - set of user Keys to track membership updates. It is undefined for server-side API. */ - return function authenticate(userKeys?: string[]): Promise { + return function authenticate(userKeys?: string[]): Promise { return fetchAuth(userKeys) .then(resp => resp.json()) .then(json => { @@ -26,7 +26,8 @@ export function authenticateFactory(fetchAuth: IFetchAuth): IAuthenticate { const channels = JSON.parse(decodedToken['x-ably-capability']); return objectAssign({ decodedToken, - channels + channels, + expiresAt: decodedToken.exp }, json); } return json; diff --git a/src/sync/streaming/AuthClient/types.ts b/src/sync/streaming/AuthClient/types.ts index 53bc2b03..a1039ffd 100644 --- a/src/sync/streaming/AuthClient/types.ts +++ b/src/sync/streaming/AuthClient/types.ts @@ -1,20 +1,14 @@ import { IDecodedJWTToken } from '../../../utils/jwt/types'; -export interface IAuthTokenPushEnabled { - pushEnabled: true +export type IJwtCredential = { + pushEnabled: boolean token: string decodedToken: IDecodedJWTToken channels: { [channel: string]: string[] } connDelay?: number + expiresAt: number // epoch seconds } -export interface IAuthTokenPushDisabled { - pushEnabled: false - token: '' -} - -export type IAuthToken = IAuthTokenPushDisabled | IAuthTokenPushEnabled - -export type IAuthenticate = (userKeys?: string[]) => Promise +export type IAuthenticate = (userKeys?: string[]) => Promise -export type IAuthenticateV2 = (isClientSide?: boolean) => Promise +export type IAuthenticateV2 = (isClientSide?: boolean) => Promise diff --git a/src/sync/streaming/SSEClient/index.ts b/src/sync/streaming/SSEClient/index.ts index bf6a2ee3..fba4a736 100644 --- a/src/sync/streaming/SSEClient/index.ts +++ b/src/sync/streaming/SSEClient/index.ts @@ -5,7 +5,7 @@ import { ISettings } from '../../../types'; import { checkIfServerSide } from '../../../utils/key'; import { isString } from '../../../utils/lang'; import { objectAssign } from '../../../utils/lang/objectAssign'; -import { IAuthTokenPushEnabled } from '../AuthClient/types'; +import { IJwtCredential } from '../AuthClient/types'; import { ISSEClient, ISseEventHandler } from './types'; const ABLY_API_VERSION = '1.1'; @@ -66,7 +66,7 @@ export class SSEClient implements ISSEClient { /** * Open the connection with a given authToken */ - open(authToken: IAuthTokenPushEnabled) { + open(authToken: IJwtCredential) { this.close(); // it closes connection if previously opened const channelsQueryParam = Object.keys(authToken.channels).map((channel) => { diff --git a/src/sync/streaming/SSEClient/types.ts b/src/sync/streaming/SSEClient/types.ts index 3d614080..999ff30a 100644 --- a/src/sync/streaming/SSEClient/types.ts +++ b/src/sync/streaming/SSEClient/types.ts @@ -1,4 +1,4 @@ -import { IAuthTokenPushEnabled } from '../AuthClient/types'; +import { IJwtCredential } from '../AuthClient/types'; export interface ISseEventHandler { handleError: (ev: Event) => any; @@ -7,7 +7,7 @@ export interface ISseEventHandler { } export interface ISSEClient { - open(authToken: IAuthTokenPushEnabled): void, + open(authToken: IJwtCredential): void, close(): void, setEventHandler(handler: ISseEventHandler): void } diff --git a/src/sync/streaming/__tests__/dataMocks.ts b/src/sync/streaming/__tests__/dataMocks.ts index cb7007d8..519c5436 100644 --- a/src/sync/streaming/__tests__/dataMocks.ts +++ b/src/sync/streaming/__tests__/dataMocks.ts @@ -37,6 +37,7 @@ export const authDataSample = { ...authDataResponseSample, decodedToken: decodedJwtPayloadSample, channels: parsedChannelsSample, + expiresAt: 1583787124, }; export const userKeySample = 'emi@split.io'; diff --git a/src/sync/streaming/pushManager.ts b/src/sync/streaming/pushManager.ts index 535e8f1c..7fa20a48 100644 --- a/src/sync/streaming/pushManager.ts +++ b/src/sync/streaming/pushManager.ts @@ -16,7 +16,7 @@ import { STREAMING_FALLBACK, STREAMING_REFRESH_TOKEN, STREAMING_CONNECTING, STRE import { IMembershipMSUpdateData, IMembershipLSUpdateData, KeyList, UpdateStrategy } from './SSEHandler/types'; import { getDelay, isInBitmap, parseBitmap, parseCompressedData } from './parseUtils'; import { Hash64, hash64 } from '../../utils/murmur3/murmur3_64'; -import { IAuthTokenPushEnabled } from './AuthClient/types'; +import { IJwtCredential } from './AuthClient/types'; import { TOKEN_REFRESH, AUTH_REJECTION } from '../../utils/constants'; import { ISdkFactoryContextSync } from '../../sdkFactory/types'; @@ -81,7 +81,7 @@ export function pushManagerFactory( let timeoutIdTokenRefresh: ReturnType; let timeoutIdSseOpen: ReturnType; - function scheduleTokenRefreshAndSse(authData: IAuthTokenPushEnabled) { + function scheduleTokenRefreshAndSse(authData: IJwtCredential) { // clear scheduled tasks if exist if (timeoutIdTokenRefresh) clearTimeout(timeoutIdTokenRefresh); if (timeoutIdSseOpen) clearTimeout(timeoutIdSseOpen); diff --git a/src/utils/Backoff.ts b/src/utils/Backoff.ts index 519b4890..9e427486 100644 --- a/src/utils/Backoff.ts +++ b/src/utils/Backoff.ts @@ -40,6 +40,27 @@ export class Backoff { return delayInMillis; } + /** + * Schedule a delayed call to `cb` + * @returns a promise that resolves/rejects with the result of the `cb` function, which must return a promise. + */ + scheduleCallAsync(): Promise { + const delayInMillis = Math.min(this.baseMillis * Math.pow(2, this.attempts), this.maxMillis); + + if (this.timeoutID) clearTimeout(this.timeoutID); + this.attempts++; + + return new Promise((resolve, reject) => { + this.timeoutID = setTimeout(() => { + this.timeoutID = undefined; + this.cb().then(resolve, reject); + }, delayInMillis); + }); + } + + /** + * Reset the backoff attempts + */ reset() { this.attempts = 0; if (this.timeoutID) { diff --git a/src/utils/__tests__/Backoff.spec.ts b/src/utils/__tests__/Backoff.spec.ts index 20e1ddad..763aaa4b 100644 --- a/src/utils/__tests__/Backoff.spec.ts +++ b/src/utils/__tests__/Backoff.spec.ts @@ -1,45 +1,79 @@ import { Backoff } from '../Backoff'; import { nearlyEqual } from '../../__tests__/testUtils'; -test('Backoff', (done) => { - - let start = Date.now(); - let backoff: Backoff; - - let alreadyReset = false; - const callback = () => { - const delta = Date.now() - start; - start += delta; - const expectedMillis = Math.min(backoff.baseMillis * Math.pow(2, backoff.attempts - 1), backoff.maxMillis); - - expect(nearlyEqual(delta, expectedMillis)).toBe(true); // executes callback at expected time - if (backoff.attempts <= 3) { - backoff.scheduleCall(); - } else { - backoff.reset(); - expect(backoff.attempts).toBe(0); // restarts attempts when `reset` called - expect(backoff.timeoutID).toBe(undefined); // restarts timeoutId when `reset` called - - // init the schedule cycle or finish the test - if (alreadyReset) { - done(); - } else { - alreadyReset = true; +describe('Backoff', () => { + + test('scheduleCall', (done) => { + + let start = Date.now(); + let backoff: Backoff; + + let alreadyReset = false; + const callback = () => { + const delta = Date.now() - start; + start += delta; + const expectedMillis = Math.min(backoff.baseMillis * Math.pow(2, backoff.attempts - 1), backoff.maxMillis); + + expect(nearlyEqual(delta, expectedMillis)).toBe(true); // executes callback at expected time + if (backoff.attempts <= 3) { backoff.scheduleCall(); + } else { + backoff.reset(); + expect(backoff.attempts).toBe(0); // restarts attempts when `reset` called + expect(backoff.timeoutID).toBe(undefined); // restarts timeoutId when `reset` called + + // init the schedule cycle or finish the test + if (alreadyReset) { + done(); + } else { + alreadyReset = true; + backoff.scheduleCall(); + } } - } - }; + }; + + backoff = new Backoff(callback); + expect(backoff.cb).toBe(callback); // contains given callback + expect(backoff.baseMillis).toBe(Backoff.DEFAULT_BASE_MILLIS); // contains default baseMillis + expect(backoff.maxMillis).toBe(Backoff.DEFAULT_MAX_MILLIS); // contains default maxMillis + + const CUSTOM_BASE = 200; + const CUSTOM_MAX = 700; + backoff = new Backoff(callback, CUSTOM_BASE, CUSTOM_MAX); + expect(backoff.baseMillis).toBe(CUSTOM_BASE); // contains given baseMillis + expect(backoff.maxMillis).toBe(CUSTOM_MAX); // contains given maxMillis + + expect(backoff.scheduleCall()).toBe(backoff.baseMillis); // scheduleCall returns the scheduled delay time + }); + + test('scheduleCallAsync resolves with cb result', async () => { + const cb = () => Promise.resolve(42); + const backoff = new Backoff(cb, 10, 50); + + const result = await backoff.scheduleCallAsync(); + expect(result).toBe(42); + expect(backoff.attempts).toBe(1); + }); + + test('scheduleCallAsync rejects when cb rejects', async () => { + const cb = () => Promise.reject(new Error('fail')); + const backoff = new Backoff(cb, 10, 50); + + await expect(backoff.scheduleCallAsync()).rejects.toThrow('fail'); + }); - backoff = new Backoff(callback); - expect(backoff.cb).toBe(callback); // contains given callback - expect(backoff.baseMillis).toBe(Backoff.DEFAULT_BASE_MILLIS); // contains default baseMillis - expect(backoff.maxMillis).toBe(Backoff.DEFAULT_MAX_MILLIS); // contains default maxMillis + test('scheduleCallAsync is cancelled by reset()', async () => { + const cb = jest.fn(() => Promise.resolve('done')); + const backoff = new Backoff(cb, 100, 100); - const CUSTOM_BASE = 200; - const CUSTOM_MAX = 700; - backoff = new Backoff(callback, CUSTOM_BASE, CUSTOM_MAX); - expect(backoff.baseMillis).toBe(CUSTOM_BASE); // contains given baseMillis - expect(backoff.maxMillis).toBe(CUSTOM_MAX); // contains given maxMillis + const promise = backoff.scheduleCallAsync(); + backoff.reset(); - expect(backoff.scheduleCall()).toBe(backoff.baseMillis); // scheduleCall returns the scheduled delay time + const result = await Promise.race([ + promise.then(() => 'resolved'), + new Promise(r => setTimeout(() => r('timeout'), 150)) + ]); + expect(result).toBe('timeout'); + expect(cb).not.toHaveBeenCalled(); + }); }); diff --git a/src/utils/jwt/types.ts b/src/utils/jwt/types.ts index 15b593c3..c5be9e86 100644 --- a/src/utils/jwt/types.ts +++ b/src/utils/jwt/types.ts @@ -1,6 +1,6 @@ export interface IDecodedJWTToken { ['x-ably-capability']: string - exp: number + exp: number // epoch seconds iat: number /** Unused fields: */ From c2ce6336faefa30a391cc78a0f1c178cf5887c4f Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 7 May 2026 17:07:13 -0300 Subject: [PATCH 2/4] JWT auth logs and url setting --- src/logger/constants.ts | 1 + src/services/__tests__/authProvider.spec.ts | 21 ++++++++-------- src/services/authProvider.ts | 27 +++++++-------------- src/services/secureSplitHttpClient.ts | 3 ++- types/splitio.d.ts | 6 +++++ 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/logger/constants.ts b/src/logger/constants.ts index 47184304..8a107c6c 100644 --- a/src/logger/constants.ts +++ b/src/logger/constants.ts @@ -122,6 +122,7 @@ export const LOG_PREFIX_ENGINE_COMBINER = LOG_PREFIX_ENGINE + ':combiner: '; export const LOG_PREFIX_ENGINE_MATCHER = LOG_PREFIX_ENGINE + ':matcher: '; export const LOG_PREFIX_ENGINE_VALUE = LOG_PREFIX_ENGINE + ':value: '; export const LOG_PREFIX_SYNC = 'sync: '; +export const LOG_PREFIX_SYNC_AUTH = 'sync:auth: '; export const LOG_PREFIX_SYNC_MANAGER = 'sync:sync-manager: '; export const LOG_PREFIX_SYNC_OFFLINE = 'sync:offline: '; export const LOG_PREFIX_SYNC_STREAMING = 'sync:streaming: '; diff --git a/src/services/__tests__/authProvider.spec.ts b/src/services/__tests__/authProvider.spec.ts index 54b76f06..991b2b7b 100644 --- a/src/services/__tests__/authProvider.spec.ts +++ b/src/services/__tests__/authProvider.spec.ts @@ -1,5 +1,6 @@ import { authProviderFactory } from '../authProvider'; import { Backoff } from '../../utils/Backoff'; +import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; // Speed up backoff for tests Backoff.__TEST__BASE_MILLIS = 10; @@ -37,7 +38,7 @@ describe('authProviderFactory', () => { test('credential() fetches and caches token', async () => { const fetchAuth = mockFetchAuth(); - const provider = authProviderFactory(fetchAuth); + const provider = authProviderFactory(fetchAuth, loggerMock); const cred = await provider.credential(); expect(cred.token).toContain('.'); @@ -51,7 +52,7 @@ describe('authProviderFactory', () => { test('credential() deduplicates concurrent calls', async () => { const fetchAuth = mockFetchAuth(); - const provider = authProviderFactory(fetchAuth); + const provider = authProviderFactory(fetchAuth, loggerMock); const [cred1, cred2] = await Promise.all([provider.credential(), provider.credential()]); expect(cred1).toBe(cred2); @@ -60,7 +61,7 @@ describe('authProviderFactory', () => { test('invalidate() clears cache, next call fetches fresh', async () => { const fetchAuth = mockFetchAuth(); - const provider = authProviderFactory(fetchAuth); + const provider = authProviderFactory(fetchAuth, loggerMock); await provider.credential(); provider.invalidate(); @@ -71,7 +72,7 @@ describe('authProviderFactory', () => { test('credential() refetches when token is expired', async () => { const fetchAuth = mockFetchAuth(); - const provider = authProviderFactory(fetchAuth); + const provider = authProviderFactory(fetchAuth, loggerMock); // Manually inject expired credential via invalidate + fetch cycle await provider.credential(); @@ -85,7 +86,7 @@ describe('authProviderFactory', () => { test('4xx errors reject immediately without retry', async () => { const fetchAuth = jest.fn(() => Promise.reject(networkError(401))); - const provider = authProviderFactory(fetchAuth); + const provider = authProviderFactory(fetchAuth, loggerMock); await expect(provider.credential()).rejects.toThrow('fetch failed'); expect(fetchAuth).toHaveBeenCalledTimes(1); @@ -103,7 +104,7 @@ describe('authProviderFactory', () => { }); }); - const provider = authProviderFactory(fetchAuth); + const provider = authProviderFactory(fetchAuth, loggerMock); const cred = await provider.credential(); expect(cred.token).toContain('.'); @@ -112,7 +113,7 @@ describe('authProviderFactory', () => { test('stop() does not throw in any state', async () => { const fetchAuth = mockFetchAuth(); - const provider = authProviderFactory(fetchAuth); + const provider = authProviderFactory(fetchAuth, loggerMock); // Before any credential() call expect(() => provider.stop()).not.toThrow(); @@ -127,7 +128,7 @@ describe('authProviderFactory', () => { // While fetch is in-flight const fetchAuth2 = jest.fn(() => new Promise(() => {})); // never resolves - const provider2 = authProviderFactory(fetchAuth2 as any); + const provider2 = authProviderFactory(fetchAuth2 as any, loggerMock); provider2.credential(); expect(() => provider2.stop()).not.toThrow(); }); @@ -135,7 +136,7 @@ describe('authProviderFactory', () => { test('stop() prevents in-flight request from rejecting or rescheduling', async () => { let rejectFetch: (err: any) => void; const fetchAuth = jest.fn(() => new Promise((_, reject) => { rejectFetch = reject; })); - const provider = authProviderFactory(fetchAuth as any); + const provider = authProviderFactory(fetchAuth as any, loggerMock); const promise = provider.credential(); provider.stop(); @@ -151,7 +152,7 @@ describe('authProviderFactory', () => { test('stop() cancels pending retries', async () => { const fetchAuth = jest.fn(() => Promise.reject(networkError())); - const provider = authProviderFactory(fetchAuth); + const provider = authProviderFactory(fetchAuth, loggerMock); const promise = provider.credential(); // Let first fetch fail and backoff schedule diff --git a/src/services/authProvider.ts b/src/services/authProvider.ts index 9b812615..cbfaf441 100644 --- a/src/services/authProvider.ts +++ b/src/services/authProvider.ts @@ -2,11 +2,10 @@ import { IFetchAuth, NetworkError } from './types'; import { IJwtCredential } from '../sync/streaming/AuthClient/types'; import { authenticateFactory } from '../sync/streaming/AuthClient'; import { Backoff } from '../utils/Backoff'; +import { ILogger } from '../logger/types'; +import { LOG_PREFIX_SYNC_AUTH } from '../logger/constants'; const SKEW_SECONDS = 30; -const MAX_RETRIES = 10; -const BACKOFF_BASE = 10000; // 10 seconds -const BACKOFF_MAX = 30000; // 30 seconds function isExpired(credential: IJwtCredential): boolean { return Date.now() / 1000 + SKEW_SECONDS >= credential.expiresAt; @@ -22,21 +21,20 @@ export interface IAuthProvider { * Factory of AuthProvider, which provides JWT credentials for authenticated HTTP requests. * Credentials are fetched lazily on demand, cached in memory, and retried with backoff on failure. */ -export function authProviderFactory(fetchAuth: IFetchAuth): IAuthProvider { +export function authProviderFactory(fetchAuth: IFetchAuth, log: ILogger): IAuthProvider { const authenticate = authenticateFactory(fetchAuth); - const backoff = new Backoff(fetchCredential, BACKOFF_BASE, BACKOFF_MAX); + const backoff = new Backoff(fetchCredential); let cachedCredential: IJwtCredential | undefined; let inFlightPromise: Promise | undefined; - let retryCount = 0; let stopped = false; function fetchCredential(): Promise { return authenticate().then(credential => { + log.info(LOG_PREFIX_SYNC_AUTH + 'credential fetched successfully'); cachedCredential = credential; inFlightPromise = undefined; - retryCount = 0; backoff.reset(); return credential; }).catch((error: NetworkError) => { @@ -44,20 +42,12 @@ export function authProviderFactory(fetchAuth: IFetchAuth): IAuthProvider { if (stopped) return cachedCredential!; if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { + log.error(LOG_PREFIX_SYNC_AUTH + 'non-retryable error fetching credential (status ' + error.statusCode + '): ' + error.message); inFlightPromise = undefined; - retryCount = 0; throw error; } - if (retryCount >= MAX_RETRIES) { - inFlightPromise = undefined; - retryCount = 0; - backoff.reset(); - // @TODO: 2-hour cooldown before allowing next attempt - throw error; - } - - retryCount++; + log.warn(LOG_PREFIX_SYNC_AUTH + 'credential fetch failed (attempt ' + (backoff.attempts + 1) + '). Error: ' + error.message); return backoff.scheduleCallAsync(); }); } @@ -68,6 +58,8 @@ export function authProviderFactory(fetchAuth: IFetchAuth): IAuthProvider { return Promise.resolve(cachedCredential); } + if (cachedCredential) log.debug(LOG_PREFIX_SYNC_AUTH + 'cached credential expired'); + return inFlightPromise || (inFlightPromise = fetchCredential()); }, @@ -79,7 +71,6 @@ export function authProviderFactory(fetchAuth: IFetchAuth): IAuthProvider { stopped = true; cachedCredential = undefined; inFlightPromise = undefined; - retryCount = 0; backoff.reset(); } }; diff --git a/src/services/secureSplitHttpClient.ts b/src/services/secureSplitHttpClient.ts index b4e9b180..7aab4496 100644 --- a/src/services/secureSplitHttpClient.ts +++ b/src/services/secureSplitHttpClient.ts @@ -15,7 +15,7 @@ import { authProviderFactory } from './authProvider'; export function secureSplitHttpClientFactory(settings: ISettings, platform: Pick, fetchAuth: IFetchAuth): ISecureSplitHttpClient { const splitHttpClient = splitHttpClientFactory(settings, platform); - const authProvider = authProviderFactory(fetchAuth); + const authProvider = authProviderFactory(fetchAuth, settings.log); function makeRequest(url: string, options: IRequestOptions | undefined, latencyTracker: ((error?: NetworkError) => void) | undefined, logErrorsAsInfo: boolean | undefined, token: string): Promise { return splitHttpClient(url, { ...options, headers: { ...options?.headers, Authorization: `Bearer ${token}` } }, latencyTracker, logErrorsAsInfo); @@ -26,6 +26,7 @@ export function secureSplitHttpClientFactory(settings: ISettings, platform: Pick return makeRequest(url, options, latencyTracker, logErrorsAsInfo, credential.token) .catch((error: NetworkError) => { if (error.statusCode === 401) { + // retry once for 401, in case the token has just expired authProvider.invalidate(); return authProvider.credential().then(newCredential => { return makeRequest(url, options, latencyTracker, logErrorsAsInfo, newCredential.token); diff --git a/types/splitio.d.ts b/types/splitio.d.ts index a515e317..528cede4 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -2349,6 +2349,12 @@ declare namespace SplitIO { * Custom endpoints to replace the default ones used by the SDK. */ urls?: { + /** + * String property to override the base URL where the SDK will get JWT authentication credentials. + * + * @defaultValue `'https://auth.split.io/api'` + */ + auth?: string; /** * String property to override the base URL where the SDK will get rollout plan related data, like feature flags and segments definitions. * From 115378a074d54d6a73d9acaaa030ae2a64d0e81c Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Fri, 8 May 2026 14:17:01 -0300 Subject: [PATCH 3/4] Update url to /v3/auth --- src/__tests__/testUtils/jwt.ts | 21 +++++ src/services/__tests__/authProvider.spec.ts | 87 +++++++++---------- .../__tests__/secureSplitHttpClient.spec.ts | 70 ++++++--------- src/services/authProvider.ts | 21 +++-- src/services/secureSplitHttpClient.ts | 7 +- src/services/splitApi.ts | 25 +++--- src/utils/settingsValidation/url.ts | 2 +- 7 files changed, 117 insertions(+), 116 deletions(-) create mode 100644 src/__tests__/testUtils/jwt.ts diff --git a/src/__tests__/testUtils/jwt.ts b/src/__tests__/testUtils/jwt.ts new file mode 100644 index 00000000..dd0311ed --- /dev/null +++ b/src/__tests__/testUtils/jwt.ts @@ -0,0 +1,21 @@ +import { IJwtCredential } from '../../sync/streaming/AuthClient/types'; + +function toBase64Url(str: string) { + return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +export function makeJwtCredential(expInSeconds = 3600): IJwtCredential { + const now = Math.floor(Date.now() / 1000); + const header = toBase64Url(JSON.stringify({ alg: 'HS256' })); + const decodedToken = { iat: now, exp: now + expInSeconds, 'x-ably-capability': '{"ch":["subscribe"]}' }; + const payload = toBase64Url(JSON.stringify(decodedToken)); + + return { + token: `${header}.${payload}.sig`, + pushEnabled: true, + connDelay: 60, + decodedToken, + channels: { ch: ['subscribe'] }, + expiresAt: decodedToken.exp + }; +} diff --git a/src/services/__tests__/authProvider.spec.ts b/src/services/__tests__/authProvider.spec.ts index 991b2b7b..9693b5a8 100644 --- a/src/services/__tests__/authProvider.spec.ts +++ b/src/services/__tests__/authProvider.spec.ts @@ -1,29 +1,17 @@ import { authProviderFactory } from '../authProvider'; import { Backoff } from '../../utils/Backoff'; import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; +import { makeJwtCredential } from '../../__tests__/testUtils/jwt'; // Speed up backoff for tests Backoff.__TEST__BASE_MILLIS = 10; Backoff.__TEST__MAX_MILLIS = 50; -function toBase64Url(str: string) { - return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -} - -function makeJwt(expInSeconds = 3600) { - const now = Math.floor(Date.now() / 1000); - const header = toBase64Url(JSON.stringify({ alg: 'HS256' })); - const payload = toBase64Url(JSON.stringify({ - iat: now, exp: now + expInSeconds, 'x-ably-capability': '{"ch":["subscribe"]}' - })); - return `${header}.${payload}.sig`; -} - -function mockFetchAuth(expInSeconds = 3600) { +function mockSplitHttpClient() { return jest.fn(() => Promise.resolve({ ok: true, status: 200, - json: () => Promise.resolve({ token: makeJwt(expInSeconds), pushEnabled: true, connDelay: 60 }), + json: () => Promise.resolve(makeJwtCredential()), text: () => Promise.resolve('') })); } @@ -34,86 +22,91 @@ function networkError(statusCode?: number) { return err; } +const mockSettings = { + urls: { auth: 'https://auth.split.io/api' }, + log: loggerMock, +} as any; + +const mockTelemetryTracker = { trackHttp: jest.fn(() => jest.fn()) } as any; + describe('authProviderFactory', () => { test('credential() fetches and caches token', async () => { - const fetchAuth = mockFetchAuth(); - const provider = authProviderFactory(fetchAuth, loggerMock); + const splitHttpClient = mockSplitHttpClient(); + const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker); const cred = await provider.credential(); expect(cred.token).toContain('.'); - expect(fetchAuth).toHaveBeenCalledTimes(1); + expect(splitHttpClient).toHaveBeenCalledTimes(1); // Second call returns cached const cred2 = await provider.credential(); expect(cred2).toBe(cred); - expect(fetchAuth).toHaveBeenCalledTimes(1); + expect(splitHttpClient).toHaveBeenCalledTimes(1); }); test('credential() deduplicates concurrent calls', async () => { - const fetchAuth = mockFetchAuth(); - const provider = authProviderFactory(fetchAuth, loggerMock); + const splitHttpClient = mockSplitHttpClient(); + const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker); const [cred1, cred2] = await Promise.all([provider.credential(), provider.credential()]); expect(cred1).toBe(cred2); - expect(fetchAuth).toHaveBeenCalledTimes(1); + expect(splitHttpClient).toHaveBeenCalledTimes(1); }); test('invalidate() clears cache, next call fetches fresh', async () => { - const fetchAuth = mockFetchAuth(); - const provider = authProviderFactory(fetchAuth, loggerMock); + const splitHttpClient = mockSplitHttpClient(); + const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker); await provider.credential(); provider.invalidate(); await provider.credential(); - expect(fetchAuth).toHaveBeenCalledTimes(2); + expect(splitHttpClient).toHaveBeenCalledTimes(2); }); test('credential() refetches when token is expired', async () => { - const fetchAuth = mockFetchAuth(); - const provider = authProviderFactory(fetchAuth, loggerMock); + const splitHttpClient = mockSplitHttpClient(); + const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker); - // Manually inject expired credential via invalidate + fetch cycle await provider.credential(); - expect(fetchAuth).toHaveBeenCalledTimes(1); + expect(splitHttpClient).toHaveBeenCalledTimes(1); - // Simulate expiration by invalidating and mocking expired response provider.invalidate(); await provider.credential(); - expect(fetchAuth).toHaveBeenCalledTimes(2); + expect(splitHttpClient).toHaveBeenCalledTimes(2); }); test('4xx errors reject immediately without retry', async () => { - const fetchAuth = jest.fn(() => Promise.reject(networkError(401))); - const provider = authProviderFactory(fetchAuth, loggerMock); + const splitHttpClient = jest.fn(() => Promise.reject(networkError(401))); + const provider = authProviderFactory(mockSettings, splitHttpClient as any, mockTelemetryTracker); await expect(provider.credential()).rejects.toThrow('fetch failed'); - expect(fetchAuth).toHaveBeenCalledTimes(1); + expect(splitHttpClient).toHaveBeenCalledTimes(1); }); test('retries on non-4xx errors with backoff', async () => { let callCount = 0; - const fetchAuth = jest.fn(() => { + const splitHttpClient = jest.fn(() => { callCount++; if (callCount < 3) return Promise.reject(networkError()); return Promise.resolve({ ok: true, status: 200, - json: () => Promise.resolve({ token: makeJwt(), pushEnabled: true, connDelay: 60 }), + json: () => Promise.resolve(makeJwtCredential()), text: () => Promise.resolve('') }); }); - const provider = authProviderFactory(fetchAuth, loggerMock); + const provider = authProviderFactory(mockSettings, splitHttpClient as any, mockTelemetryTracker); const cred = await provider.credential(); expect(cred.token).toContain('.'); - expect(fetchAuth).toHaveBeenCalledTimes(3); + expect(splitHttpClient).toHaveBeenCalledTimes(3); }); test('stop() does not throw in any state', async () => { - const fetchAuth = mockFetchAuth(); - const provider = authProviderFactory(fetchAuth, loggerMock); + const splitHttpClient = mockSplitHttpClient(); + const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker); // Before any credential() call expect(() => provider.stop()).not.toThrow(); @@ -127,16 +120,16 @@ describe('authProviderFactory', () => { expect(() => provider.stop()).not.toThrow(); // While fetch is in-flight - const fetchAuth2 = jest.fn(() => new Promise(() => {})); // never resolves - const provider2 = authProviderFactory(fetchAuth2 as any, loggerMock); + const splitHttpClient2 = jest.fn(() => new Promise(() => {})); // never resolves + const provider2 = authProviderFactory(mockSettings, splitHttpClient2 as any, mockTelemetryTracker); provider2.credential(); expect(() => provider2.stop()).not.toThrow(); }); test('stop() prevents in-flight request from rejecting or rescheduling', async () => { let rejectFetch: (err: any) => void; - const fetchAuth = jest.fn(() => new Promise((_, reject) => { rejectFetch = reject; })); - const provider = authProviderFactory(fetchAuth as any, loggerMock); + const splitHttpClient = jest.fn(() => new Promise((_, reject) => { rejectFetch = reject; })); + const provider = authProviderFactory(mockSettings, splitHttpClient as any, mockTelemetryTracker); const promise = provider.credential(); provider.stop(); @@ -147,12 +140,12 @@ describe('authProviderFactory', () => { // Should resolve (not reject) with last cached credential (undefined in this case), and no retry scheduled const result = await promise; expect(result).toEqual(undefined); - expect(fetchAuth).toHaveBeenCalledTimes(1); + expect(splitHttpClient).toHaveBeenCalledTimes(1); }); test('stop() cancels pending retries', async () => { - const fetchAuth = jest.fn(() => Promise.reject(networkError())); - const provider = authProviderFactory(fetchAuth, loggerMock); + const splitHttpClient = jest.fn(() => Promise.reject(networkError())); + const provider = authProviderFactory(mockSettings, splitHttpClient as any, mockTelemetryTracker); const promise = provider.credential(); // Let first fetch fail and backoff schedule diff --git a/src/services/__tests__/secureSplitHttpClient.spec.ts b/src/services/__tests__/secureSplitHttpClient.spec.ts index 052a2454..bc3d1307 100644 --- a/src/services/__tests__/secureSplitHttpClient.spec.ts +++ b/src/services/__tests__/secureSplitHttpClient.spec.ts @@ -1,85 +1,65 @@ import { secureSplitHttpClientFactory } from '../secureSplitHttpClient'; import { Backoff } from '../../utils/Backoff'; +import { makeJwtCredential } from '../../__tests__/testUtils/jwt'; // Speed up backoff for tests Backoff.__TEST__BASE_MILLIS = 10; Backoff.__TEST__MAX_MILLIS = 50; -function toBase64Url(str: string) { - return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -} - -function makeJwt(expInSeconds = 3600) { - const now = Math.floor(Date.now() / 1000); - const header = toBase64Url(JSON.stringify({ alg: 'HS256' })); - const payload = toBase64Url(JSON.stringify({ - iat: now, exp: now + expInSeconds, 'x-ably-capability': '{"ch":["subscribe"]}' - })); - return `${header}.${payload}.sig`; -} - const mockSettings = { core: { authorizationKey: 'sdk-key' }, log: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, version: '1.0.0', runtime: {}, + urls: { auth: 'https://auth.split.io/api' }, sync: { requestOptions: undefined } } as any; -function mockFetchAuth() { - return jest.fn(() => Promise.resolve({ - ok: true, status: 200, - json: () => Promise.resolve({ token: makeJwt(), pushEnabled: true, connDelay: 60 }), - text: () => Promise.resolve('') - })); -} +const mockTelemetryTracker = { trackHttp: jest.fn(() => jest.fn()) } as any; -function mockPlatform(fetchImpl: jest.Mock) { - return { getFetch: () => fetchImpl, getOptions: () => undefined }; + +const authResponse = { ok: true, status: 200, json: () => Promise.resolve(makeJwtCredential()), text: () => Promise.resolve('') }; + +function createSecureSplitHttpClient(configsHandler: (callCount: number) => any) { + let configsCallCount = 0; + const fetchImpl = jest.fn((url: string) => { + if (url.includes('/auth')) return Promise.resolve(authResponse); + configsCallCount++; + return configsHandler(configsCallCount); + }); + const client = secureSplitHttpClientFactory(mockSettings, { getFetch: () => fetchImpl, getOptions: () => undefined }, mockTelemetryTracker); + return { client, fetchImpl }; } describe('secureSplitHttpClientFactory', () => { test('injects JWT Authorization header', async () => { - const fetchImpl = jest.fn(() => Promise.resolve({ ok: true, status: 200 })); - const fetchAuth = mockFetchAuth(); - const client = secureSplitHttpClientFactory(mockSettings, mockPlatform(fetchImpl), fetchAuth); + const { client, fetchImpl } = createSecureSplitHttpClient(() => Promise.resolve({ ok: true, status: 200 })); await client('http://api/configs'); const calls = fetchImpl.mock.calls as any[]; - const reqOpts = calls[calls.length - 1][1]; - expect(reqOpts.headers.Authorization).toMatch(/^Bearer .+\..+\..+$/); + const configsCall = calls.find(c => c[0].includes('/configs')); + expect(configsCall[1].headers.Authorization).toMatch(/^Bearer .+\..+\..+$/); }); test('retries once on 401 with fresh token', async () => { - let configsCallCount = 0; - const fetchImpl = jest.fn((url: string) => { - if (url.includes('/configs')) { - configsCallCount++; - if (configsCallCount === 1) return Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') }); - } + const { client, fetchImpl } = createSecureSplitHttpClient((count) => { + if (count === 1) return Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') }); return Promise.resolve({ ok: true, status: 200 }); }); - const fetchAuth = mockFetchAuth(); - const client = secureSplitHttpClientFactory(mockSettings, mockPlatform(fetchImpl), fetchAuth); await client('http://api/configs'); - // fetchAuth called twice (initial + after invalidation) - expect(fetchAuth).toHaveBeenCalledTimes(2); - expect(configsCallCount).toBe(2); + const authCalls = (fetchImpl.mock.calls as any[]).filter(c => c[0].includes('/auth')); + expect(authCalls.length).toBe(2); }); test('does not retry on non-401 errors', async () => { - const fetchImpl = jest.fn((url: string) => { - if (url.includes('/configs')) return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('Server Error') }); - return Promise.resolve({ ok: true, status: 200 }); - }); - const fetchAuth = mockFetchAuth(); - const client = secureSplitHttpClientFactory(mockSettings, mockPlatform(fetchImpl), fetchAuth); + const { client, fetchImpl } = createSecureSplitHttpClient(() => Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('Server Error') })); await expect(client('http://api/configs')).rejects.toThrow(); - expect(fetchAuth).toHaveBeenCalledTimes(1); + const authCalls = (fetchImpl.mock.calls as any[]).filter(c => c[0].includes('/auth')); + expect(authCalls.length).toBe(1); }); }); diff --git a/src/services/authProvider.ts b/src/services/authProvider.ts index cbfaf441..8915505b 100644 --- a/src/services/authProvider.ts +++ b/src/services/authProvider.ts @@ -1,9 +1,11 @@ -import { IFetchAuth, NetworkError } from './types'; +import { ISplitHttpClient, NetworkError } from './types'; import { IJwtCredential } from '../sync/streaming/AuthClient/types'; import { authenticateFactory } from '../sync/streaming/AuthClient'; import { Backoff } from '../utils/Backoff'; -import { ILogger } from '../logger/types'; import { LOG_PREFIX_SYNC_AUTH } from '../logger/constants'; +import { ISettings } from '../types'; +import { TOKEN } from '../utils/constants'; +import { ITelemetryTracker } from '../trackers/types'; const SKEW_SECONDS = 30; @@ -12,16 +14,23 @@ function isExpired(credential: IJwtCredential): boolean { } export interface IAuthProvider { - credential(): Promise; - invalidate(): void; - stop(): void; + credential(): Promise; + invalidate(): void; + stop(): void; } /** * Factory of AuthProvider, which provides JWT credentials for authenticated HTTP requests. * Credentials are fetched lazily on demand, cached in memory, and retried with backoff on failure. */ -export function authProviderFactory(fetchAuth: IFetchAuth, log: ILogger): IAuthProvider { +export function authProviderFactory(settings: ISettings, splitHttpClient: ISplitHttpClient, telemetryTracker: ITelemetryTracker): IAuthProvider { + + const { urls, log } = settings; + + function fetchAuth() { + let url = `${urls.auth}/v3/auth?capabilities=config`; + return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN)); + } const authenticate = authenticateFactory(fetchAuth); const backoff = new Backoff(fetchCredential); diff --git a/src/services/secureSplitHttpClient.ts b/src/services/secureSplitHttpClient.ts index 7aab4496..1d2e47fd 100644 --- a/src/services/secureSplitHttpClient.ts +++ b/src/services/secureSplitHttpClient.ts @@ -1,8 +1,9 @@ -import { IRequestOptions, IResponse, ISecureSplitHttpClient, IFetchAuth, NetworkError } from './types'; +import { IRequestOptions, IResponse, ISecureSplitHttpClient, NetworkError } from './types'; import { ISettings } from '../types'; import { IPlatform } from '../sdkFactory/types'; import { splitHttpClientFactory } from './splitHttpClient'; import { authProviderFactory } from './authProvider'; +import { ITelemetryTracker } from '../trackers/types'; /** * Factory of Secure HTTP client, which authenticates requests using a JWT token. @@ -12,10 +13,10 @@ import { authProviderFactory } from './authProvider'; * @param platform - object containing environment-specific dependencies * @param fetchAuth - function to fetch auth credentials from the /v2/auth endpoint */ -export function secureSplitHttpClientFactory(settings: ISettings, platform: Pick, fetchAuth: IFetchAuth): ISecureSplitHttpClient { +export function secureSplitHttpClientFactory(settings: ISettings, platform: Pick, telemetryTracker: ITelemetryTracker): ISecureSplitHttpClient { const splitHttpClient = splitHttpClientFactory(settings, platform); - const authProvider = authProviderFactory(fetchAuth, settings.log); + const authProvider = authProviderFactory(settings, splitHttpClient, telemetryTracker); function makeRequest(url: string, options: IRequestOptions | undefined, latencyTracker: ((error?: NetworkError) => void) | undefined, logErrorsAsInfo: boolean | undefined, token: string): Promise { return splitHttpClient(url, { ...options, headers: { ...options?.headers, Authorization: `Bearer ${token}` } }, latencyTracker, logErrorsAsInfo); diff --git a/src/services/splitApi.ts b/src/services/splitApi.ts index c4c3ccf9..b9fe142f 100644 --- a/src/services/splitApi.ts +++ b/src/services/splitApi.ts @@ -1,7 +1,7 @@ import { IPlatform } from '../sdkFactory/types'; import { ISettings } from '../types'; import { splitHttpClientFactory } from './splitHttpClient'; -import { IFetchAuth, ISecureSplitHttpClient, ISplitApi } from './types'; +import { ISecureSplitHttpClient, ISplitApi } from './types'; import { objectAssign } from '../utils/lang/objectAssign'; import { ITelemetryTracker } from '../trackers/types'; import { SPLITS, IMPRESSIONS, IMPRESSIONS_COUNT, EVENTS, TELEMETRY, TOKEN, SEGMENT, MEMBERSHIPS } from '../utils/constants'; @@ -24,24 +24,14 @@ export function splitApiFactory( settings: ISettings, platform: Pick, telemetryTracker: ITelemetryTracker, - secureSplitHttpClientFactory?: (settings: ISettings, platform: Pick, fetchAuth: IFetchAuth) => ISecureSplitHttpClient, + secureSplitHttpClientFactory?: (settings: ISettings, platform: Pick, telemetryTracker: ITelemetryTracker) => ISecureSplitHttpClient, ): ISplitApi { const urls = settings.urls; const filterQueryString = settings.sync.__splitFiltersValidation && settings.sync.__splitFiltersValidation.queryString; const SplitSDKImpressionsMode = settings.sync.impressionsMode; const splitHttpClient = splitHttpClientFactory(settings, platform); - - function fetchAuth(userMatchingKeys?: string[]) { - let url = `${urls.auth}/v2/auth?s=${settings.sync.flagSpecVersion}`; - if (userMatchingKeys) { // `userMatchingKeys` is undefined in server-side - const queryParams = userMatchingKeys.map(userKeyToQueryParam).join('&'); - if (queryParams) url += '&' + queryParams; - } - return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN)); - } - - const secureSplitHttpClient = secureSplitHttpClientFactory ? secureSplitHttpClientFactory(settings, platform, fetchAuth) : undefined; + const secureSplitHttpClient = secureSplitHttpClientFactory ? secureSplitHttpClientFactory(settings, platform, telemetryTracker) : undefined; return { // @TODO throw errors if health check requests fail, to log them in the Synchronizer @@ -55,7 +45,14 @@ export function splitApiFactory( return splitHttpClient(url).then(() => true).catch(() => false); }, - fetchAuth, + fetchAuth(userMatchingKeys?: string[]) { + let url = `${urls.auth}/v2/auth?s=${settings.sync.flagSpecVersion}`; + if (userMatchingKeys) { // `userMatchingKeys` is undefined in server-side + const queryParams = userMatchingKeys.map(userKeyToQueryParam).join('&'); + if (queryParams) url += '&' + queryParams; + } + return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN)); + }, fetchSplitChanges(since: number, noCache?: boolean, till?: number, rbSince?: number) { const url = `${urls.sdk}/splitChanges?s=${settings.sync.flagSpecVersion}&since=${since}${rbSince ? '&rbSince=' + rbSince : ''}${filterQueryString || ''}${till ? '&till=' + till : ''}`; diff --git a/src/utils/settingsValidation/url.ts b/src/utils/settingsValidation/url.ts index c483f930..cd2fbbab 100644 --- a/src/utils/settingsValidation/url.ts +++ b/src/utils/settingsValidation/url.ts @@ -2,7 +2,7 @@ import { ISettings } from '../../types'; const telemetryEndpointMatcher = /^\/v1\/metrics\/(config|usage)/; const eventsEndpointMatcher = /^\/(testImpressions|metrics|events)/; -const authEndpointMatcher = /^\/v2\/auth/; +const authEndpointMatcher = /^\/(v2|v3)\/auth/; const streamingEndpointMatcher = /^\/(sse|event-stream)/; /** From 7dfb8e0dc692c7e2d63a063f540276524af479d2 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 11 May 2026 17:17:43 -0300 Subject: [PATCH 4/4] Refactor JWT auth --- src/__tests__/testUtils/jwt.ts | 13 ++++++++----- src/services/authProvider.ts | 18 +++++++++--------- src/sync/streaming/AuthClient/index.ts | 7 +++---- src/sync/streaming/AuthClient/types.ts | 19 ++++++++++++++----- src/sync/streaming/SSEClient/index.ts | 4 ++-- src/sync/streaming/SSEClient/types.ts | 4 ++-- src/sync/streaming/__tests__/dataMocks.ts | 1 - src/sync/streaming/pushManager.ts | 4 ++-- 8 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/__tests__/testUtils/jwt.ts b/src/__tests__/testUtils/jwt.ts index dd0311ed..b0623c5b 100644 --- a/src/__tests__/testUtils/jwt.ts +++ b/src/__tests__/testUtils/jwt.ts @@ -1,10 +1,10 @@ -import { IJwtCredential } from '../../sync/streaming/AuthClient/types'; +import { IJwtCredentialV3 } from '../../sync/streaming/AuthClient/types'; function toBase64Url(str: string) { return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } -export function makeJwtCredential(expInSeconds = 3600): IJwtCredential { +export function makeJwtCredential(expInSeconds = 3600): IJwtCredentialV3 { const now = Math.floor(Date.now() / 1000); const header = toBase64Url(JSON.stringify({ alg: 'HS256' })); const decodedToken = { iat: now, exp: now + expInSeconds, 'x-ably-capability': '{"ch":["subscribe"]}' }; @@ -12,10 +12,13 @@ export function makeJwtCredential(expInSeconds = 3600): IJwtCredential { return { token: `${header}.${payload}.sig`, - pushEnabled: true, - connDelay: 60, decodedToken, channels: { ch: ['subscribe'] }, - expiresAt: decodedToken.exp + config: { + streaming: { + enabled: true, + delay: 60, + } + } }; } diff --git a/src/services/authProvider.ts b/src/services/authProvider.ts index 8915505b..d17b5829 100644 --- a/src/services/authProvider.ts +++ b/src/services/authProvider.ts @@ -1,5 +1,5 @@ import { ISplitHttpClient, NetworkError } from './types'; -import { IJwtCredential } from '../sync/streaming/AuthClient/types'; +import { IJwtCredentialV3 } from '../sync/streaming/AuthClient/types'; import { authenticateFactory } from '../sync/streaming/AuthClient'; import { Backoff } from '../utils/Backoff'; import { LOG_PREFIX_SYNC_AUTH } from '../logger/constants'; @@ -9,12 +9,12 @@ import { ITelemetryTracker } from '../trackers/types'; const SKEW_SECONDS = 30; -function isExpired(credential: IJwtCredential): boolean { - return Date.now() / 1000 + SKEW_SECONDS >= credential.expiresAt; +function isExpired(credential: IJwtCredentialV3): boolean { + return Date.now() / 1000 + SKEW_SECONDS >= credential.decodedToken.exp; } export interface IAuthProvider { - credential(): Promise; + credential(): Promise; invalidate(): void; stop(): void; } @@ -35,12 +35,12 @@ export function authProviderFactory(settings: ISettings, splitHttpClient: ISplit const authenticate = authenticateFactory(fetchAuth); const backoff = new Backoff(fetchCredential); - let cachedCredential: IJwtCredential | undefined; - let inFlightPromise: Promise | undefined; + let cachedCredential: IJwtCredentialV3 | undefined; + let inFlightPromise: Promise | undefined; let stopped = false; - function fetchCredential(): Promise { - return authenticate().then(credential => { + function fetchCredential(): Promise { + return authenticate().then((credential: IJwtCredentialV3) => { log.info(LOG_PREFIX_SYNC_AUTH + 'credential fetched successfully'); cachedCredential = credential; inFlightPromise = undefined; @@ -62,7 +62,7 @@ export function authProviderFactory(settings: ISettings, splitHttpClient: ISplit } return { - credential(): Promise { + credential(): Promise { if (cachedCredential && !isExpired(cachedCredential)) { return Promise.resolve(cachedCredential); } diff --git a/src/sync/streaming/AuthClient/index.ts b/src/sync/streaming/AuthClient/index.ts index 272a6a7e..bdd03a16 100644 --- a/src/sync/streaming/AuthClient/index.ts +++ b/src/sync/streaming/AuthClient/index.ts @@ -1,5 +1,5 @@ import { IFetchAuth } from '../../../services/types'; -import { IAuthenticate, IJwtCredential } from './types'; +import { IAuthenticate, IJwtCredentialV2 } from './types'; import { objectAssign } from '../../../utils/lang/objectAssign'; import { encodeToBase64 } from '../../../utils/base64'; import { decodeJWTtoken } from '../../../utils/jwt'; @@ -16,7 +16,7 @@ export function authenticateFactory(fetchAuth: IFetchAuth): IAuthenticate { * Run authentication requests to Auth Server, and returns a promise that resolves with the decoded JTW token. * @param userKeys - set of user Keys to track membership updates. It is undefined for server-side API. */ - return function authenticate(userKeys?: string[]): Promise { + return function authenticate(userKeys?: string[]): Promise { return fetchAuth(userKeys) .then(resp => resp.json()) .then(json => { @@ -26,8 +26,7 @@ export function authenticateFactory(fetchAuth: IFetchAuth): IAuthenticate { const channels = JSON.parse(decodedToken['x-ably-capability']); return objectAssign({ decodedToken, - channels, - expiresAt: decodedToken.exp + channels }, json); } return json; diff --git a/src/sync/streaming/AuthClient/types.ts b/src/sync/streaming/AuthClient/types.ts index a1039ffd..ae98c812 100644 --- a/src/sync/streaming/AuthClient/types.ts +++ b/src/sync/streaming/AuthClient/types.ts @@ -1,14 +1,23 @@ import { IDecodedJWTToken } from '../../../utils/jwt/types'; -export type IJwtCredential = { +export type IJwtCredentialV2 = { pushEnabled: boolean - token: string + token: string // empty string ("") when `"pushEnabled": false` decodedToken: IDecodedJWTToken channels: { [channel: string]: string[] } connDelay?: number - expiresAt: number // epoch seconds } -export type IAuthenticate = (userKeys?: string[]) => Promise +export type IJwtCredentialV3 = { + token: string + decodedToken: IDecodedJWTToken + channels: { [channel: string]: string[] } + config?: { + streaming?: { + delay?: number + enabled?: boolean + } | null; + } | null; +} -export type IAuthenticateV2 = (isClientSide?: boolean) => Promise +export type IAuthenticate = (userKeys?: string[]) => Promise diff --git a/src/sync/streaming/SSEClient/index.ts b/src/sync/streaming/SSEClient/index.ts index fba4a736..1f31d5a3 100644 --- a/src/sync/streaming/SSEClient/index.ts +++ b/src/sync/streaming/SSEClient/index.ts @@ -5,7 +5,7 @@ import { ISettings } from '../../../types'; import { checkIfServerSide } from '../../../utils/key'; import { isString } from '../../../utils/lang'; import { objectAssign } from '../../../utils/lang/objectAssign'; -import { IJwtCredential } from '../AuthClient/types'; +import { IJwtCredentialV2 } from '../AuthClient/types'; import { ISSEClient, ISseEventHandler } from './types'; const ABLY_API_VERSION = '1.1'; @@ -66,7 +66,7 @@ export class SSEClient implements ISSEClient { /** * Open the connection with a given authToken */ - open(authToken: IJwtCredential) { + open(authToken: IJwtCredentialV2) { this.close(); // it closes connection if previously opened const channelsQueryParam = Object.keys(authToken.channels).map((channel) => { diff --git a/src/sync/streaming/SSEClient/types.ts b/src/sync/streaming/SSEClient/types.ts index 999ff30a..8072c05b 100644 --- a/src/sync/streaming/SSEClient/types.ts +++ b/src/sync/streaming/SSEClient/types.ts @@ -1,4 +1,4 @@ -import { IJwtCredential } from '../AuthClient/types'; +import { IJwtCredentialV2 } from '../AuthClient/types'; export interface ISseEventHandler { handleError: (ev: Event) => any; @@ -7,7 +7,7 @@ export interface ISseEventHandler { } export interface ISSEClient { - open(authToken: IJwtCredential): void, + open(authToken: IJwtCredentialV2): void, close(): void, setEventHandler(handler: ISseEventHandler): void } diff --git a/src/sync/streaming/__tests__/dataMocks.ts b/src/sync/streaming/__tests__/dataMocks.ts index 519c5436..cb7007d8 100644 --- a/src/sync/streaming/__tests__/dataMocks.ts +++ b/src/sync/streaming/__tests__/dataMocks.ts @@ -37,7 +37,6 @@ export const authDataSample = { ...authDataResponseSample, decodedToken: decodedJwtPayloadSample, channels: parsedChannelsSample, - expiresAt: 1583787124, }; export const userKeySample = 'emi@split.io'; diff --git a/src/sync/streaming/pushManager.ts b/src/sync/streaming/pushManager.ts index 836cd2af..5ba6aa6a 100644 --- a/src/sync/streaming/pushManager.ts +++ b/src/sync/streaming/pushManager.ts @@ -16,7 +16,7 @@ import { STREAMING_FALLBACK, STREAMING_REFRESH_TOKEN, STREAMING_CONNECTING, STRE import { IMembershipMSUpdateData, IMembershipLSUpdateData, KeyList, UpdateStrategy } from './SSEHandler/types'; import { getDelay, isInBitmap, parseBitmap, parseCompressedData } from './parseUtils'; import { Hash64, hash64 } from '../../utils/murmur3/murmur3_64'; -import { IJwtCredential } from './AuthClient/types'; +import { IJwtCredentialV2 } from './AuthClient/types'; import { TOKEN_REFRESH, AUTH_REJECTION } from '../../utils/constants'; import { ISdkFactoryContextSync } from '../../sdkFactory/types'; @@ -81,7 +81,7 @@ export function pushManagerFactory( let timeoutIdTokenRefresh: ReturnType; let timeoutIdSseOpen: ReturnType; - function scheduleTokenRefreshAndSse(authData: IJwtCredential) { + function scheduleTokenRefreshAndSse(authData: IJwtCredentialV2) { // clear scheduled tasks if exist if (timeoutIdTokenRefresh) clearTimeout(timeoutIdTokenRefresh); if (timeoutIdSseOpen) clearTimeout(timeoutIdSseOpen);