Skip to content

Commit 6a3e486

Browse files
Merge pull request #504 from splitio/add-jwt-auth
[SDK Configs] Add JWT auth
2 parents 4b23e76 + 7dfb8e0 commit 6a3e486

19 files changed

Lines changed: 519 additions & 61 deletions

File tree

src/__tests__/testUtils/jwt.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { IJwtCredentialV3 } from '../../sync/streaming/AuthClient/types';
2+
3+
function toBase64Url(str: string) {
4+
return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
5+
}
6+
7+
export function makeJwtCredential(expInSeconds = 3600): IJwtCredentialV3 {
8+
const now = Math.floor(Date.now() / 1000);
9+
const header = toBase64Url(JSON.stringify({ alg: 'HS256' }));
10+
const decodedToken = { iat: now, exp: now + expInSeconds, 'x-ably-capability': '{"ch":["subscribe"]}' };
11+
const payload = toBase64Url(JSON.stringify(decodedToken));
12+
13+
return {
14+
token: `${header}.${payload}.sig`,
15+
decodedToken,
16+
channels: { ch: ['subscribe'] },
17+
config: {
18+
streaming: {
19+
enabled: true,
20+
delay: 60,
21+
}
22+
}
23+
};
24+
}

src/logger/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export const LOG_PREFIX_ENGINE_COMBINER = LOG_PREFIX_ENGINE + ':combiner: ';
122122
export const LOG_PREFIX_ENGINE_MATCHER = LOG_PREFIX_ENGINE + ':matcher: ';
123123
export const LOG_PREFIX_ENGINE_VALUE = LOG_PREFIX_ENGINE + ':value: ';
124124
export const LOG_PREFIX_SYNC = 'sync: ';
125+
export const LOG_PREFIX_SYNC_AUTH = 'sync:auth: ';
125126
export const LOG_PREFIX_SYNC_MANAGER = 'sync:sync-manager: ';
126127
export const LOG_PREFIX_SYNC_OFFLINE = 'sync:offline: ';
127128
export const LOG_PREFIX_SYNC_STREAMING = 'sync:streaming: ';

