@oxyhq/core 3.4.17 → 3.4.19
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +27 -2
- package/dist/cjs/OxyServices.base.js +2 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +27 -2
- package/dist/esm/OxyServices.base.js +2 -3
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +4 -0
- package/package.json +1 -1
- package/src/HttpService.ts +34 -2
- package/src/OxyServices.base.ts +2 -3
- package/src/__tests__/httpServiceCsrf.test.ts +68 -0
- package/src/__tests__/linkedClient.test.ts +103 -13
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { OxyConfig } from './models/interfaces';
|
|
16
16
|
export type AuthRefreshReason = 'preflight' | 'response-401';
|
|
17
17
|
export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
|
|
18
|
+
export type AccessTokenProvider = () => string | null;
|
|
18
19
|
export interface RequestOptions {
|
|
19
20
|
cache?: boolean;
|
|
20
21
|
cacheTTL?: number;
|
|
@@ -52,6 +53,7 @@ export declare class HttpService {
|
|
|
52
53
|
private tokenRefreshPromise;
|
|
53
54
|
private tokenRefreshCooldownUntil;
|
|
54
55
|
private authRefreshHandler;
|
|
56
|
+
private accessTokenProvider;
|
|
55
57
|
/**
|
|
56
58
|
* Fan-out listeners notified on EVERY access-token change on this instance:
|
|
57
59
|
* explicit `setTokens`, `clearTokens`, an AuthManager-owned refresh, and the
|
|
@@ -64,6 +66,7 @@ export declare class HttpService {
|
|
|
64
66
|
private _actingAsUserId;
|
|
65
67
|
private requestMetrics;
|
|
66
68
|
constructor(config: OxyConfig);
|
|
69
|
+
private syncAccessTokenFromProvider;
|
|
67
70
|
/**
|
|
68
71
|
* Robust FormData detection that works in browser, React Native, and
|
|
69
72
|
* Node.js polyfill environments.
|
|
@@ -190,6 +193,7 @@ export declare class HttpService {
|
|
|
190
193
|
getActingAs(): string | null;
|
|
191
194
|
setTokens(accessToken: string): void;
|
|
192
195
|
setAuthRefreshHandler(handler: AuthRefreshHandler | null): void;
|
|
196
|
+
setAccessTokenProvider(provider: AccessTokenProvider | null): void;
|
|
193
197
|
clearTokens(): void;
|
|
194
198
|
/**
|
|
195
199
|
* Subscribe to access-token changes on this instance.
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -37,6 +37,7 @@ interface JwtPayload {
|
|
|
37
37
|
|
|
38
38
|
export type AuthRefreshReason = 'preflight' | 'response-401';
|
|
39
39
|
export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
|
|
40
|
+
export type AccessTokenProvider = () => string | null;
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Structural type that captures the multipart-write surface every supported
|
|
@@ -171,6 +172,7 @@ export class HttpService {
|
|
|
171
172
|
private tokenRefreshPromise: Promise<string | null> | null = null;
|
|
172
173
|
private tokenRefreshCooldownUntil: number = 0;
|
|
173
174
|
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
175
|
+
private accessTokenProvider: AccessTokenProvider | null = null;
|
|
174
176
|
|
|
175
177
|
/**
|
|
176
178
|
* Fan-out listeners notified on EVERY access-token change on this instance:
|
|
@@ -216,6 +218,29 @@ export class HttpService {
|
|
|
216
218
|
);
|
|
217
219
|
}
|
|
218
220
|
|
|
221
|
+
private syncAccessTokenFromProvider(): string | null {
|
|
222
|
+
if (!this.accessTokenProvider) {
|
|
223
|
+
return this.tokenStore.getAccessToken();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const providedToken = this.accessTokenProvider();
|
|
227
|
+
const currentToken = this.tokenStore.getAccessToken();
|
|
228
|
+
|
|
229
|
+
if (providedToken) {
|
|
230
|
+
if (providedToken !== currentToken) {
|
|
231
|
+
this.tokenStore.setTokens(providedToken);
|
|
232
|
+
this.notifyTokenChange();
|
|
233
|
+
}
|
|
234
|
+
return providedToken;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (currentToken) {
|
|
238
|
+
this.clearTokens();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
219
244
|
/**
|
|
220
245
|
* Robust FormData detection that works in browser, React Native, and
|
|
221
246
|
* Node.js polyfill environments.
|
|
@@ -856,7 +881,7 @@ export class HttpService {
|
|
|
856
881
|
* Get auth header with automatic token refresh
|
|
857
882
|
*/
|
|
858
883
|
private async getAuthHeader(): Promise<string | null> {
|
|
859
|
-
const accessToken = this.
|
|
884
|
+
const accessToken = this.syncAccessTokenFromProvider();
|
|
860
885
|
if (!accessToken) {
|
|
861
886
|
return null;
|
|
862
887
|
}
|
|
@@ -869,7 +894,10 @@ export class HttpService {
|
|
|
869
894
|
if (decoded.exp && decoded.exp - currentTime < 60) {
|
|
870
895
|
const refreshed = await this.refreshAccessToken('preflight');
|
|
871
896
|
if (refreshed) return `Bearer ${refreshed}`;
|
|
872
|
-
|
|
897
|
+
if (decoded.exp > currentTime) {
|
|
898
|
+
return `Bearer ${accessToken}`;
|
|
899
|
+
}
|
|
900
|
+
// Refresh failed — don't use an expired token (would cause 401 loop)
|
|
873
901
|
return null;
|
|
874
902
|
}
|
|
875
903
|
|
|
@@ -990,6 +1018,10 @@ export class HttpService {
|
|
|
990
1018
|
this.authRefreshHandler = handler;
|
|
991
1019
|
}
|
|
992
1020
|
|
|
1021
|
+
setAccessTokenProvider(provider: AccessTokenProvider | null): void {
|
|
1022
|
+
this.accessTokenProvider = provider;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
993
1025
|
clearTokens(): void {
|
|
994
1026
|
this.tokenStore.clearTokens();
|
|
995
1027
|
this.tokenStore.clearCsrfToken();
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -149,12 +149,10 @@ export class OxyServicesBase {
|
|
|
149
149
|
|
|
150
150
|
syncToken(this.getAccessToken());
|
|
151
151
|
const unsubscribe = this.onTokensChanged(syncToken);
|
|
152
|
+
client.setAccessTokenProvider(() => this.getAccessToken());
|
|
152
153
|
client.setAuthRefreshHandler(async (reason: AuthRefreshReason) => {
|
|
153
154
|
const refreshed = await this.httpService.refreshAccessToken(reason);
|
|
154
155
|
if (!refreshed) {
|
|
155
|
-
if (reason === 'response-401') {
|
|
156
|
-
this.clearTokens();
|
|
157
|
-
}
|
|
158
156
|
return null;
|
|
159
157
|
}
|
|
160
158
|
|
|
@@ -167,6 +165,7 @@ export class OxyServicesBase {
|
|
|
167
165
|
dispose: () => {
|
|
168
166
|
unsubscribe();
|
|
169
167
|
client.setAuthRefreshHandler(null);
|
|
168
|
+
client.setAccessTokenProvider(null);
|
|
170
169
|
client.clearTokens();
|
|
171
170
|
},
|
|
172
171
|
};
|
|
@@ -63,6 +63,74 @@ describe('HttpService CSRF behavior', () => {
|
|
|
63
63
|
expect(headers['X-CSRF-Token']).toBeUndefined();
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it('keeps a valid near-expiry bearer token when preflight refresh cannot refresh', async () => {
|
|
67
|
+
const calls: FetchCall[] = [];
|
|
68
|
+
globalThis.fetch = async (input, init) => {
|
|
69
|
+
const url = String(input);
|
|
70
|
+
calls.push({ url, init });
|
|
71
|
+
return jsonResponse({ ok: true });
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
75
|
+
const accessToken = createJwt({
|
|
76
|
+
userId: 'user_1',
|
|
77
|
+
exp: Math.floor(Date.now() / 1000) + 30,
|
|
78
|
+
});
|
|
79
|
+
let refreshAttempts = 0;
|
|
80
|
+
http.setTokens(accessToken);
|
|
81
|
+
http.setAuthRefreshHandler(async () => {
|
|
82
|
+
refreshAttempts += 1;
|
|
83
|
+
return null;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await http.post('/posts', { text: 'hello' });
|
|
87
|
+
|
|
88
|
+
expect(refreshAttempts).toBe(1);
|
|
89
|
+
expect(calls).toHaveLength(1);
|
|
90
|
+
expect(calls[0].url).toBe('https://api.mention.earth/posts');
|
|
91
|
+
const headers = readHeaders(calls[0].init);
|
|
92
|
+
expect(headers.Authorization).toBe(`Bearer ${accessToken}`);
|
|
93
|
+
expect(headers['X-CSRF-Token']).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('does not use an expired bearer token when preflight refresh cannot refresh', async () => {
|
|
97
|
+
const calls: FetchCall[] = [];
|
|
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
|
+
};
|
|
109
|
+
|
|
110
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
111
|
+
const accessToken = createJwt({
|
|
112
|
+
userId: 'user_1',
|
|
113
|
+
exp: Math.floor(Date.now() / 1000) - 10,
|
|
114
|
+
});
|
|
115
|
+
let refreshAttempts = 0;
|
|
116
|
+
http.setTokens(accessToken);
|
|
117
|
+
http.setAuthRefreshHandler(async () => {
|
|
118
|
+
refreshAttempts += 1;
|
|
119
|
+
return null;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await http.post('/posts', { text: 'hello' });
|
|
123
|
+
|
|
124
|
+
expect(refreshAttempts).toBe(1);
|
|
125
|
+
expect(calls.map((call) => call.url)).toEqual([
|
|
126
|
+
'https://api.mention.earth/csrf-token',
|
|
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
|
+
});
|
|
133
|
+
|
|
66
134
|
it('still fetches csrf-token for cookie-authenticated writes without bearer', async () => {
|
|
67
135
|
const calls: FetchCall[] = [];
|
|
68
136
|
globalThis.fetch = async (input, init) => {
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { OxyServices } from '../OxyServices';
|
|
2
2
|
|
|
3
|
+
interface FetchCall {
|
|
4
|
+
url: string;
|
|
5
|
+
init: RequestInit | undefined;
|
|
6
|
+
}
|
|
7
|
+
|
|
3
8
|
function createServices(): OxyServices {
|
|
4
9
|
return new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
5
10
|
}
|
|
6
11
|
|
|
12
|
+
function createJwt(payload: Record<string, unknown>): string {
|
|
13
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
14
|
+
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
function jsonResponse(data: unknown): Response {
|
|
8
18
|
return new Response(JSON.stringify({ data }), {
|
|
9
19
|
status: 200,
|
|
@@ -11,7 +21,28 @@ function jsonResponse(data: unknown): Response {
|
|
|
11
21
|
});
|
|
12
22
|
}
|
|
13
23
|
|
|
24
|
+
function readHeaders(init: RequestInit | undefined): Record<string, string> {
|
|
25
|
+
const headers = init?.headers;
|
|
26
|
+
if (!headers) {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
if (headers instanceof Headers) {
|
|
30
|
+
return Object.fromEntries(headers.entries());
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(headers)) {
|
|
33
|
+
return Object.fromEntries(headers);
|
|
34
|
+
}
|
|
35
|
+
return headers as Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
14
38
|
describe('OxyServices.createLinkedClient', () => {
|
|
39
|
+
const originalFetch = globalThis.fetch;
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
globalThis.fetch = originalFetch;
|
|
43
|
+
jest.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
15
46
|
it('mirrors token changes from the session owner', () => {
|
|
16
47
|
const oxy = createServices();
|
|
17
48
|
const linked = oxy.createLinkedClient({ baseURL: 'https://api.syra.fm' });
|
|
@@ -56,7 +87,7 @@ describe('OxyServices.createLinkedClient', () => {
|
|
|
56
87
|
linked.dispose();
|
|
57
88
|
});
|
|
58
89
|
|
|
59
|
-
it('
|
|
90
|
+
it('keeps the session owner intact when a linked response 401 cannot refresh', async () => {
|
|
60
91
|
const oxy = createServices();
|
|
61
92
|
oxy.setTokens('stale_access');
|
|
62
93
|
const linked = oxy.createLinkedClient({ baseURL: 'https://api.syra.fm' });
|
|
@@ -64,9 +95,73 @@ describe('OxyServices.createLinkedClient', () => {
|
|
|
64
95
|
const refreshed = await linked.client.refreshAccessToken('response-401');
|
|
65
96
|
|
|
66
97
|
expect(refreshed).toBeNull();
|
|
67
|
-
expect(oxy.getAccessToken()).
|
|
98
|
+
expect(oxy.getAccessToken()).toBe('stale_access');
|
|
99
|
+
expect(linked.client.getAccessToken()).toBe('stale_access');
|
|
100
|
+
|
|
101
|
+
linked.dispose();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('resynchronizes from the session owner after a linked app 401 clears the local token', async () => {
|
|
105
|
+
const calls: FetchCall[] = [];
|
|
106
|
+
let queueWriteAttempts = 0;
|
|
107
|
+
globalThis.fetch = async (input, init) => {
|
|
108
|
+
const url = String(input);
|
|
109
|
+
calls.push({ url, init });
|
|
110
|
+
|
|
111
|
+
if (url.endsWith('/csrf-token')) {
|
|
112
|
+
return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
|
|
113
|
+
status: 200,
|
|
114
|
+
headers: { 'content-type': 'application/json' },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (url.endsWith('/api/queue/current')) {
|
|
119
|
+
queueWriteAttempts += 1;
|
|
120
|
+
if (queueWriteAttempts === 1) {
|
|
121
|
+
return new Response(JSON.stringify({ error: 'MISSING_TOKEN' }), {
|
|
122
|
+
status: 401,
|
|
123
|
+
statusText: 'Unauthorized',
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return jsonResponse({ ok: true });
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const oxy = createServices();
|
|
133
|
+
const accessToken = createJwt({
|
|
134
|
+
userId: 'user_1',
|
|
135
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
136
|
+
});
|
|
137
|
+
oxy.setTokens(accessToken);
|
|
138
|
+
oxy.getClient().setAuthRefreshHandler(async () => null);
|
|
139
|
+
const linked = oxy.createLinkedClient({ baseURL: 'https://api.syra.fm' });
|
|
140
|
+
|
|
141
|
+
await expect(linked.client.put('/api/queue/current', { trackId: 'track_1' }, { retry: false })).rejects.toMatchObject({
|
|
142
|
+
message: 'MISSING_TOKEN',
|
|
143
|
+
status: 401,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(oxy.getAccessToken()).toBe(accessToken);
|
|
68
147
|
expect(linked.client.getAccessToken()).toBeNull();
|
|
69
148
|
|
|
149
|
+
await linked.client.put('/api/queue/current', { trackId: 'track_2' });
|
|
150
|
+
|
|
151
|
+
expect(calls.map((call) => call.url)).toEqual([
|
|
152
|
+
'https://api.syra.fm/api/queue/current',
|
|
153
|
+
'https://api.syra.fm/api/queue/current',
|
|
154
|
+
]);
|
|
155
|
+
expect(linked.client.getAccessToken()).toBe(accessToken);
|
|
156
|
+
|
|
157
|
+
const firstHeaders = readHeaders(calls[0]?.init);
|
|
158
|
+
expect(firstHeaders.Authorization).toBe(`Bearer ${accessToken}`);
|
|
159
|
+
expect(firstHeaders['X-CSRF-Token']).toBeUndefined();
|
|
160
|
+
|
|
161
|
+
const secondHeaders = readHeaders(calls[1]?.init);
|
|
162
|
+
expect(secondHeaders.Authorization).toBe(`Bearer ${accessToken}`);
|
|
163
|
+
expect(secondHeaders['X-CSRF-Token']).toBeUndefined();
|
|
164
|
+
|
|
70
165
|
linked.dispose();
|
|
71
166
|
});
|
|
72
167
|
|
|
@@ -98,22 +193,17 @@ describe('OxyServices.createLinkedClient', () => {
|
|
|
98
193
|
});
|
|
99
194
|
|
|
100
195
|
it('joins linked base URLs with relative paths that omit the leading slash', async () => {
|
|
101
|
-
const originalFetch = globalThis.fetch;
|
|
102
196
|
const fetchMock = jest.fn(async () => jsonResponse({ ok: true }));
|
|
103
197
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
104
198
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const linked = oxy.createLinkedClient({ baseURL: 'https://api.mention.earth' });
|
|
199
|
+
const oxy = createServices();
|
|
200
|
+
const linked = oxy.createLinkedClient({ baseURL: 'https://api.mention.earth' });
|
|
108
201
|
|
|
109
|
-
|
|
202
|
+
await linked.client.get('profile/settings/me');
|
|
110
203
|
|
|
111
|
-
|
|
112
|
-
|
|
204
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
205
|
+
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://api.mention.earth/profile/settings/me');
|
|
113
206
|
|
|
114
|
-
|
|
115
|
-
} finally {
|
|
116
|
-
globalThis.fetch = originalFetch;
|
|
117
|
-
}
|
|
207
|
+
linked.dispose();
|
|
118
208
|
});
|
|
119
209
|
});
|