Skip to content

Commit 5ed8ea4

Browse files
Add SecureSplitHttpClient and PushManagerSecure for JWT management
AI-Session-Id: 5bb45bda-30a5-42e2-ba2a-9d3aeeac7a31 AI-Tool: claude-code AI-Model: unknown
1 parent b992a20 commit 5ed8ea4

File tree

4 files changed

+424
-3
lines changed

4 files changed

+424
-3
lines changed
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { IRequestOptions, IResponse, ISplitHttpClient, NetworkError, ISecureSplitHttpClient } from './types';
2+
import { objectAssign } from '../utils/lang/objectAssign';
3+
import { ERROR_HTTP, ERROR_CLIENT_CANNOT_GET_READY } from '../logger/constants';
4+
import { ISettings } from '../types';
5+
import { IPlatform } from '../sdkFactory/types';
6+
import { decorateHeaders, removeNonISO88591 } from './decorateHeaders';
7+
import { splitHttpClientFactory } from './splitHttpClient';
8+
import { timeout } from '../utils/promise/timeout';
9+
import { decodeJWTtoken } from '../utils/jwt';
10+
import { SECONDS_BEFORE_EXPIRATION } from '../sync/streaming/constants';
11+
import { IAuthToken } from '../sync/streaming/AuthClient/types';
12+
13+
const PENDING_FETCH_ERROR_TIMEOUT = 100;
14+
const messageNoFetch = 'Global fetch API is not available.';
15+
16+
/**
17+
* Creates an auth data manager that transparently handles JWT credential lifecycle:
18+
* fetching, caching, expiry checks, invalidation, and deduplication of concurrent requests.
19+
*
20+
* @param innerHttpClient - standard HTTP client (authenticated with SDK key) used to call the auth endpoint
21+
* @param settings - SDK settings, used to build the auth endpoint URL
22+
*/
23+
function authDataManagerFactory(innerHttpClient: ISplitHttpClient, settings: ISettings) {
24+
let currentToken: IAuthToken | null = null;
25+
let pendingRequest: Promise<IAuthToken> | null = null;
26+
27+
function fetchToken(): Promise<IAuthToken> {
28+
const url = settings.urls.auth + '/v2/auth?s=' + settings.sync.flagSpecVersion;
29+
return innerHttpClient(url)
30+
.then(function (resp) { return resp.json(); })
31+
.then(function (json) {
32+
let authToken: IAuthToken;
33+
if (json.token) {
34+
const decodedToken = decodeJWTtoken(json.token);
35+
if (typeof decodedToken.iat !== 'number' || typeof decodedToken.exp !== 'number') {
36+
throw new Error('token properties "issuedAt" (iat) or "expiration" (exp) are missing or invalid');
37+
}
38+
const channels = JSON.parse(decodedToken['x-ably-capability']);
39+
authToken = objectAssign({ decodedToken, channels }, json) as IAuthToken;
40+
} else {
41+
authToken = json as IAuthToken;
42+
}
43+
currentToken = authToken;
44+
return authToken;
45+
});
46+
}
47+
48+
function isExpired(token: IAuthToken): boolean {
49+
// Consider token expired SECONDS_BEFORE_EXPIRATION (600s) before actual expiry,
50+
// so that proactive refresh (e.g., for streaming) gets a fresh token
51+
return !token.pushEnabled || Date.now() / 1000 >= token.decodedToken.exp - SECONDS_BEFORE_EXPIRATION;
52+
}
53+
54+
return {
55+
getAuthData(): Promise<IAuthToken> {
56+
// Return cached token if valid and not expired
57+
if (currentToken && !isExpired(currentToken)) {
58+
return Promise.resolve(currentToken);
59+
}
60+
61+
// Deduplicate concurrent requests
62+
if (pendingRequest) return pendingRequest;
63+
64+
pendingRequest = fetchToken().then(
65+
function (token) {
66+
pendingRequest = null;
67+
return token;
68+
},
69+
function (error) {
70+
pendingRequest = null;
71+
throw error;
72+
}
73+
);
74+
75+
return pendingRequest;
76+
},
77+
78+
// Internal: used by the secure HTTP client on 401 to force a fresh token
79+
invalidate() {
80+
currentToken = null;
81+
}
82+
};
83+
}
84+
85+
/**
86+
* Factory of Secure Split HTTP clients. Like `splitHttpClientFactory`, but transparently
87+
* manages JWT authentication: obtains a JWT from the auth endpoint (using the SDK key internally),
88+
* caches it, and retries once on 401 responses with a fresh token.
89+
*
90+
* @param settings - SDK settings
91+
* @param platform - object containing environment-specific dependencies
92+
* @returns an object with `httpClient` (ISplitHttpClient) and `getAuthData` to retrieve current auth token
93+
*/
94+
export function secureSplitHttpClientFactory(
95+
settings: ISettings,
96+
platform: Pick<IPlatform, 'getOptions' | 'getFetch'>
97+
): ISecureSplitHttpClient {
98+
99+
const { getOptions, getFetch } = platform;
100+
const { log, version, runtime: { ip, hostname } } = settings;
101+
const options = getOptions && getOptions(settings);
102+
const fetch = getFetch && getFetch(settings);
103+
104+
// if fetch is not available, log Error
105+
if (!fetch) log.error(ERROR_CLIENT_CANNOT_GET_READY, [messageNoFetch]);
106+
107+
const commonHeaders: Record<string, string> = {
108+
'Accept': 'application/json',
109+
'Content-Type': 'application/json',
110+
'SplitSDKVersion': version
111+
};
112+
113+
if (ip) commonHeaders['SplitSDKMachineIP'] = ip;
114+
if (hostname) commonHeaders['SplitSDKMachineName'] = removeNonISO88591(hostname);
115+
116+
// Inner standard HTTP client for auth endpoint calls (authenticates with SDK key)
117+
const innerHttpClient = splitHttpClientFactory(settings, platform);
118+
const authDataManager = authDataManagerFactory(innerHttpClient, settings);
119+
120+
function doFetch(url: string, request: Record<string, any>): Promise<IResponse> {
121+
return fetch!(url, request)
122+
.then(function (response) {
123+
if (!response.ok) {
124+
return timeout(PENDING_FETCH_ERROR_TIMEOUT, response.text()).then(
125+
function (message) { return Promise.reject({ response: response, message: message }); },
126+
function () { return Promise.reject({ response: response }); }
127+
);
128+
}
129+
return response;
130+
});
131+
}
132+
133+
function buildRequest(reqOpts: IRequestOptions, authToken: string): Record<string, any> {
134+
const headers = objectAssign({}, commonHeaders, { 'Authorization': 'Bearer ' + authToken }, reqOpts.headers || {});
135+
return objectAssign({
136+
headers: decorateHeaders(settings, headers),
137+
method: reqOpts.method || 'GET',
138+
body: reqOpts.body
139+
}, options);
140+
}
141+
142+
function handleError(error: any, url: string, logErrorsAsInfo: boolean): NetworkError {
143+
const resp = error && error.response;
144+
let msg = '';
145+
146+
if (resp) {
147+
switch (resp.status) {
148+
case 404: msg = 'Invalid SDK key or resource not found.';
149+
break;
150+
default: msg = error.message;
151+
break;
152+
}
153+
} else {
154+
msg = error.message || 'Network Error';
155+
}
156+
157+
if (!resp || resp.status !== 403) {
158+
log[logErrorsAsInfo ? 'info' : 'error'](ERROR_HTTP, [resp ? 'status code ' + resp.status : 'no status code', url, msg]);
159+
}
160+
161+
const networkError: NetworkError = new Error(msg);
162+
networkError.statusCode = resp && resp.status;
163+
return networkError;
164+
}
165+
166+
function httpClient(url: string, reqOpts: IRequestOptions = {}, latencyTracker: (error?: NetworkError) => void = function () { }, logErrorsAsInfo: boolean = false): Promise<IResponse> {
167+
if (!fetch) return Promise.reject(new Error(messageNoFetch));
168+
169+
return authDataManager.getAuthData()
170+
.then(function (authToken) {
171+
const request = buildRequest(reqOpts, authToken.token);
172+
return doFetch(url, request)
173+
.then(function (response) {
174+
latencyTracker();
175+
return response;
176+
})
177+
.catch(function (error) {
178+
const resp = error && error.response;
179+
180+
// On 401, invalidate credential and retry once with a fresh token
181+
if (resp && resp.status === 401) {
182+
authDataManager.invalidate();
183+
return authDataManager.getAuthData()
184+
.then(function (freshToken) {
185+
const retryRequest = buildRequest(reqOpts, freshToken.token);
186+
return doFetch(url, retryRequest)
187+
.then(function (response) {
188+
latencyTracker();
189+
return response;
190+
});
191+
});
192+
}
193+
194+
throw error;
195+
});
196+
})
197+
.catch(function (error) {
198+
const networkError = handleError(error, url, logErrorsAsInfo);
199+
latencyTracker(networkError);
200+
throw networkError;
201+
});
202+
}
203+
204+
return {
205+
httpClient: httpClient,
206+
getAuthData: authDataManager.getAuthData
207+
};
208+
}

