@oxyhq/core 3.4.18 → 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 +23 -1
- package/dist/cjs/OxyServices.base.js +2 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +23 -1
- 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 +30 -1
- package/src/OxyServices.base.ts +2 -3
- 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
|
}
|
|
@@ -993,6 +1018,10 @@ export class HttpService {
|
|
|
993
1018
|
this.authRefreshHandler = handler;
|
|
994
1019
|
}
|
|
995
1020
|
|
|
1021
|
+
setAccessTokenProvider(provider: AccessTokenProvider | null): void {
|
|
1022
|
+
this.accessTokenProvider = provider;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
996
1025
|
clearTokens(): void {
|
|
997
1026
|
this.tokenStore.clearTokens();
|
|
998
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
|
};
|
|
@@ -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
|
});
|