src/sdkClient/sdkLifecycle.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const COOLDOWN_TIME_IN_MILLIS = 1000;
77
* Creates an Sdk client, i.e., a base client with status, init, flush and destroy interface
88
*/
99
export function sdkLifecycleFactory(params: ISdkFactoryContext, isSharedClient?: boolean): { init(): void; flush(): Promise<void>; destroy(): Promise<void> } {
10-
const { sdkReadinessManager, syncManager, storage, settings, telemetryTracker, impressionsTracker, platform } = params;
10+
const { sdkReadinessManager, syncManager, storage, settings, telemetryTracker, impressionsTracker, platform, splitApi } = params;
1111

1212
let hasInit = false;
1313
let lastActionTime = 0;
@@ -68,6 +68,7 @@ export function sdkLifecycleFactory(params: ISdkFactoryContext, isSharedClient?:
6868

6969
// Stop background jobs
7070
syncManager && syncManager.stop();
71+
splitApi && splitApi.stop();
7172

7273
return __flush().then(() => {
7374
// Cleanup storage
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { authProviderFactory } from '../authProvider';
2+
import { Backoff } from '../../utils/Backoff';
3+
import { loggerMock } from '../../logger/__tests__/sdkLogger.mock';
4+
import { makeJwtCredential } from '../../__tests__/testUtils/jwt';
5+
6+
// Speed up backoff for tests
7+
Backoff.__TEST__BASE_MILLIS = 10;
8+
Backoff.__TEST__MAX_MILLIS = 50;
9+
10+
function mockSplitHttpClient() {
11+
return jest.fn(() => Promise.resolve({
12+
ok: true,
13+
status: 200,
14+
json: () => Promise.resolve(makeJwtCredential()),
15+
text: () => Promise.resolve('')
16+
}));
17+
}
18+
19+
function networkError(statusCode?: number) {
20+
const err: any = new Error('fetch failed');
21+
err.statusCode = statusCode;
22+
return err;
23+
}
24+
25+
const mockSettings = {
26+
urls: { auth: 'https://auth.split.io/api' },
27+
log: loggerMock,
28+
} as any;
29+
30+
const mockTelemetryTracker = { trackHttp: jest.fn(() => jest.fn()) } as any;
31+
32+
describe('authProviderFactory', () => {
33+
34+
test('credential() fetches and caches token', async () => {
35+
const splitHttpClient = mockSplitHttpClient();
36+
const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);
37+
38+
const cred = await provider.credential();
39+
expect(cred.token).toContain('.');
40+
expect(splitHttpClient).toHaveBeenCalledTimes(1);
41+
42+
// Second call returns cached
43+
const cred2 = await provider.credential();
44+
expect(cred2).toBe(cred);
45+
expect(splitHttpClient).toHaveBeenCalledTimes(1);
46+
});
47+
48+
test('credential() deduplicates concurrent calls', async () => {
49+
const splitHttpClient = mockSplitHttpClient();
50+
const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);
51+
52+
const [cred1, cred2] = await Promise.all([provider.credential(), provider.credential()]);
53+
expect(cred1).toBe(cred2);
54+
expect(splitHttpClient).toHaveBeenCalledTimes(1);
55+
});
56+
57+
test('invalidate() clears cache, next call fetches fresh', async () => {
58+
const splitHttpClient = mockSplitHttpClient();
59+
const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);
60+
61+
await provider.credential();
62+
provider.invalidate();
63+
64+
await provider.credential();
65+
expect(splitHttpClient).toHaveBeenCalledTimes(2);
66+
});
67+
68+
test('credential() refetches when token is expired', async () => {
69+
const splitHttpClient = mockSplitHttpClient();
70+
const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);
71+
72+
await provider.credential();
73+
expect(splitHttpClient).toHaveBeenCalledTimes(1);
74+
75+
provider.invalidate();
76+
await provider.credential();
77+
expect(splitHttpClient).toHaveBeenCalledTimes(2);
78+
});
79+
80+
test('4xx errors reject immediately without retry', async () => {
81+
const splitHttpClient = jest.fn(() => Promise.reject(networkError(401)));
82+
const provider = authProviderFactory(mockSettings, splitHttpClient as any, mockTelemetryTracker);
83+
84+
await expect(provider.credential()).rejects.toThrow('fetch failed');
85+
expect(splitHttpClient).toHaveBeenCalledTimes(1);
86+
});
87+
88+
test('retries on non-4xx errors with backoff', async () => {
89+
let callCount = 0;
90+
const splitHttpClient = jest.fn(() => {
91+
callCount++;
92+
if (callCount < 3) return Promise.reject(networkError());
93+
return Promise.resolve({
94+
ok: true, status: 200,
95+
json: () => Promise.resolve(makeJwtCredential()),
96+
text: () => Promise.resolve('')
97+
});
98+
});
99+
100+
const provider = authProviderFactory(mockSettings, splitHttpClient as any, mockTelemetryTracker);
101+
const cred = await provider.credential();
102+
103+
expect(cred.token).toContain('.');
104+
expect(splitHttpClient).toHaveBeenCalledTimes(3);
105+
});
106+
107+
test('stop() does not throw in any state', async () => {
108+
const splitHttpClient = mockSplitHttpClient();
109+
const provider = authProviderFactory(mockSettings, splitHttpClient, mockTelemetryTracker);
110+
111+
// Before any credential() call
112+
expect(() => provider.stop()).not.toThrow();
113+
114+
// After credential is cached
115+
await provider.credential();
116+
expect(() => provider.stop()).not.toThrow();
117+
118+
// After invalidate
119+
provider.invalidate();
120+
expect(() => provider.stop()).not.toThrow();
121+
122+
// While fetch is in-flight
123+
const splitHttpClient2 = jest.fn(() => new Promise(() => {})); // never resolves
124+
const provider2 = authProviderFactory(mockSettings, splitHttpClient2 as any, mockTelemetryTracker);
125+
provider2.credential();
126+
expect(() => provider2.stop()).not.toThrow();
127+
});
128+
129+
test('stop() prevents in-flight request from rejecting or rescheduling', async () => {
130+
let rejectFetch: (err: any) => void;
131+
const splitHttpClient = jest.fn(() => new Promise((_, reject) => { rejectFetch = reject; }));
132+
const provider = authProviderFactory(mockSettings, splitHttpClient as any, mockTelemetryTracker);
133+
134+
const promise = provider.credential();
135+
provider.stop();
136+
137+
// Simulate the in-flight fetch failing after stop
138+
rejectFetch!(networkError());
139+
140+
// Should resolve (not reject) with last cached credential (undefined in this case), and no retry scheduled
141+
const result = await promise;
142+
expect(result).toEqual(undefined);
143+
expect(splitHttpClient).toHaveBeenCalledTimes(1);
144+
});
145+
146+
test('stop() cancels pending retries', async () => {
147+
const splitHttpClient = jest.fn(() => Promise.reject(networkError()));
148+
const provider = authProviderFactory(mockSettings, splitHttpClient as any, mockTelemetryTracker);
149+
150+
const promise = provider.credential();
151+
// Let first fetch fail and backoff schedule
152+
await new Promise(r => setTimeout(r, 5));
153+
154+
provider.stop();
155+
156+
// Promise should never resolve/reject after stop (pending timeout cleared)
157+
const result = await Promise.race([
158+
promise.then(() => 'resolved').catch(() => 'rejected'),
159+
new Promise(r => setTimeout(() => r('timeout'), 100))
160+
]);
161+
expect(result).toBe('timeout');
162+
});
163+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { secureSplitHttpClientFactory } from '../secureSplitHttpClient';
2+
import { Backoff } from '../../utils/Backoff';
3+
import { makeJwtCredential } from '../../__tests__/testUtils/jwt';
4+
5+
// Speed up backoff for tests
6+
Backoff.__TEST__BASE_MILLIS = 10;
7+
Backoff.__TEST__MAX_MILLIS = 50;
8+
9+
const mockSettings = {
10+
core: { authorizationKey: 'sdk-key' },
11+
log: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
12+
version: '1.0.0',
13+
runtime: {},
14+
urls: { auth: 'https://auth.split.io/api' },
15+
sync: { requestOptions: undefined }
16+
} as any;
17+
18+
const mockTelemetryTracker = { trackHttp: jest.fn(() => jest.fn()) } as any;
19+
20+
21+
const authResponse = { ok: true, status: 200, json: () => Promise.resolve(makeJwtCredential()), text: () => Promise.resolve('') };
22+
23+
function createSecureSplitHttpClient(configsHandler: (callCount: number) => any) {
24+
let configsCallCount = 0;
25+
const fetchImpl = jest.fn((url: string) => {
26+
if (url.includes('/auth')) return Promise.resolve(authResponse);
27+
configsCallCount++;
28+
return configsHandler(configsCallCount);
29+
});
30+
const client = secureSplitHttpClientFactory(mockSettings, { getFetch: () => fetchImpl, getOptions: () => undefined }, mockTelemetryTracker);
31+
return { client, fetchImpl };
32+
}
33+
34+
describe('secureSplitHttpClientFactory', () => {
35+
36+
test('injects JWT Authorization header', async () => {
37+
const { client, fetchImpl } = createSecureSplitHttpClient(() => Promise.resolve({ ok: true, status: 200 }));
38+
39+
await client('http://api/configs');
40+
41+
const calls = fetchImpl.mock.calls as any[];
42+
const configsCall = calls.find(c => c[0].includes('/configs'));
43+
expect(configsCall[1].headers.Authorization).toMatch(/^Bearer .+\..+\..+$/);
44+
});
45+
46+
test('retries once on 401 with fresh token', async () => {
47+
const { client, fetchImpl } = createSecureSplitHttpClient((count) => {
48+
if (count === 1) return Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') });
49+
return Promise.resolve({ ok: true, status: 200 });
50+
});
51+
52+
await client('http://api/configs');
53+
54+
const authCalls = (fetchImpl.mock.calls as any[]).filter(c => c[0].includes('/auth'));
55+
expect(authCalls.length).toBe(2);
56+
});
57+
58+
test('does not retry on non-401 errors', async () => {
59+
const { client, fetchImpl } = createSecureSplitHttpClient(() => Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('Server Error') }));
60+
61+
await expect(client('http://api/configs')).rejects.toThrow();
62+
const authCalls = (fetchImpl.mock.calls as any[]).filter(c => c[0].includes('/auth'));
63+
expect(authCalls.length).toBe(1);
64+
});
65+
});

