@oxy.so/core 1.9.1 → 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 +56 -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 +32 -2
- package/dist/cjs/utils/errorUtils.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +56 -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 +32 -2
- package/dist/esm/utils/errorUtils.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +31 -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 +7 -0
- package/dist/types/utils/errorUtils.d.ts +1 -1
- package/package.json +1 -1
- package/src/HttpService.ts +59 -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/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 +38 -2
- package/src/utils/errorUtils.ts +1 -1
|
@@ -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) {
|
|
@@ -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
|
|
package/src/server/cors.ts
CHANGED
|
@@ -82,7 +82,7 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
|
|
|
82
82
|
overrides.requestChallenge
|
|
83
83
|
?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
|
|
84
84
|
verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
|
|
85
|
-
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
85
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
|
|
86
86
|
} as unknown as OxyServices;
|
|
87
87
|
return { oxy, setTokens };
|
|
88
88
|
}
|
|
@@ -59,8 +59,18 @@ interface RefreshMockOverrides {
|
|
|
59
59
|
|
|
60
60
|
function makeOxy(
|
|
61
61
|
overrides: RefreshMockOverrides = {},
|
|
62
|
-
): {
|
|
62
|
+
): {
|
|
63
|
+
oxy: OxyServices;
|
|
64
|
+
setTokens: jest.Mock;
|
|
65
|
+
noteRefreshRateLimited: jest.Mock;
|
|
66
|
+
/** `HttpService`'s session epoch; bump it to simulate a sign-out mid-refresh. */
|
|
67
|
+
epoch: { current: number };
|
|
68
|
+
/** `HttpService.hasSessionEnded()`: a sign-out with no token planted since. */
|
|
69
|
+
ended: { current: boolean };
|
|
70
|
+
} {
|
|
63
71
|
const setTokens = jest.fn();
|
|
72
|
+
const epoch = { current: 0 };
|
|
73
|
+
const ended = { current: false };
|
|
64
74
|
const noteRefreshRateLimited = jest.fn();
|
|
65
75
|
const oxy = {
|
|
66
76
|
setTokens,
|
|
@@ -71,9 +81,11 @@ function makeOxy(
|
|
|
71
81
|
httpService: {
|
|
72
82
|
runSingleFlightDeviceSecretMint: makeMintSingleFlight(),
|
|
73
83
|
noteRefreshRateLimited,
|
|
84
|
+
getSessionEpoch: () => epoch.current,
|
|
85
|
+
hasSessionEnded: () => ended.current,
|
|
74
86
|
},
|
|
75
87
|
} as unknown as OxyServices;
|
|
76
|
-
return { oxy, setTokens, noteRefreshRateLimited };
|
|
88
|
+
return { oxy, setTokens, noteRefreshRateLimited, epoch, ended };
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
describe('refreshPersistedSession — arm 1 (device-secret mint)', () => {
|
|
@@ -297,6 +309,107 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
|
|
|
297
309
|
});
|
|
298
310
|
});
|
|
299
311
|
|
|
312
|
+
describe('refreshPersistedSession — a sign-out mid-refresh', () => {
|
|
313
|
+
it('arm 1: plants nothing and leaves a cleared store cleared', async () => {
|
|
314
|
+
const store = createMemoryAuthStateStore();
|
|
315
|
+
await store.save(STORED);
|
|
316
|
+
let respond!: () => void;
|
|
317
|
+
const { oxy, setTokens, epoch } = makeOxy({
|
|
318
|
+
mintFromDeviceSecret: () =>
|
|
319
|
+
new Promise((resolve) => {
|
|
320
|
+
respond = () => resolve(MINT);
|
|
321
|
+
}),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
325
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
326
|
+
// The sign-out: the credential is dropped and the session epoch moves.
|
|
327
|
+
await store.clear();
|
|
328
|
+
epoch.current += 1;
|
|
329
|
+
respond();
|
|
330
|
+
|
|
331
|
+
await expect(pending).resolves.toBeNull();
|
|
332
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
333
|
+
expect(await store.load()).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('arm 1: still persists the rotated secret when the store kept the credential', async () => {
|
|
337
|
+
const store = createMemoryAuthStateStore();
|
|
338
|
+
await store.save(STORED);
|
|
339
|
+
let respond!: () => void;
|
|
340
|
+
const { oxy, setTokens, epoch } = makeOxy({
|
|
341
|
+
mintFromDeviceSecret: () =>
|
|
342
|
+
new Promise((resolve) => {
|
|
343
|
+
respond = () => resolve(MINT);
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
348
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
349
|
+
// A local teardown that keeps the store (so a reload can restore): the
|
|
350
|
+
// server rotated the secret, and the store must not be left holding the old one.
|
|
351
|
+
epoch.current += 1;
|
|
352
|
+
respond();
|
|
353
|
+
|
|
354
|
+
await expect(pending).resolves.toBeNull();
|
|
355
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
356
|
+
expect((await store.load())?.deviceSecret).toBe('ds-next-secret');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('arm 2: the shared keychain does not sign the user back in', async () => {
|
|
360
|
+
const store = createMemoryAuthStateStore();
|
|
361
|
+
let respond!: () => void;
|
|
362
|
+
const signInWithSharedIdentity = jest.fn(
|
|
363
|
+
(_opts?: { plantTokens?: boolean }) =>
|
|
364
|
+
new Promise<SessionLoginResponse>((resolve) => {
|
|
365
|
+
respond = () =>
|
|
366
|
+
resolve({
|
|
367
|
+
sessionId: 'sess-shared',
|
|
368
|
+
deviceId: 'dev-1',
|
|
369
|
+
deviceSecret: 'ds-shared',
|
|
370
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
371
|
+
user: { id: 'user-1', username: 'u', name: {}, avatar: undefined },
|
|
372
|
+
accessToken: 'access-shared',
|
|
373
|
+
});
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
const { oxy, setTokens, epoch } = makeOxy({ signInWithSharedIdentity });
|
|
377
|
+
|
|
378
|
+
const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
379
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
380
|
+
epoch.current += 1;
|
|
381
|
+
respond();
|
|
382
|
+
|
|
383
|
+
await expect(pending).resolves.toBeNull();
|
|
384
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
|
|
385
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
386
|
+
expect(await store.load()).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('refreshPersistedSession — after a sign-out', () => {
|
|
391
|
+
it('does not re-sign-in with the shared keychain', async () => {
|
|
392
|
+
const store = createMemoryAuthStateStore();
|
|
393
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
394
|
+
const { oxy, setTokens, ended } = makeOxy({ signInWithSharedIdentity });
|
|
395
|
+
ended.current = true;
|
|
396
|
+
|
|
397
|
+
await expect(refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).resolves.toBeNull();
|
|
398
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
399
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('still mints from a device credential the store holds (a sign-in made in another tab)', async () => {
|
|
403
|
+
const store = createMemoryAuthStateStore();
|
|
404
|
+
await store.save(STORED);
|
|
405
|
+
const { oxy, setTokens, ended } = makeOxy();
|
|
406
|
+
ended.current = true;
|
|
407
|
+
|
|
408
|
+
await expect(refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).resolves.toBe('access-new');
|
|
409
|
+
expect(setTokens).toHaveBeenCalledWith('access-new');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
300
413
|
describe('refreshPersistedSession — single-flight (no double-rotation)', () => {
|
|
301
414
|
it('coalesces two concurrent mints into ONE server rotation; the store holds the final current secret', async () => {
|
|
302
415
|
// The server rotates the secret on every mint. Two concurrent lanes must
|