src/services/splitApi.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IPlatform } from '../sdkFactory/types';
22
import { ISettings } from '../types';
33
import { splitHttpClientFactory } from './splitHttpClient';
4-
import { ISplitApi } from './types';
4+
import { ISplitApi, ISplitHttpClient } from './types';
55
import { objectAssign } from '../utils/lang/objectAssign';
66
import { ITelemetryTracker } from '../trackers/types';
77
import { SPLITS, IMPRESSIONS, IMPRESSIONS_COUNT, EVENTS, TELEMETRY, TOKEN, SEGMENT, MEMBERSHIPS } from '../utils/constants';
@@ -19,17 +19,19 @@ function userKeyToQueryParam(userKey: string) {
1919
* @param settings - validated settings object
2020
* @param platform - object containing environment-specific dependencies
2121
* @param telemetryTracker - telemetry tracker
22+
* @param _splitHttpClient - optional split http client to use instead of the default one
2223
*/
2324
export function splitApiFactory(
2425
settings: ISettings,
2526
platform: Pick<IPlatform, 'getOptions' | 'getFetch'>,
26-
telemetryTracker: ITelemetryTracker
27+
telemetryTracker: ITelemetryTracker,
28+
_splitHttpClient?: ISplitHttpClient
2729
): ISplitApi {
2830

2931
const urls = settings.urls;
3032
const filterQueryString = settings.sync.__splitFiltersValidation && settings.sync.__splitFiltersValidation.queryString;
3133
const SplitSDKImpressionsMode = settings.sync.impressionsMode;
32-
const splitHttpClient = splitHttpClientFactory(settings, platform);
34+
const splitHttpClient = _splitHttpClient || splitHttpClientFactory(settings, platform);
3335

3436
return {
3537
// @TODO throw errors if health check requests fail, to log them in the Synchronizer

src/services/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { IAuthToken } from '../sync/streaming/AuthClient/types';
2+
13
export type IRequestOptions = {
24
method?: string,
35
headers?: Record<string, string>,
@@ -33,6 +35,12 @@ export type IHealthCheckAPI = () => Promise<boolean>
3335

3436
export type ISplitHttpClient = (url: string, options?: IRequestOptions, latencyTracker?: (error?: NetworkError) => void, logErrorsAsInfo?: boolean) => Promise<IResponse>
3537

38+
export type ISecureSplitHttpClient = {
39+
httpClient: ISplitHttpClient;
40+
// Expose the auth token for the SSEClient to use
41+
getAuthData(): Promise<IAuthToken>
42+
}
43+
3644
export type IFetchAuth = (userKeys?: string[]) => Promise<IResponse>
3745

3846
export type IFetchSplitChanges = (since: number, noCache?: boolean, till?: number, rbSince?: number) => Promise<IResponse>

0 commit comments

Comments
 (0)