src/services/authProvider.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { ISplitHttpClient, NetworkError } from './types';
2+
import { IJwtCredentialV3 } from '../sync/streaming/AuthClient/types';
3+
import { authenticateFactory } from '../sync/streaming/AuthClient';
4+
import { Backoff } from '../utils/Backoff';
5+
import { LOG_PREFIX_SYNC_AUTH } from '../logger/constants';
6+
import { ISettings } from '../types';
7+
import { TOKEN } from '../utils/constants';
8+
import { ITelemetryTracker } from '../trackers/types';
9+
10+
const SKEW_SECONDS = 30;
11+
12+
function isExpired(credential: IJwtCredentialV3): boolean {
13+
return Date.now() / 1000 + SKEW_SECONDS >= credential.decodedToken.exp;
14+
}
15+
16+
export interface IAuthProvider {
17+
credential(): Promise<IJwtCredentialV3>;
18+
invalidate(): void;
19+
stop(): void;
20+
}
21+
22+
/**
23+
* Factory of AuthProvider, which provides JWT credentials for authenticated HTTP requests.
24+
* Credentials are fetched lazily on demand, cached in memory, and retried with backoff on failure.
25+
*/
26+
export function authProviderFactory(settings: ISettings, splitHttpClient: ISplitHttpClient, telemetryTracker: ITelemetryTracker): IAuthProvider {
27+
28+
const { urls, log } = settings;
29+
30+
function fetchAuth() {
31+
let url = `${urls.auth}/v3/auth?capabilities=config`;
32+
return splitHttpClient(url, undefined, telemetryTracker.trackHttp(TOKEN));
33+
}
34+
35+
const authenticate = authenticateFactory(fetchAuth);
36+
const backoff = new Backoff(fetchCredential);
37+
38+
let cachedCredential: IJwtCredentialV3 | undefined;
39+
let inFlightPromise: Promise<IJwtCredentialV3> | undefined;
40+
let stopped = false;
41+
42+
function fetchCredential(): Promise<IJwtCredentialV3> {
43+
return authenticate().then((credential: IJwtCredentialV3) => {
44+
log.info(LOG_PREFIX_SYNC_AUTH + 'credential fetched successfully');
45+
cachedCredential = credential;
46+
inFlightPromise = undefined;
47+
backoff.reset();
48+
return credential;
49+
}).catch((error: NetworkError) => {
50+
// Avoid rejected promises and unnecessary retries after stop()
51+
if (stopped) return cachedCredential!;
52+
53+
if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
54+
log.error(LOG_PREFIX_SYNC_AUTH + 'non-retryable error fetching credential (status ' + error.statusCode + '): ' + error.message);
55+
inFlightPromise = undefined;
56+
throw error;
57+
}
58+
59+
log.warn(LOG_PREFIX_SYNC_AUTH + 'credential fetch failed (attempt ' + (backoff.attempts + 1) + '). Error: ' + error.message);
60+
return backoff.scheduleCallAsync();
61+
});
62+
}
63+
64+
return {
65+
credential(): Promise<IJwtCredentialV3> {
66+
if (cachedCredential && !isExpired(cachedCredential)) {
67+
return Promise.resolve(cachedCredential);
68+
}
69+
70+
if (cachedCredential) log.debug(LOG_PREFIX_SYNC_AUTH + 'cached credential expired');
71+
72+
return inFlightPromise || (inFlightPromise = fetchCredential());
73+
},
74+
75+
invalidate() {
76+
cachedCredential = undefined;
77+
},
78+
79+
stop() {
80+
stopped = true;
81+
cachedCredential = undefined;
82+
inFlightPromise = undefined;
83+
backoff.reset();
84+
}
85+
};
86+
}

0 commit comments

Comments
 (0)