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