@oxyhq/core 3.16.1 → 3.17.1
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 +8 -1
- package/dist/cjs/OxyServices.base.js +25 -0
- package/dist/cjs/mixins/OxyServices.links.js +6 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +8 -1
- package/dist/esm/OxyServices.base.js +25 -0
- package/dist/esm/mixins/OxyServices.links.js +6 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.base.d.ts +11 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
- package/dist/types/mixins/OxyServices.applications.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +1 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +1 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +1 -0
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.links.d.ts +6 -1
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.reputation.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.silent.d.ts +1 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +1 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/dist/types/mixins/OxyServices.workspaces.d.ts +1 -0
- package/package.json +1 -1
- package/src/HttpService.ts +8 -1
- package/src/OxyServices.base.ts +25 -0
- package/src/__tests__/inSessionRefresh.test.ts +193 -0
- package/src/index.ts +4 -0
- package/src/mixins/OxyServices.links.ts +8 -4
- package/src/mixins/__tests__/OxyServices.links.test.ts +8 -8
|
@@ -142,6 +142,17 @@ export declare class OxyServicesBase {
|
|
|
142
142
|
* Get the raw access token (for constructing anchor URLs when needed)
|
|
143
143
|
*/
|
|
144
144
|
getAccessToken(): string | null;
|
|
145
|
+
/**
|
|
146
|
+
* Decode the current access token and return its `exp` claim in SECONDS since
|
|
147
|
+
* the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
|
|
148
|
+
* the token is opaque/undecodable, or it carries no numeric `exp`.
|
|
149
|
+
*
|
|
150
|
+
* Exposed so `@oxyhq/services` can schedule a PROACTIVE in-session refresh a
|
|
151
|
+
* fixed lead before expiry without re-importing a JWT decoder (and without
|
|
152
|
+
* duplicating the `jwt-decode` dependency in the RN bundle). HttpService keeps
|
|
153
|
+
* the per-request preflight refresh; this powers the idle/background timer.
|
|
154
|
+
*/
|
|
155
|
+
getAccessTokenExpiry(): number | null;
|
|
145
156
|
/**
|
|
146
157
|
* Set the acting-as identity for managed accounts.
|
|
147
158
|
*
|
package/dist/types/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ import './crypto/polyfill';
|
|
|
20
20
|
export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
|
|
21
21
|
export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
|
|
22
22
|
export type { LinkedHttpClient } from './OxyServices.base';
|
|
23
|
+
export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
|
|
23
24
|
export { AuthManager, createAuthManager } from './AuthManager';
|
|
24
25
|
export type { StorageAdapter, AuthStateChangeCallback, AuthMethod, AuthManagerConfig, } from './AuthManager';
|
|
25
26
|
export type { AuthManagerAccount, RestoreFromCookiesResult, RestoreFromCookiesOptions, SwitchAuthuserResult, } from './AuthManagerTypes';
|
|
@@ -54,6 +54,7 @@ export declare function OxyServicesAnalyticsMixin<T extends typeof OxyServicesBa
|
|
|
54
54
|
getCurrentUserId(): string | null;
|
|
55
55
|
hasValidToken(): boolean;
|
|
56
56
|
getAccessToken(): string | null;
|
|
57
|
+
getAccessTokenExpiry(): number | null;
|
|
57
58
|
setActingAs(userId: string | null): void;
|
|
58
59
|
getActingAs(): string | null;
|
|
59
60
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -90,6 +90,7 @@ export declare function OxyServicesAppDataMixin<T extends typeof OxyServicesBase
|
|
|
90
90
|
getCurrentUserId(): string | null;
|
|
91
91
|
hasValidToken(): boolean;
|
|
92
92
|
getAccessToken(): string | null;
|
|
93
|
+
getAccessTokenExpiry(): number | null;
|
|
93
94
|
setActingAs(userId: string | null): void;
|
|
94
95
|
getActingAs(): string | null;
|
|
95
96
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -475,6 +475,7 @@ export declare function OxyServicesApplicationsMixin<T extends typeof OxyService
|
|
|
475
475
|
getCurrentUserId(): string | null;
|
|
476
476
|
hasValidToken(): boolean;
|
|
477
477
|
getAccessToken(): string | null;
|
|
478
|
+
getAccessTokenExpiry(): number | null;
|
|
478
479
|
setActingAs(userId: string | null): void;
|
|
479
480
|
getActingAs(): string | null;
|
|
480
481
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -134,6 +134,7 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
|
|
|
134
134
|
getCurrentUserId(): string | null;
|
|
135
135
|
hasValidToken(): boolean;
|
|
136
136
|
getAccessToken(): string | null;
|
|
137
|
+
getAccessTokenExpiry(): number | null;
|
|
137
138
|
setActingAs(userId: string | null): void;
|
|
138
139
|
getActingAs(): string | null;
|
|
139
140
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -569,6 +569,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
569
569
|
getCurrentUserId(): string | null;
|
|
570
570
|
hasValidToken(): boolean;
|
|
571
571
|
getAccessToken(): string | null;
|
|
572
|
+
getAccessTokenExpiry(): number | null;
|
|
572
573
|
setActingAs(userId: string | null): void;
|
|
573
574
|
getActingAs(): string | null;
|
|
574
575
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -492,6 +492,7 @@ export declare function OxyServicesCivicMixin<T extends typeof OxyServicesBase>(
|
|
|
492
492
|
getCurrentUserId(): string | null;
|
|
493
493
|
hasValidToken(): boolean;
|
|
494
494
|
getAccessToken(): string | null;
|
|
495
|
+
getAccessTokenExpiry(): number | null;
|
|
495
496
|
setActingAs(userId: string | null): void;
|
|
496
497
|
getActingAs(): string | null;
|
|
497
498
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -82,6 +82,7 @@ export declare function OxyServicesContactsMixin<T extends typeof OxyServicesBas
|
|
|
82
82
|
getCurrentUserId(): string | null;
|
|
83
83
|
hasValidToken(): boolean;
|
|
84
84
|
getAccessToken(): string | null;
|
|
85
|
+
getAccessTokenExpiry(): number | null;
|
|
85
86
|
setActingAs(userId: string | null): void;
|
|
86
87
|
getActingAs(): string | null;
|
|
87
88
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -84,6 +84,7 @@ export declare function OxyServicesDevicesMixin<T extends typeof OxyServicesBase
|
|
|
84
84
|
getCurrentUserId(): string | null;
|
|
85
85
|
hasValidToken(): boolean;
|
|
86
86
|
getAccessToken(): string | null;
|
|
87
|
+
getAccessTokenExpiry(): number | null;
|
|
87
88
|
setActingAs(userId: string | null): void;
|
|
88
89
|
getActingAs(): string | null;
|
|
89
90
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -232,6 +232,7 @@ export declare function OxyServicesFeaturesMixin<T extends typeof OxyServicesBas
|
|
|
232
232
|
getCurrentUserId(): string | null;
|
|
233
233
|
hasValidToken(): boolean;
|
|
234
234
|
getAccessToken(): string | null;
|
|
235
|
+
getAccessTokenExpiry(): number | null;
|
|
235
236
|
setActingAs(userId: string | null): void;
|
|
236
237
|
getActingAs(): string | null;
|
|
237
238
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -286,6 +286,7 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
|
|
|
286
286
|
getCurrentUserId(): string | null;
|
|
287
287
|
hasValidToken(): boolean;
|
|
288
288
|
getAccessToken(): string | null;
|
|
289
|
+
getAccessTokenExpiry(): number | null;
|
|
289
290
|
setActingAs(userId: string | null): void;
|
|
290
291
|
getActingAs(): string | null;
|
|
291
292
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -229,6 +229,7 @@ export declare function OxyServicesIdentityMixin<T extends typeof OxyServicesBas
|
|
|
229
229
|
getCurrentUserId(): string | null;
|
|
230
230
|
hasValidToken(): boolean;
|
|
231
231
|
getAccessToken(): string | null;
|
|
232
|
+
getAccessTokenExpiry(): number | null;
|
|
232
233
|
setActingAs(userId: string | null): void;
|
|
233
234
|
getActingAs(): string | null;
|
|
234
235
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -69,6 +69,7 @@ export declare function OxyServicesLanguageMixin<T extends typeof OxyServicesBas
|
|
|
69
69
|
getCurrentUserId(): string | null;
|
|
70
70
|
hasValidToken(): boolean;
|
|
71
71
|
getAccessToken(): string | null;
|
|
72
|
+
getAccessTokenExpiry(): number | null;
|
|
72
73
|
setActingAs(userId: string | null): void;
|
|
73
74
|
getActingAs(): string | null;
|
|
74
75
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -37,11 +37,15 @@ export declare function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(
|
|
|
37
37
|
* Resolve multiple link previews via `POST /links/previews` (body `{ urls }`).
|
|
38
38
|
*
|
|
39
39
|
* Inputs are de-duplicated and split into chunks of {@link LINK_PREVIEWS_CHUNK_SIZE}
|
|
40
|
-
* (the server-side cap). Chunks run concurrently and their
|
|
40
|
+
* (the server-side cap). Chunks run concurrently and their result maps are
|
|
41
41
|
* merged into a single result keyed by the REQUESTED url (the exact string
|
|
42
42
|
* passed in `urls`) — matching the batch contract — so a caller can always
|
|
43
43
|
* look its own input back up.
|
|
44
44
|
*
|
|
45
|
+
* `makeRequest` unwraps the API's top-level `{ data }` envelope (same as
|
|
46
|
+
* `getUsersByIds`'s `makeServiceRequest<User[]>`), so each chunk response is
|
|
47
|
+
* already the `Record<url, LinkPreview>` map — merge it directly.
|
|
48
|
+
*
|
|
45
49
|
* An empty / whitespace-only input resolves immediately with `{}` and
|
|
46
50
|
* performs no network call. A failure in any chunk surfaces (via
|
|
47
51
|
* `handleError`) rather than being swallowed.
|
|
@@ -82,6 +86,7 @@ export declare function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(
|
|
|
82
86
|
getCurrentUserId(): string | null;
|
|
83
87
|
hasValidToken(): boolean;
|
|
84
88
|
getAccessToken(): string | null;
|
|
89
|
+
getAccessTokenExpiry(): number | null;
|
|
85
90
|
setActingAs(userId: string | null): void;
|
|
86
91
|
getActingAs(): string | null;
|
|
87
92
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -52,6 +52,7 @@ export declare function OxyServicesLocationMixin<T extends typeof OxyServicesBas
|
|
|
52
52
|
getCurrentUserId(): string | null;
|
|
53
53
|
hasValidToken(): boolean;
|
|
54
54
|
getAccessToken(): string | null;
|
|
55
|
+
getAccessTokenExpiry(): number | null;
|
|
55
56
|
setActingAs(userId: string | null): void;
|
|
56
57
|
getActingAs(): string | null;
|
|
57
58
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -124,6 +124,7 @@ export declare function OxyServicesManagedAccountsMixin<T extends typeof OxyServ
|
|
|
124
124
|
getCurrentUserId(): string | null;
|
|
125
125
|
hasValidToken(): boolean;
|
|
126
126
|
getAccessToken(): string | null;
|
|
127
|
+
getAccessTokenExpiry(): number | null;
|
|
127
128
|
setActingAs(userId: string | null): void;
|
|
128
129
|
getActingAs(): string | null;
|
|
129
130
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -222,6 +222,7 @@ export declare function OxyServicesNodesMixin<T extends typeof OxyServicesBase>(
|
|
|
222
222
|
getCurrentUserId(): string | null;
|
|
223
223
|
hasValidToken(): boolean;
|
|
224
224
|
getAccessToken(): string | null;
|
|
225
|
+
getAccessTokenExpiry(): number | null;
|
|
225
226
|
setActingAs(userId: string | null): void;
|
|
226
227
|
getActingAs(): string | null;
|
|
227
228
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -99,6 +99,7 @@ export declare function OxyServicesPaymentMixin<T extends typeof OxyServicesBase
|
|
|
99
99
|
getCurrentUserId(): string | null;
|
|
100
100
|
hasValidToken(): boolean;
|
|
101
101
|
getAccessToken(): string | null;
|
|
102
|
+
getAccessTokenExpiry(): number | null;
|
|
102
103
|
setActingAs(userId: string | null): void;
|
|
103
104
|
getActingAs(): string | null;
|
|
104
105
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -128,6 +128,7 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
|
|
|
128
128
|
getCurrentUserId(): string | null;
|
|
129
129
|
hasValidToken(): boolean;
|
|
130
130
|
getAccessToken(): string | null;
|
|
131
|
+
getAccessTokenExpiry(): number | null;
|
|
131
132
|
setActingAs(userId: string | null): void;
|
|
132
133
|
getActingAs(): string | null;
|
|
133
134
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -71,6 +71,7 @@ export declare function OxyServicesRedirectAuthMixin<T extends typeof OxyService
|
|
|
71
71
|
getCurrentUserId(): string | null;
|
|
72
72
|
hasValidToken(): boolean;
|
|
73
73
|
getAccessToken(): string | null;
|
|
74
|
+
getAccessTokenExpiry(): number | null;
|
|
74
75
|
setActingAs(userId: string | null): void;
|
|
75
76
|
getActingAs(): string | null;
|
|
76
77
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -417,6 +417,7 @@ export declare function OxyServicesReputationMixin<T extends typeof OxyServicesB
|
|
|
417
417
|
getCurrentUserId(): string | null;
|
|
418
418
|
hasValidToken(): boolean;
|
|
419
419
|
getAccessToken(): string | null;
|
|
420
|
+
getAccessTokenExpiry(): number | null;
|
|
420
421
|
setActingAs(userId: string | null): void;
|
|
421
422
|
getActingAs(): string | null;
|
|
422
423
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -66,6 +66,7 @@ export declare function OxyServicesSecurityMixin<T extends typeof OxyServicesBas
|
|
|
66
66
|
getCurrentUserId(): string | null;
|
|
67
67
|
hasValidToken(): boolean;
|
|
68
68
|
getAccessToken(): string | null;
|
|
69
|
+
getAccessTokenExpiry(): number | null;
|
|
69
70
|
setActingAs(userId: string | null): void;
|
|
70
71
|
getActingAs(): string | null;
|
|
71
72
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -109,6 +109,7 @@ export declare function OxyServicesSilentAuthMixin<T extends typeof OxyServicesB
|
|
|
109
109
|
getCurrentUserId(): string | null;
|
|
110
110
|
hasValidToken(): boolean;
|
|
111
111
|
getAccessToken(): string | null;
|
|
112
|
+
getAccessTokenExpiry(): number | null;
|
|
112
113
|
setActingAs(userId: string | null): void;
|
|
113
114
|
getActingAs(): string | null;
|
|
114
115
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -94,6 +94,7 @@ export declare function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Ba
|
|
|
94
94
|
getCurrentUserId(): string | null;
|
|
95
95
|
hasValidToken(): boolean;
|
|
96
96
|
getAccessToken(): string | null;
|
|
97
|
+
getAccessTokenExpiry(): number | null;
|
|
97
98
|
setActingAs(userId: string | null): void;
|
|
98
99
|
getActingAs(): string | null;
|
|
99
100
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -92,6 +92,7 @@ export declare function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>
|
|
|
92
92
|
getCurrentUserId(): string | null;
|
|
93
93
|
hasValidToken(): boolean;
|
|
94
94
|
getAccessToken(): string | null;
|
|
95
|
+
getAccessTokenExpiry(): number | null;
|
|
95
96
|
setActingAs(userId: string | null): void;
|
|
96
97
|
getActingAs(): string | null;
|
|
97
98
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -363,6 +363,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
363
363
|
getCurrentUserId(): string | null;
|
|
364
364
|
hasValidToken(): boolean;
|
|
365
365
|
getAccessToken(): string | null;
|
|
366
|
+
getAccessTokenExpiry(): number | null;
|
|
366
367
|
setActingAs(userId: string | null): void;
|
|
367
368
|
getActingAs(): string | null;
|
|
368
369
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -277,6 +277,7 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
|
|
|
277
277
|
getCurrentUserId(): string | null;
|
|
278
278
|
hasValidToken(): boolean;
|
|
279
279
|
getAccessToken(): string | null;
|
|
280
|
+
getAccessTokenExpiry(): number | null;
|
|
280
281
|
setActingAs(userId: string | null): void;
|
|
281
282
|
getActingAs(): string | null;
|
|
282
283
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
@@ -198,6 +198,7 @@ export declare function OxyServicesWorkspacesMixin<T extends typeof OxyServicesB
|
|
|
198
198
|
getCurrentUserId(): string | null;
|
|
199
199
|
hasValidToken(): boolean;
|
|
200
200
|
getAccessToken(): string | null;
|
|
201
|
+
getAccessTokenExpiry(): number | null;
|
|
201
202
|
setActingAs(userId: string | null): void;
|
|
202
203
|
getActingAs(): string | null;
|
|
203
204
|
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -524,7 +524,14 @@ export class HttpService {
|
|
|
524
524
|
if (response.status === 401 && !config._isAuthRetry) {
|
|
525
525
|
const refreshed = await this.refreshAccessToken('response-401');
|
|
526
526
|
if (refreshed) {
|
|
527
|
-
|
|
527
|
+
// `deduplicate: false` is REQUIRED on the retry (mirrors the 403
|
|
528
|
+
// CSRF retry below). This re-issue runs while the ORIGINAL request
|
|
529
|
+
// is still in-flight under its dedupe key; the refreshed token is
|
|
530
|
+
// for the SAME user, so the identity-scoped key is UNCHANGED — a
|
|
531
|
+
// deduplicated retry would resolve to the still-pending original
|
|
532
|
+
// and await itself (deadlock). Opting the retry out of dedupe makes
|
|
533
|
+
// it a fresh request.
|
|
534
|
+
return this.request<T>({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
|
|
528
535
|
}
|
|
529
536
|
// Refresh failed or no token — clear tokens and stale CSRF
|
|
530
537
|
this.tokenStore.clearTokens();
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -310,6 +310,31 @@ export class OxyServicesBase {
|
|
|
310
310
|
return this.httpService.getAccessToken();
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Decode the current access token and return its `exp` claim in SECONDS since
|
|
315
|
+
* the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
|
|
316
|
+
* the token is opaque/undecodable, or it carries no numeric `exp`.
|
|
317
|
+
*
|
|
318
|
+
* Exposed so `@oxyhq/services` can schedule a PROACTIVE in-session refresh a
|
|
319
|
+
* fixed lead before expiry without re-importing a JWT decoder (and without
|
|
320
|
+
* duplicating the `jwt-decode` dependency in the RN bundle). HttpService keeps
|
|
321
|
+
* the per-request preflight refresh; this powers the idle/background timer.
|
|
322
|
+
*/
|
|
323
|
+
public getAccessTokenExpiry(): number | null {
|
|
324
|
+
const token = this.httpService.getAccessToken();
|
|
325
|
+
if (!token) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const decoded = jwtDecode<JwtPayload>(token);
|
|
330
|
+
return typeof decoded.exp === 'number' ? decoded.exp : null;
|
|
331
|
+
} catch {
|
|
332
|
+
// A malformed / non-JWT token has no usable expiry — fall back to the
|
|
333
|
+
// reactive 401 refresh path instead of a scheduled one.
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
313
338
|
/**
|
|
314
339
|
* Set the acting-as identity for managed accounts.
|
|
315
340
|
*
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-session access-token refresh plumbing.
|
|
3
|
+
*
|
|
4
|
+
* These guard the HttpService surface that `@oxyhq/services`' OxyContext now
|
|
5
|
+
* drives via `setAuthRefreshHandler`: a 401 re-mints through the handler and
|
|
6
|
+
* retries the ORIGINAL request with the fresh token; an unrecoverable refresh
|
|
7
|
+
* clears tokens and notifies listeners (the reconcile signal that flips the RN
|
|
8
|
+
* provider out of its zombie logged-in state); concurrent refreshes coalesce and
|
|
9
|
+
* a failed refresh is cooldown-guarded (no refresh storm). Plus the new
|
|
10
|
+
* `OxyServices.getAccessTokenExpiry()` helper the proactive scheduler reads.
|
|
11
|
+
*/
|
|
12
|
+
import { HttpService } from '../HttpService';
|
|
13
|
+
import { OxyServices } from '../OxyServices';
|
|
14
|
+
|
|
15
|
+
interface FetchCall {
|
|
16
|
+
url: string;
|
|
17
|
+
init: RequestInit | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createJwt(payload: Record<string, unknown>): string {
|
|
21
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
22
|
+
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function jsonResponse(data: unknown): Response {
|
|
26
|
+
return new Response(JSON.stringify({ data }), {
|
|
27
|
+
status: 200,
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function unauthorized(): Response {
|
|
33
|
+
return new Response(JSON.stringify({ error: 'token expired' }), {
|
|
34
|
+
status: 401,
|
|
35
|
+
statusText: 'Unauthorized',
|
|
36
|
+
headers: { 'content-type': 'application/json' },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readHeaders(init: RequestInit | undefined): Record<string, string> {
|
|
41
|
+
const headers = init?.headers;
|
|
42
|
+
if (!headers) return {};
|
|
43
|
+
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
|
44
|
+
if (Array.isArray(headers)) return Object.fromEntries(headers);
|
|
45
|
+
return headers as Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const farFutureExp = (): number => Math.floor(Date.now() / 1000) + 3600;
|
|
49
|
+
|
|
50
|
+
describe('HttpService in-session refresh handler', () => {
|
|
51
|
+
const originalFetch = globalThis.fetch;
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
globalThis.fetch = originalFetch;
|
|
55
|
+
jest.restoreAllMocks();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('refreshes via the handler on a 401 and retries the original request with the fresh token', async () => {
|
|
59
|
+
const oldToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'old' });
|
|
60
|
+
const newToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'new' });
|
|
61
|
+
const calls: FetchCall[] = [];
|
|
62
|
+
let feedAttempts = 0;
|
|
63
|
+
globalThis.fetch = async (input, init) => {
|
|
64
|
+
const url = String(input);
|
|
65
|
+
calls.push({ url, init });
|
|
66
|
+
if (url.endsWith('/feed')) {
|
|
67
|
+
feedAttempts += 1;
|
|
68
|
+
if (feedAttempts === 1) return unauthorized();
|
|
69
|
+
}
|
|
70
|
+
return jsonResponse({ ok: true });
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
74
|
+
http.setTokens(oldToken);
|
|
75
|
+
|
|
76
|
+
let handlerCalls = 0;
|
|
77
|
+
const reasons: string[] = [];
|
|
78
|
+
http.setAuthRefreshHandler(async (reason) => {
|
|
79
|
+
handlerCalls += 1;
|
|
80
|
+
reasons.push(reason);
|
|
81
|
+
// Simulate a silent arm planting the rotated token internally.
|
|
82
|
+
http.setTokens(newToken);
|
|
83
|
+
return newToken;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = await http.post('/feed', { cursor: 0 }, { retry: false });
|
|
87
|
+
|
|
88
|
+
expect(result).toEqual({ ok: true });
|
|
89
|
+
expect(handlerCalls).toBe(1);
|
|
90
|
+
expect(reasons).toEqual(['response-401']);
|
|
91
|
+
|
|
92
|
+
const feedCalls = calls.filter((call) => call.url.endsWith('/feed'));
|
|
93
|
+
expect(feedCalls).toHaveLength(2);
|
|
94
|
+
// The original request is retried carrying the FRESH bearer.
|
|
95
|
+
expect(readHeaders(feedCalls[1].init).Authorization).toBe(`Bearer ${newToken}`);
|
|
96
|
+
expect(http.getAccessToken()).toBe(newToken);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('clears tokens and notifies listeners when the handler cannot refresh on a 401 (reconcile signal)', async () => {
|
|
100
|
+
globalThis.fetch = async (input) => {
|
|
101
|
+
const url = String(input);
|
|
102
|
+
if (url.endsWith('/feed')) return unauthorized();
|
|
103
|
+
return jsonResponse({ ok: true });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const oxy = new OxyServices({ baseURL: 'https://api.mention.earth' });
|
|
107
|
+
oxy.setTokens(createJwt({ userId: 'u', exp: farFutureExp() }));
|
|
108
|
+
|
|
109
|
+
const tokenEvents: Array<string | null> = [];
|
|
110
|
+
oxy.onTokensChanged((token) => tokenEvents.push(token));
|
|
111
|
+
|
|
112
|
+
oxy.getClient().setAuthRefreshHandler(async () => null);
|
|
113
|
+
|
|
114
|
+
await expect(oxy.getClient().post('/feed', {}, { retry: false })).rejects.toBeDefined();
|
|
115
|
+
|
|
116
|
+
// The dead session reconciles: token cleared + a null change emitted (which
|
|
117
|
+
// OxyContext's `handleTokenChange(null)` turns into a local sign-out).
|
|
118
|
+
expect(oxy.getAccessToken()).toBeNull();
|
|
119
|
+
expect(tokenEvents).toContain(null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('coalesces concurrent refreshes into a single handler invocation', async () => {
|
|
123
|
+
globalThis.fetch = async () => jsonResponse({ ok: true });
|
|
124
|
+
const newToken = createJwt({ userId: 'u', exp: farFutureExp(), jti: 'fresh' });
|
|
125
|
+
|
|
126
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
127
|
+
http.setTokens(createJwt({ userId: 'u', exp: farFutureExp() }));
|
|
128
|
+
|
|
129
|
+
let handlerCalls = 0;
|
|
130
|
+
http.setAuthRefreshHandler(async () => {
|
|
131
|
+
handlerCalls += 1;
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
133
|
+
http.setTokens(newToken);
|
|
134
|
+
return newToken;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const [a, b, c] = await Promise.all([
|
|
138
|
+
http.refreshAccessToken('preflight'),
|
|
139
|
+
http.refreshAccessToken('preflight'),
|
|
140
|
+
http.refreshAccessToken('preflight'),
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
expect(handlerCalls).toBe(1);
|
|
144
|
+
expect([a, b, c]).toEqual([newToken, newToken, newToken]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('cooldown-guards a failed refresh so it does not storm the handler', async () => {
|
|
148
|
+
globalThis.fetch = async () => jsonResponse({ ok: true });
|
|
149
|
+
|
|
150
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
151
|
+
http.setTokens(createJwt({ userId: 'u', exp: farFutureExp() }));
|
|
152
|
+
|
|
153
|
+
let handlerCalls = 0;
|
|
154
|
+
http.setAuthRefreshHandler(async () => {
|
|
155
|
+
handlerCalls += 1;
|
|
156
|
+
return null;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const first = await http.refreshAccessToken('preflight');
|
|
160
|
+
const second = await http.refreshAccessToken('preflight');
|
|
161
|
+
|
|
162
|
+
expect(first).toBeNull();
|
|
163
|
+
expect(second).toBeNull();
|
|
164
|
+
// The second call is inside the post-failure cooldown → handler not re-run.
|
|
165
|
+
expect(handlerCalls).toBe(1);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('OxyServices.getAccessTokenExpiry', () => {
|
|
170
|
+
it('returns the JWT exp (seconds) of the current access token', () => {
|
|
171
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
172
|
+
const exp = Math.floor(Date.now() / 1000) + 1234;
|
|
173
|
+
oxy.setTokens(createJwt({ userId: 'u', exp }));
|
|
174
|
+
expect(oxy.getAccessTokenExpiry()).toBe(exp);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns null when there is no access token', () => {
|
|
178
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
179
|
+
expect(oxy.getAccessTokenExpiry()).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('returns null for an opaque / non-JWT token', () => {
|
|
183
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
184
|
+
oxy.setTokens('opaque-not-a-jwt');
|
|
185
|
+
expect(oxy.getAccessTokenExpiry()).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('returns null for a JWT with no numeric exp claim', () => {
|
|
189
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
190
|
+
oxy.setTokens(createJwt({ userId: 'u' }));
|
|
191
|
+
expect(oxy.getAccessTokenExpiry()).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,10 @@ import './crypto/polyfill';
|
|
|
26
26
|
export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices';
|
|
27
27
|
export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
|
|
28
28
|
export type { LinkedHttpClient } from './OxyServices.base';
|
|
29
|
+
// Auth-refresh handler surface — consumed by `@oxyhq/services`'s OxyContext to
|
|
30
|
+
// install an in-session access-token refresh handler on the owner HttpService
|
|
31
|
+
// (the linked-client refresh path delegates back to it).
|
|
32
|
+
export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
|
|
29
33
|
|
|
30
34
|
// ---------------------------------------------------------------------------
|
|
31
35
|
// Authentication
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* later read, so an SDK GET cache would pin the stale `'pending'` snapshot.
|
|
16
16
|
* App-side caching (React Query / stores) owns this responsibility.
|
|
17
17
|
*/
|
|
18
|
-
import type { LinkPreview
|
|
18
|
+
import type { LinkPreview } from '@oxyhq/contracts';
|
|
19
19
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
20
20
|
import { buildUrl } from '../utils/apiUtils';
|
|
21
21
|
|
|
@@ -57,11 +57,15 @@ export function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
57
57
|
* Resolve multiple link previews via `POST /links/previews` (body `{ urls }`).
|
|
58
58
|
*
|
|
59
59
|
* Inputs are de-duplicated and split into chunks of {@link LINK_PREVIEWS_CHUNK_SIZE}
|
|
60
|
-
* (the server-side cap). Chunks run concurrently and their
|
|
60
|
+
* (the server-side cap). Chunks run concurrently and their result maps are
|
|
61
61
|
* merged into a single result keyed by the REQUESTED url (the exact string
|
|
62
62
|
* passed in `urls`) — matching the batch contract — so a caller can always
|
|
63
63
|
* look its own input back up.
|
|
64
64
|
*
|
|
65
|
+
* `makeRequest` unwraps the API's top-level `{ data }` envelope (same as
|
|
66
|
+
* `getUsersByIds`'s `makeServiceRequest<User[]>`), so each chunk response is
|
|
67
|
+
* already the `Record<url, LinkPreview>` map — merge it directly.
|
|
68
|
+
*
|
|
65
69
|
* An empty / whitespace-only input resolves immediately with `{}` and
|
|
66
70
|
* performs no network call. A failure in any chunk surfaces (via
|
|
67
71
|
* `handleError`) rather than being swallowed.
|
|
@@ -82,7 +86,7 @@ export function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
82
86
|
try {
|
|
83
87
|
const responses = await Promise.all(
|
|
84
88
|
chunks.map((chunk) =>
|
|
85
|
-
this.makeRequest<
|
|
89
|
+
this.makeRequest<Record<string, LinkPreview>>(
|
|
86
90
|
'POST',
|
|
87
91
|
'/links/previews',
|
|
88
92
|
{ urls: chunk },
|
|
@@ -92,7 +96,7 @@ export function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
92
96
|
);
|
|
93
97
|
|
|
94
98
|
return responses.reduce<Record<string, LinkPreview>>(
|
|
95
|
-
(merged, response) => Object.assign(merged, response
|
|
99
|
+
(merged, response) => Object.assign(merged, response ?? {}),
|
|
96
100
|
{},
|
|
97
101
|
);
|
|
98
102
|
} catch (error) {
|