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
24 changes: 24 additions & 0 deletions src/__tests__/testUtils/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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): 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"]}' };
const payload = toBase64Url(JSON.stringify(decodedToken));

return {
token: `${header}.${payload}.sig`,
decodedToken,
channels: { ch: ['subscribe'] },
config: {
streaming: {
enabled: true,
delay: 60,
}
}
};
}
1 change: 1 addition & 0 deletions src/logger/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ';
Expand Down
3 changes: 2 additions & 1 deletion src/sdkClient/sdkLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>; destroy(): Promise<void> } {
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;
Expand Down Expand Up @@ -68,6 +68,7 @@ export function sdkLifecycleFactory(params: ISdkFactoryContext, isSharedClient?:

// Stop background jobs
syncManager && syncManager.stop();
splitApi && splitApi.stop();

return __flush().then(() => {
// Cleanup storage
Expand Down
163 changes: 163 additions & 0 deletions src/services/__tests__/authProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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 mockSplitHttpClient() {
return jest.fn(() => Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(makeJwtCredential()),
text: () => Promise.resolve('')
}));
}

function networkError(statusCode?: number) {
const err: any = new Error('fetch failed');
err.statusCode = statusCode;
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 splitHttpClient = mockSplitHttpClient();
const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);

const cred = await provider.credential();
expect(cred.token).toContain('.');
expect(splitHttpClient).toHaveBeenCalledTimes(1);

// Second call returns cached
const cred2 = await provider.credential();
expect(cred2).toBe(cred);
expect(splitHttpClient).toHaveBeenCalledTimes(1);
});

test('credential() deduplicates concurrent calls', async () => {
const splitHttpClient = mockSplitHttpClient();
const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);

const [cred1, cred2] = await Promise.all([provider.credential(), provider.credential()]);
expect(cred1).toBe(cred2);
expect(splitHttpClient).toHaveBeenCalledTimes(1);
});

test('invalidate() clears cache, next call fetches fresh', async () => {
const splitHttpClient = mockSplitHttpClient();
const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);

await provider.credential();
provider.invalidate();

await provider.credential();
expect(splitHttpClient).toHaveBeenCalledTimes(2);
});

test('credential() refetches when token is expired', async () => {
const splitHttpClient = mockSplitHttpClient();
const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);

await provider.credential();
expect(splitHttpClient).toHaveBeenCalledTimes(1);

provider.invalidate();
await provider.credential();
expect(splitHttpClient).toHaveBeenCalledTimes(2);
});

test('4xx errors reject immediately without retry', async () => {
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(splitHttpClient).toHaveBeenCalledTimes(1);
});

test('retries on non-4xx errors with backoff', async () => {
let callCount = 0;
const splitHttpClient = jest.fn(() => {
callCount++;
if (callCount < 3) return Promise.reject(networkError());
return Promise.resolve({
ok: true, status: 200,
json: () => Promise.resolve(makeJwtCredential()),
text: () => Promise.resolve('')
});
});

const provider = authProviderFactory(mockSettings, splitHttpClient as any, mockTelemetryTracker);
const cred = await provider.credential();

expect(cred.token).toContain('.');
expect(splitHttpClient).toHaveBeenCalledTimes(3);
});

test('stop() does not throw in any state', async () => {
const splitHttpClient = mockSplitHttpClient();
const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);

// 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 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 splitHttpClient = jest.fn(() => new Promise((_, reject) => { rejectFetch = reject; }));
const provider = authProviderFactory(mockSettings, splitHttpClient as any, mockTelemetryTracker);

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(splitHttpClient).toHaveBeenCalledTimes(1);
});

test('stop() cancels pending retries', async () => {
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
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');
});
});
65 changes: 65 additions & 0 deletions src/services/__tests__/secureSplitHttpClient.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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;

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;

const mockTelemetryTracker = { trackHttp: jest.fn(() => jest.fn()) } as any;


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 { client, fetchImpl } = createSecureSplitHttpClient(() => Promise.resolve({ ok: true, status: 200 }));

await client('http://api/configs');

const calls = fetchImpl.mock.calls as any[];
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 () => {
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 });
});

await client('http://api/configs');

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 { client, fetchImpl } = createSecureSplitHttpClient(() => Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('Server Error') }));

await expect(client('http://api/configs')).rejects.toThrow();
const authCalls = (fetchImpl.mock.calls as any[]).filter(c => c[0].includes('/auth'));
expect(authCalls.length).toBe(1);
});
});
86 changes: 86 additions & 0 deletions src/services/authProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { ISplitHttpClient, NetworkError } from './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';
import { ISettings } from '../types';
import { TOKEN } from '../utils/constants';
import { ITelemetryTracker } from '../trackers/types';

const SKEW_SECONDS = 30;

function isExpired(credential: IJwtCredentialV3): boolean {
return Date.now() / 1000 + SKEW_SECONDS >= credential.decodedToken.exp;
}

export interface IAuthProvider {
credential(): Promise<IJwtCredentialV3>;
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(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);

let cachedCredential: IJwtCredentialV3 | undefined;
let inFlightPromise: Promise<IJwtCredentialV3> | undefined;
let stopped = false;

function fetchCredential(): Promise<IJwtCredentialV3> {
return authenticate().then((credential: IJwtCredentialV3) => {
log.info(LOG_PREFIX_SYNC_AUTH + 'credential fetched successfully');
cachedCredential = credential;
inFlightPromise = undefined;
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) {
log.error(LOG_PREFIX_SYNC_AUTH + 'non-retryable error fetching credential (status ' + error.statusCode + '): ' + error.message);
inFlightPromise = undefined;
throw error;
}

log.warn(LOG_PREFIX_SYNC_AUTH + 'credential fetch failed (attempt ' + (backoff.attempts + 1) + '). Error: ' + error.message);
return backoff.scheduleCallAsync();
});
}

return {
credential(): Promise<IJwtCredentialV3> {
if (cachedCredential && !isExpired(cachedCredential)) {
return Promise.resolve(cachedCredential);
}

if (cachedCredential) log.debug(LOG_PREFIX_SYNC_AUTH + 'cached credential expired');

return inFlightPromise || (inFlightPromise = fetchCredential());
},

invalidate() {
cachedCredential = undefined;
},

stop() {
stopped = true;
cachedCredential = undefined;
inFlightPromise = undefined;
backoff.reset();
}
};
}
Loading