Skip to content

Commit f8eb362

Browse files
Add SecureSplitHttpClient and PushManagerSecure for JWT management
1 parent b992a20 commit f8eb362

File tree

4 files changed

+450
-3
lines changed

4 files changed

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

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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { IDecodedJWTToken } from '../utils/jwt/types';
2+
13
export type IRequestOptions = {
24
method?: string,
35
headers?: Record<string, string>,
@@ -84,3 +86,17 @@ interface IEventSource {
8486
}
8587

8688
export type IEventSourceConstructor = new (url: string, eventSourceInitDict?: any) => IEventSource
89+
90+
// Auth credential (JWT) with decoded metadata, used by SecureSplitHttpClient
91+
export interface IAuthCredential {
92+
token: string
93+
pushEnabled: boolean
94+
decodedToken: IDecodedJWTToken
95+
channels: Record<string, string[]>
96+
connDelay?: number
97+
}
98+
99+
// AuthProvider interface for transparent JWT management
100+
export interface IAuthProvider {
101+
getAuthData(): Promise<IAuthCredential>
102+
}

0 commit comments

Comments
 (0)