@oxy.so/core 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +112 -163
- package/dist/cjs/OxyServices.base.js +4 -2
- package/dist/cjs/boot/sessionColdBoot.js +3 -0
- package/dist/cjs/mixins/OxyServices.user.js +2 -5
- package/dist/cjs/server/cors.js +0 -1
- package/dist/cjs/session/refresh.js +71 -7
- package/dist/cjs/utils/errorUtils.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +112 -163
- package/dist/esm/OxyServices.base.js +4 -2
- package/dist/esm/boot/sessionColdBoot.js +3 -0
- package/dist/esm/mixins/OxyServices.user.js +2 -5
- package/dist/esm/server/cors.js +0 -1
- package/dist/esm/session/refresh.js +70 -6
- package/dist/esm/utils/errorUtils.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +59 -7
- package/dist/types/OxyServices.base.d.ts +3 -1
- package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
- package/dist/types/mixins/OxyServices.user.d.ts +1 -4
- package/dist/types/session/refresh.d.ts +21 -0
- package/dist/types/utils/errorUtils.d.ts +1 -1
- package/package.json +1 -1
- package/src/HttpService.ts +116 -187
- package/src/OxyServices.base.ts +4 -2
- package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
- package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
- package/src/__tests__/inSessionRefresh.test.ts +53 -0
- package/src/__tests__/linkedClient.test.ts +0 -7
- package/src/__tests__/nearExpiryMintStorm.test.ts +165 -0
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
- package/src/boot/sessionColdBoot.ts +3 -0
- package/src/mixins/OxyServices.user.ts +2 -5
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
- package/src/mixins/__tests__/topics.test.ts +2 -3
- package/src/server/cors.ts +0 -1
- package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
- package/src/session/__tests__/refresh.test.ts +115 -2
- package/src/session/refresh.ts +77 -6
- package/src/utils/errorUtils.ts +1 -1
package/src/OxyServices.base.ts
CHANGED
|
@@ -212,10 +212,12 @@ export class OxyServicesBase {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
|
-
* Clear stored authentication tokens
|
|
215
|
+
* Clear stored authentication tokens and end the local session: a re-mint
|
|
216
|
+
* already in flight plants nothing, and no new one runs until a token is set
|
|
217
|
+
* again (see `HttpService.endSession`).
|
|
216
218
|
*/
|
|
217
219
|
public clearTokens(): void {
|
|
218
|
-
this.httpService.
|
|
220
|
+
this.httpService.endSession();
|
|
219
221
|
this._cachedUserId = undefined;
|
|
220
222
|
this._cachedAccessToken = null;
|
|
221
223
|
}
|
|
@@ -46,8 +46,8 @@ describe('HttpService form-urlencoded bodies', () => {
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* POST a form body and return the token-endpoint call. Selected by URL
|
|
50
|
-
*
|
|
49
|
+
* POST a form body and return the token-endpoint call. Selected by URL so
|
|
50
|
+
* any preflight a future change adds cannot shift the call under test.
|
|
51
51
|
*/
|
|
52
52
|
async function postForm(body: URLSearchParams): Promise<FetchCall> {
|
|
53
53
|
const calls: FetchCall[] = [];
|
|
@@ -111,9 +111,6 @@ describe('HttpService form-urlencoded bodies', () => {
|
|
|
111
111
|
it('prefers OAuth error_description over the bare error code', async () => {
|
|
112
112
|
globalThis.fetch = async (input) => {
|
|
113
113
|
const url = String(input);
|
|
114
|
-
if (url.endsWith('/csrf-token')) {
|
|
115
|
-
return jsonResponse({ csrfToken: 'csrf-test' });
|
|
116
|
-
}
|
|
117
114
|
return new Response(
|
|
118
115
|
JSON.stringify({
|
|
119
116
|
error: 'invalid_grant',
|
|
@@ -31,7 +31,12 @@ function readHeaders(init: RequestInit | undefined): Record<string, string> {
|
|
|
31
31
|
return headers as Record<string, string>;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Writes carry exactly one credential: the bearer, when there is one. Oxy's API
|
|
36
|
+
* has no CSRF layer (issue #1044) and the SDK neither fetches `/csrf-token` nor
|
|
37
|
+
* sends `X-CSRF-Token` / `X-Native-App`, for its own API or a linked one.
|
|
38
|
+
*/
|
|
39
|
+
describe('HttpService writes', () => {
|
|
35
40
|
const originalFetch = globalThis.fetch;
|
|
36
41
|
|
|
37
42
|
afterEach(() => {
|
|
@@ -39,13 +44,18 @@ describe('HttpService CSRF behavior', () => {
|
|
|
39
44
|
jest.restoreAllMocks();
|
|
40
45
|
});
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
function recordFetches(respond: (url: string) => Response = () => jsonResponse({ ok: true })): FetchCall[] {
|
|
43
48
|
const calls: FetchCall[] = [];
|
|
44
49
|
globalThis.fetch = async (input, init) => {
|
|
45
50
|
const url = String(input);
|
|
46
51
|
calls.push({ url, init });
|
|
47
|
-
return
|
|
52
|
+
return respond(url);
|
|
48
53
|
};
|
|
54
|
+
return calls;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it('sends a bearer-authenticated write as a single request', async () => {
|
|
58
|
+
const calls = recordFetches();
|
|
49
59
|
|
|
50
60
|
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
51
61
|
const accessToken = createJwt({
|
|
@@ -56,20 +66,31 @@ describe('HttpService CSRF behavior', () => {
|
|
|
56
66
|
|
|
57
67
|
await http.post('/posts', { text: 'hello' });
|
|
58
68
|
|
|
59
|
-
expect(calls).
|
|
60
|
-
expect(calls[0].url).toBe('https://api.mention.earth/posts');
|
|
69
|
+
expect(calls.map((call) => call.url)).toEqual(['https://api.mention.earth/posts']);
|
|
61
70
|
const headers = readHeaders(calls[0].init);
|
|
62
71
|
expect(headers.Authorization).toBe(`Bearer ${accessToken}`);
|
|
63
72
|
expect(headers['X-CSRF-Token']).toBeUndefined();
|
|
73
|
+
expect(headers['X-Native-App']).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('sends a write without a bearer as a single request with no CSRF preflight', async () => {
|
|
77
|
+
const calls = recordFetches();
|
|
78
|
+
|
|
79
|
+
const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
|
|
80
|
+
|
|
81
|
+
await http.post('/users/by-ids', { ids: ['user_1'] });
|
|
82
|
+
|
|
83
|
+
// POSITIVE CONTROL: the write itself went out, so the absence below is not
|
|
84
|
+
// the absence of any request at all.
|
|
85
|
+
expect(calls.map((call) => call.url)).toEqual(['https://api.oxy.so/users/by-ids']);
|
|
86
|
+
const headers = readHeaders(calls[0].init);
|
|
87
|
+
expect(headers.Authorization).toBeUndefined();
|
|
88
|
+
expect(headers['X-CSRF-Token']).toBeUndefined();
|
|
89
|
+
expect(headers['X-Native-App']).toBeUndefined();
|
|
64
90
|
});
|
|
65
91
|
|
|
66
92
|
it('keeps a valid near-expiry bearer token when preflight refresh cannot refresh', async () => {
|
|
67
|
-
const calls
|
|
68
|
-
globalThis.fetch = async (input, init) => {
|
|
69
|
-
const url = String(input);
|
|
70
|
-
calls.push({ url, init });
|
|
71
|
-
return jsonResponse({ ok: true });
|
|
72
|
-
};
|
|
93
|
+
const calls = recordFetches();
|
|
73
94
|
|
|
74
95
|
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
75
96
|
const accessToken = createJwt({
|
|
@@ -86,26 +107,12 @@ describe('HttpService CSRF behavior', () => {
|
|
|
86
107
|
await http.post('/posts', { text: 'hello' });
|
|
87
108
|
|
|
88
109
|
expect(refreshAttempts).toBe(1);
|
|
89
|
-
expect(calls).
|
|
90
|
-
expect(calls[0].
|
|
91
|
-
const headers = readHeaders(calls[0].init);
|
|
92
|
-
expect(headers.Authorization).toBe(`Bearer ${accessToken}`);
|
|
93
|
-
expect(headers['X-CSRF-Token']).toBeUndefined();
|
|
110
|
+
expect(calls.map((call) => call.url)).toEqual(['https://api.mention.earth/posts']);
|
|
111
|
+
expect(readHeaders(calls[0].init).Authorization).toBe(`Bearer ${accessToken}`);
|
|
94
112
|
});
|
|
95
113
|
|
|
96
114
|
it('does not use an expired bearer token when preflight refresh cannot refresh', async () => {
|
|
97
|
-
const calls
|
|
98
|
-
globalThis.fetch = async (input, init) => {
|
|
99
|
-
const url = String(input);
|
|
100
|
-
calls.push({ url, init });
|
|
101
|
-
if (url.endsWith('/csrf-token')) {
|
|
102
|
-
return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
|
|
103
|
-
status: 200,
|
|
104
|
-
headers: { 'content-type': 'application/json' },
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
return jsonResponse({ ok: true });
|
|
108
|
-
};
|
|
115
|
+
const calls = recordFetches();
|
|
109
116
|
|
|
110
117
|
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
111
118
|
const accessToken = createJwt({
|
|
@@ -122,48 +129,28 @@ describe('HttpService CSRF behavior', () => {
|
|
|
122
129
|
await http.post('/posts', { text: 'hello' });
|
|
123
130
|
|
|
124
131
|
expect(refreshAttempts).toBe(1);
|
|
125
|
-
expect(calls.map((call) => call.url)).toEqual([
|
|
126
|
-
|
|
127
|
-
'https://api.mention.earth/posts',
|
|
128
|
-
]);
|
|
129
|
-
const headers = readHeaders(calls[1].init);
|
|
130
|
-
expect(headers.Authorization).toBeUndefined();
|
|
131
|
-
expect(headers['X-CSRF-Token']).toBe('csrf_1');
|
|
132
|
+
expect(calls.map((call) => call.url)).toEqual(['https://api.mention.earth/posts']);
|
|
133
|
+
expect(readHeaders(calls[0].init).Authorization).toBeUndefined();
|
|
132
134
|
});
|
|
133
135
|
|
|
134
|
-
it('
|
|
135
|
-
const calls
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
|
|
141
|
-
status: 200,
|
|
136
|
+
it('does not retry a 403, whatever its code says', async () => {
|
|
137
|
+
const calls = recordFetches(
|
|
138
|
+
() =>
|
|
139
|
+
new Response(JSON.stringify({ code: 'CSRF_TOKEN_INVALID' }), {
|
|
140
|
+
status: 403,
|
|
141
|
+
statusText: 'Forbidden',
|
|
142
142
|
headers: { 'content-type': 'application/json' },
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
return jsonResponse({ ok: true });
|
|
146
|
-
};
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
147
145
|
|
|
148
146
|
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
149
147
|
|
|
150
|
-
await http.post('/posts', { text: 'hello' });
|
|
151
|
-
|
|
152
|
-
expect(calls.map((call) => call.url)).toEqual([
|
|
153
|
-
'https://api.mention.earth/csrf-token',
|
|
154
|
-
'https://api.mention.earth/posts',
|
|
155
|
-
]);
|
|
156
|
-
const headers = readHeaders(calls[1].init);
|
|
157
|
-
expect(headers.Authorization).toBeUndefined();
|
|
158
|
-
expect(headers['X-CSRF-Token']).toBe('csrf_1');
|
|
148
|
+
await expect(http.post('/posts', { text: 'hello' })).rejects.toBeDefined();
|
|
149
|
+
expect(calls.map((call) => call.url)).toEqual(['https://api.mention.earth/posts']);
|
|
159
150
|
});
|
|
160
151
|
|
|
161
152
|
it('includes credentials for configured API origin requests', async () => {
|
|
162
|
-
const calls
|
|
163
|
-
globalThis.fetch = async (input, init) => {
|
|
164
|
-
calls.push({ url: String(input), init });
|
|
165
|
-
return jsonResponse({ ok: true });
|
|
166
|
-
};
|
|
153
|
+
const calls = recordFetches();
|
|
167
154
|
|
|
168
155
|
const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
|
|
169
156
|
|
|
@@ -175,11 +162,7 @@ describe('HttpService CSRF behavior', () => {
|
|
|
175
162
|
});
|
|
176
163
|
|
|
177
164
|
it('omits credentials for caller-supplied absolute URLs outside the configured API origin', async () => {
|
|
178
|
-
const calls
|
|
179
|
-
globalThis.fetch = async (input, init) => {
|
|
180
|
-
calls.push({ url: String(input), init });
|
|
181
|
-
return jsonResponse({ ok: true });
|
|
182
|
-
};
|
|
165
|
+
const calls = recordFetches();
|
|
183
166
|
|
|
184
167
|
const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
|
|
185
168
|
|
|
@@ -189,47 +172,4 @@ describe('HttpService CSRF behavior', () => {
|
|
|
189
172
|
expect(calls[0].url).toBe('https://attacker.oxy.so/collect');
|
|
190
173
|
expect(calls[0].init?.credentials).toBe('omit');
|
|
191
174
|
});
|
|
192
|
-
|
|
193
|
-
it('bypasses request deduplication for the internal CSRF retry', async () => {
|
|
194
|
-
const calls: FetchCall[] = [];
|
|
195
|
-
let csrfFetches = 0;
|
|
196
|
-
let postFetches = 0;
|
|
197
|
-
|
|
198
|
-
globalThis.fetch = async (input, init) => {
|
|
199
|
-
const url = String(input);
|
|
200
|
-
calls.push({ url, init });
|
|
201
|
-
|
|
202
|
-
if (url.endsWith('/csrf-token')) {
|
|
203
|
-
csrfFetches += 1;
|
|
204
|
-
return new Response(JSON.stringify({ csrfToken: `csrf_${csrfFetches}` }), {
|
|
205
|
-
status: 200,
|
|
206
|
-
headers: { 'content-type': 'application/json' },
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
postFetches += 1;
|
|
211
|
-
if (postFetches === 1) {
|
|
212
|
-
return new Response(JSON.stringify({ code: 'CSRF_TOKEN_INVALID' }), {
|
|
213
|
-
status: 403,
|
|
214
|
-
statusText: 'Forbidden',
|
|
215
|
-
headers: { 'content-type': 'application/json' },
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return jsonResponse({ ok: true });
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
223
|
-
|
|
224
|
-
await expect(http.post('/posts', { text: 'hello' })).resolves.toEqual({ ok: true });
|
|
225
|
-
expect(calls.map((call) => call.url)).toEqual([
|
|
226
|
-
'https://api.mention.earth/csrf-token',
|
|
227
|
-
'https://api.mention.earth/posts',
|
|
228
|
-
'https://api.mention.earth/csrf-token',
|
|
229
|
-
'https://api.mention.earth/posts',
|
|
230
|
-
]);
|
|
231
|
-
|
|
232
|
-
expect(readHeaders(calls[1].init)['X-CSRF-Token']).toBe('csrf_1');
|
|
233
|
-
expect(readHeaders(calls[3].init)['X-CSRF-Token']).toBe('csrf_2');
|
|
234
|
-
});
|
|
235
175
|
});
|
|
@@ -233,6 +233,59 @@ describe('HttpService in-session refresh handler', () => {
|
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
describe('HttpService.endSession — a sign-out outranks a refresh', () => {
|
|
237
|
+
it('plants nothing when the session ends while a re-mint is in flight', async () => {
|
|
238
|
+
const oldToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'old' });
|
|
239
|
+
const lateToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'late' });
|
|
240
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
241
|
+
http.setTokens(oldToken);
|
|
242
|
+
|
|
243
|
+
let finish!: (token: string | null) => void;
|
|
244
|
+
http.setAuthRefreshHandler(
|
|
245
|
+
() =>
|
|
246
|
+
new Promise((resolve) => {
|
|
247
|
+
finish = resolve;
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
const seen: Array<string | null> = [];
|
|
251
|
+
http.addTokenChangeListener((token) => seen.push(token));
|
|
252
|
+
|
|
253
|
+
const pending = http.refreshAccessToken('preflight');
|
|
254
|
+
http.endSession();
|
|
255
|
+
finish(lateToken);
|
|
256
|
+
|
|
257
|
+
await expect(pending).resolves.toBeNull();
|
|
258
|
+
expect(http.getAccessToken()).toBeNull();
|
|
259
|
+
expect(seen).toEqual([null]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('abandons only what was in flight: a later re-mint (another tab signed in) still runs', async () => {
|
|
263
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
264
|
+
const fresh = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'fresh' });
|
|
265
|
+
const handler = jest.fn(async () => fresh);
|
|
266
|
+
http.setAuthRefreshHandler(handler);
|
|
267
|
+
|
|
268
|
+
http.endSession();
|
|
269
|
+
expect(http.hasSessionEnded()).toBe(true);
|
|
270
|
+
await expect(http.refreshAccessToken('preflight')).resolves.toBe(fresh);
|
|
271
|
+
expect(http.getAccessToken()).toBe(fresh);
|
|
272
|
+
expect(http.hasSessionEnded()).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('is what OxyServices.clearTokens does; a plain HttpService.clearTokens (a mirror) is not', () => {
|
|
276
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
277
|
+
const before = oxy.httpService.getSessionEpoch();
|
|
278
|
+
oxy.clearTokens();
|
|
279
|
+
expect(oxy.httpService.getSessionEpoch()).toBe(before + 1);
|
|
280
|
+
expect(oxy.httpService.hasSessionEnded()).toBe(true);
|
|
281
|
+
|
|
282
|
+
const mirror = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
283
|
+
mirror.clearTokens();
|
|
284
|
+
expect(mirror.getSessionEpoch()).toBe(0);
|
|
285
|
+
expect(mirror.hasSessionEnded()).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
236
289
|
describe('OxyServices.getAccessTokenExpiry', () => {
|
|
237
290
|
it('returns the JWT exp (seconds) of the current access token', () => {
|
|
238
291
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
@@ -208,13 +208,6 @@ describe('OxyServices.createLinkedClient', () => {
|
|
|
208
208
|
const url = String(input);
|
|
209
209
|
calls.push({ url, init });
|
|
210
210
|
|
|
211
|
-
if (url.endsWith('/csrf-token')) {
|
|
212
|
-
return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
|
|
213
|
-
status: 200,
|
|
214
|
-
headers: { 'content-type': 'application/json' },
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
211
|
if (url.endsWith('/api/queue/current')) {
|
|
219
212
|
queueWriteAttempts += 1;
|
|
220
213
|
if (queueWriteAttempts === 1) {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The near-expiry mint storm that signed a busy app out (OxyHQ/Mention#1140).
|
|
3
|
+
*
|
|
4
|
+
* What the server does: `POST /session/device/token` answers with the session's
|
|
5
|
+
* STORED access token for as long as that token has not expired, and mints a
|
|
6
|
+
* new one only once it is past `exp`. The mint is budgeted at 30/min per device.
|
|
7
|
+
*
|
|
8
|
+
* What the client did with that: the scheduler and the request-time preflight
|
|
9
|
+
* both re-mint 60s before `exp`. The server handed the same token back, which
|
|
10
|
+
* counted as a success (no cooldown), so the scheduler re-armed at its 1s floor
|
|
11
|
+
* and every request re-ran the preflight: ~30 mints in the token's last minute,
|
|
12
|
+
* then a 429, whose 60s cooldown straddled the real expiry. The next request
|
|
13
|
+
* carried no bearer, drew a 401, found the refresh cooling down, and the
|
|
14
|
+
* HttpService cleared the token — which the provider reads as a sign-out.
|
|
15
|
+
*
|
|
16
|
+
* Production logs for the device showed exactly this: a burst of ~30 mints at
|
|
17
|
+
* ~1.4s intervals every ~16 minutes, a single mint ~78s later (the scheduler's
|
|
18
|
+
* 5+10+20+40s backoff through the 60s rate-limit cooldown), and a sign-out on
|
|
19
|
+
* the one cycle where the app was in active use across the expiry.
|
|
20
|
+
*
|
|
21
|
+
* This suite simulates that server against the real HttpService, refresh
|
|
22
|
+
* scheduler and 401 lane.
|
|
23
|
+
*/
|
|
24
|
+
import { OxyServices } from '../OxyServices';
|
|
25
|
+
import { startTokenRefreshScheduler } from '../session/refresh';
|
|
26
|
+
|
|
27
|
+
const TOKEN_TTL_SECONDS = 15 * 60;
|
|
28
|
+
const MINT_BUDGET_PER_MINUTE = 30;
|
|
29
|
+
|
|
30
|
+
function createJwt(payload: Record<string, unknown>): string {
|
|
31
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
32
|
+
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expOf(token: string): number {
|
|
36
|
+
const [, payload] = token.split('.');
|
|
37
|
+
return (JSON.parse(Buffer.from(payload, 'base64url').toString()) as { exp: number }).exp;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The device mint as the server implements it, including its rate limiter.
|
|
42
|
+
*
|
|
43
|
+
* `rotateWithinSeconds` is how close to `exp` the stored token may be before the
|
|
44
|
+
* server rotates instead of handing it back: `0` is the server as it was (it
|
|
45
|
+
* rotated only a token already past `exp`), a positive value is the fixed one.
|
|
46
|
+
*/
|
|
47
|
+
function createMintServer(rotateWithinSeconds: number): {
|
|
48
|
+
mint: () => { status: 200; token: string } | { status: 429 };
|
|
49
|
+
storedToken: () => string;
|
|
50
|
+
mints: number[];
|
|
51
|
+
rateLimited: number;
|
|
52
|
+
} {
|
|
53
|
+
let serial = 0;
|
|
54
|
+
const issue = (): string => {
|
|
55
|
+
serial += 1;
|
|
56
|
+
return createJwt({ userId: 'u', jti: `t${serial}`, exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS });
|
|
57
|
+
};
|
|
58
|
+
let stored = issue();
|
|
59
|
+
const mints: number[] = [];
|
|
60
|
+
const state = { rateLimited: 0 };
|
|
61
|
+
return {
|
|
62
|
+
mint: () => {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
if (mints.filter((at) => now - at < 60_000).length >= MINT_BUDGET_PER_MINUTE) {
|
|
65
|
+
state.rateLimited += 1;
|
|
66
|
+
return { status: 429 };
|
|
67
|
+
}
|
|
68
|
+
mints.push(now);
|
|
69
|
+
// `getAccessToken`: hand back the stored token unless it has expired.
|
|
70
|
+
if (expOf(stored) - Math.floor(now / 1000) < (rotateWithinSeconds > 0 ? rotateWithinSeconds : 0)) {
|
|
71
|
+
stored = issue();
|
|
72
|
+
}
|
|
73
|
+
return { status: 200, token: stored };
|
|
74
|
+
},
|
|
75
|
+
storedToken: () => stored,
|
|
76
|
+
mints,
|
|
77
|
+
get rateLimited() {
|
|
78
|
+
return state.rateLimited;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('a busy app across an access token expiry', () => {
|
|
84
|
+
const originalFetch = globalThis.fetch;
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
globalThis.fetch = originalFetch;
|
|
88
|
+
jest.useRealTimers();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
async function runBusyApp(rotateWithinSeconds: number): Promise<{
|
|
92
|
+
tokenEvents: Array<string | null>;
|
|
93
|
+
finalToken: string | null;
|
|
94
|
+
server: ReturnType<typeof createMintServer>;
|
|
95
|
+
}> {
|
|
96
|
+
jest.useFakeTimers({ now: new Date('2026-09-25T13:48:00Z') });
|
|
97
|
+
const server = createMintServer(rotateWithinSeconds);
|
|
98
|
+
|
|
99
|
+
// The app's own API: 401 without a live bearer, as any resource server does.
|
|
100
|
+
globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
|
|
101
|
+
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
102
|
+
const bearer = headers.Authorization ?? headers.authorization;
|
|
103
|
+
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : null;
|
|
104
|
+
if (!token || expOf(token) <= Math.floor(Date.now() / 1000)) {
|
|
105
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
106
|
+
status: 401,
|
|
107
|
+
headers: { 'content-type': 'application/json' },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return new Response(JSON.stringify({ data: { ok: true } }), {
|
|
111
|
+
status: 200,
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
});
|
|
114
|
+
}) as typeof fetch;
|
|
115
|
+
|
|
116
|
+
const oxy = new OxyServices({ baseURL: 'https://api.mention.earth', enableCache: false, enableRetry: false });
|
|
117
|
+
oxy.setTokens(server.storedToken());
|
|
118
|
+
oxy.httpService.setAuthRefreshHandler(async (reason) => {
|
|
119
|
+
const answer = server.mint();
|
|
120
|
+
if (answer.status === 429) {
|
|
121
|
+
// What `refreshDeviceSecretArm` does with a 429.
|
|
122
|
+
oxy.httpService.noteRefreshRateLimited();
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
oxy.setTokens(answer.token);
|
|
126
|
+
return answer.token;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const tokenEvents: Array<string | null> = [];
|
|
130
|
+
oxy.onTokensChanged((token) => tokenEvents.push(token));
|
|
131
|
+
const scheduler = startTokenRefreshScheduler(oxy);
|
|
132
|
+
|
|
133
|
+
// Twenty minutes of an app in use: a request every 700ms, straight across
|
|
134
|
+
// the first token's expiry at 14:03:00.
|
|
135
|
+
for (let elapsed = 0; elapsed < 20 * 60_000; elapsed += 700) {
|
|
136
|
+
await oxy.httpService.get('/feed').catch(() => undefined);
|
|
137
|
+
await jest.advanceTimersByTimeAsync(700);
|
|
138
|
+
}
|
|
139
|
+
scheduler.dispose();
|
|
140
|
+
return { tokenEvents, finalToken: oxy.getAccessToken(), server };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it('against the server that hands back an unexpired token: never cleared, never rate limited', async () => {
|
|
144
|
+
const { tokenEvents, finalToken, server } = await runBusyApp(0);
|
|
145
|
+
|
|
146
|
+
// Before the fix: ~30 mints of the same token, a 429, then the token was
|
|
147
|
+
// cleared (a run of `null` events) and the app was signed out.
|
|
148
|
+
expect(tokenEvents).not.toContain(null);
|
|
149
|
+
expect(finalToken).not.toBeNull();
|
|
150
|
+
expect(server.rateLimited).toBe(0);
|
|
151
|
+
// One rotation. The server still hands the expired token back for the
|
|
152
|
+
// second it expires in (it compares `exp < now`), which costs a few mints in
|
|
153
|
+
// that one second — nowhere near the budget.
|
|
154
|
+
expect(server.mints.length).toBeLessThan(MINT_BUDGET_PER_MINUTE / 3);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('against the server that rotates a token near expiry: one mint per rotation', async () => {
|
|
158
|
+
const { tokenEvents, finalToken, server } = await runBusyApp(120);
|
|
159
|
+
|
|
160
|
+
expect(tokenEvents).not.toContain(null);
|
|
161
|
+
expect(finalToken).not.toBeNull();
|
|
162
|
+
expect(server.rateLimited).toBe(0);
|
|
163
|
+
expect(server.mints).toHaveLength(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -104,7 +104,7 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
|
|
|
104
104
|
overrides.requestChallenge
|
|
105
105
|
?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
|
|
106
106
|
verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
|
|
107
|
-
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
107
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
|
|
108
108
|
} as unknown as OxyServices;
|
|
109
109
|
return { oxy, setTokens };
|
|
110
110
|
}
|
|
@@ -79,7 +79,7 @@ function makeOxy(overrides: {
|
|
|
79
79
|
setTokens,
|
|
80
80
|
mintFromDeviceSecret,
|
|
81
81
|
signInWithSharedIdentity,
|
|
82
|
-
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
82
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
|
|
83
83
|
} as unknown as OxyServices;
|
|
84
84
|
return { oxy, setTokens, mintFromDeviceSecret, signInWithSharedIdentity };
|
|
85
85
|
}
|
|
@@ -45,7 +45,7 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
|
|
|
45
45
|
throw new Error('mintFromDeviceSecret not stubbed');
|
|
46
46
|
}),
|
|
47
47
|
// The device-secret-mint step runs through the client's single-flight.
|
|
48
|
-
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
48
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
|
|
49
49
|
} as unknown as OxyServices;
|
|
50
50
|
return { oxy, setTokens };
|
|
51
51
|
}
|
|
@@ -326,6 +326,9 @@ export async function runSessionColdBoot(
|
|
|
326
326
|
{ component: 'sessionColdBoot', method: 'device-secret-mint' },
|
|
327
327
|
);
|
|
328
328
|
return { kind: 'skip' };
|
|
329
|
+
case 'session-ended':
|
|
330
|
+
// Signed out while the boot was minting; the arm planted nothing.
|
|
331
|
+
return { kind: 'skip' };
|
|
329
332
|
case 'no-secret':
|
|
330
333
|
return { kind: 'skip' };
|
|
331
334
|
}
|
|
@@ -470,10 +470,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
470
470
|
* `PostHydrationService`) and is unchanged.
|
|
471
471
|
* - **Plain client (browser / React Native with a user session):** when no
|
|
472
472
|
* service credentials are configured, the chunk is fetched via
|
|
473
|
-
* `makeRequest`, which attaches the configured user bearer.
|
|
474
|
-
* middleware skips bearer-authenticated writes, and `makeRequest` only
|
|
475
|
-
* fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
|
|
476
|
-
* so the user-bearer POST is sent without CSRF and succeeds. Previously
|
|
473
|
+
* `makeRequest`, which attaches the configured user bearer. Previously
|
|
477
474
|
* this method always used the service path, so every client-side caller
|
|
478
475
|
* silently received `[]` because `getServiceToken()` had no credentials.
|
|
479
476
|
*
|
|
@@ -504,7 +501,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
504
501
|
|
|
505
502
|
// A backend that called configureServiceAuth() uses the bearer-service
|
|
506
503
|
// path; any other caller (browser / RN with a user session) uses the
|
|
507
|
-
// user-bearer path.
|
|
504
|
+
// user-bearer path.
|
|
508
505
|
const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
|
|
509
506
|
|
|
510
507
|
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
@@ -22,11 +22,9 @@ import { OxyServices } from '../../OxyServices';
|
|
|
22
22
|
* hand-built `Object.assign(new Error(), { status: 404 })` proves only that
|
|
23
23
|
* the branch reads the shape the test itself invented.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* makes the CSRF fetch burn its own retries — so a naive call-count assertion
|
|
29
|
-
* measures CSRF attempts rather than the route.
|
|
25
|
+
* Assertions select calls by URL rather than position, so a preflight added
|
|
26
|
+
* to the real stack later cannot make a call-count assertion measure it
|
|
27
|
+
* instead of the route.
|
|
30
28
|
*/
|
|
31
29
|
const ROUTE = '/session/device/background-credential';
|
|
32
30
|
|
|
@@ -67,19 +65,16 @@ const ACCESS_TOKEN = (() => {
|
|
|
67
65
|
describe('provisionBackgroundCredential over a real HttpService', () => {
|
|
68
66
|
const originalFetch = global.fetch;
|
|
69
67
|
|
|
70
|
-
/** Answers
|
|
68
|
+
/** Answers every call with `body`/`status`. */
|
|
71
69
|
const stubFetch = (body: unknown, status: number) => {
|
|
72
70
|
const fetchMock = jest.fn(async (input: unknown) => {
|
|
73
|
-
if (String(input).includes('/csrf-token')) {
|
|
74
|
-
return jsonResponse({ csrfToken: 'csrf-test-token' }, 200);
|
|
75
|
-
}
|
|
76
71
|
return jsonResponse(body, status);
|
|
77
72
|
});
|
|
78
73
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
79
74
|
return fetchMock;
|
|
80
75
|
};
|
|
81
76
|
|
|
82
|
-
/** Every stubbed call whose URL is the route under test
|
|
77
|
+
/** Every stubbed call whose URL is the route under test. */
|
|
83
78
|
const routeCalls = (fetchMock: jest.Mock) =>
|
|
84
79
|
fetchMock.mock.calls.filter(([input]) => String(input).includes(ROUTE));
|
|
85
80
|
|
|
@@ -27,7 +27,7 @@ function jsonResponse(data: unknown): Response {
|
|
|
27
27
|
* Build a non-verified JWT whose payload decodes to the given claims.
|
|
28
28
|
* `jwtDecode` only base64url-decodes the middle segment (no signature check).
|
|
29
29
|
* Used to put the SDK in an authenticated state so a state-changing POST
|
|
30
|
-
* carries a bearer header
|
|
30
|
+
* carries a bearer header.
|
|
31
31
|
*/
|
|
32
32
|
function makeJwt(payload: Record<string, unknown>): string {
|
|
33
33
|
const b64url = (obj: Record<string, unknown>): string =>
|
|
@@ -142,9 +142,8 @@ describe('discovery error handling', () => {
|
|
|
142
142
|
* `POST /profiles/recommendations` with the validated request body.
|
|
143
143
|
*/
|
|
144
144
|
describe('getProfileRecommendations routing (GET vs POST)', () => {
|
|
145
|
-
// Authenticate so the state-changing POST carries a bearer header
|
|
146
|
-
//
|
|
147
|
-
// A bearer token does not affect GET routing or the request body, so the
|
|
145
|
+
// Authenticate so the state-changing POST carries a bearer header. A bearer
|
|
146
|
+
// token does not affect GET routing or the request body, so the
|
|
148
147
|
// GET-path assertions below are unaffected.
|
|
149
148
|
beforeEach(() => {
|
|
150
149
|
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
@@ -24,8 +24,7 @@ function rawResponse(body: unknown): Response {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* A non-verified JWT whose payload decodes to the given claims. Puts the SDK in
|
|
27
|
-
* an authenticated state so the `POST /topics/resolve` carries a bearer header
|
|
28
|
-
* and skips the CSRF-token pre-fetch (which would otherwise consume a mock).
|
|
27
|
+
* an authenticated state so the `POST /topics/resolve` carries a bearer header.
|
|
29
28
|
*/
|
|
30
29
|
function makeJwt(payload: Record<string, unknown>): string {
|
|
31
30
|
const b64url = (obj: Record<string, unknown>): string =>
|
|
@@ -59,7 +58,7 @@ describe('topics mixin envelope unwrapping', () => {
|
|
|
59
58
|
fetchMock = jest.fn();
|
|
60
59
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
61
60
|
oxy = new OxyServices({ baseURL: 'http://test.invalid', enableRetry: false });
|
|
62
|
-
// Authenticate so the resolve POST
|
|
61
|
+
// Authenticate so the resolve POST carries a bearer. Harmless for GETs.
|
|
63
62
|
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
64
63
|
});
|
|
65
64
|
|