@oxyhq/core 1.5.0 → 1.6.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/AuthManager.js +14 -1
- package/dist/cjs/HttpService.js +87 -69
- package/dist/cjs/OxyServices.base.js +5 -4
- package/dist/cjs/crypto/keyManager.js +1 -13
- package/dist/cjs/crypto/signatureService.js +7 -20
- package/dist/cjs/index.js +9 -1
- package/dist/cjs/mixins/OxyServices.analytics.js +2 -2
- package/dist/cjs/mixins/OxyServices.assets.js +14 -14
- package/dist/cjs/mixins/OxyServices.auth.js +19 -19
- package/dist/cjs/mixins/OxyServices.developer.js +6 -6
- package/dist/cjs/mixins/OxyServices.devices.js +7 -7
- package/dist/cjs/mixins/OxyServices.features.js +23 -23
- package/dist/cjs/mixins/OxyServices.fedcm.js +1 -1
- package/dist/cjs/mixins/OxyServices.karma.js +6 -6
- package/dist/cjs/mixins/OxyServices.location.js +2 -2
- package/dist/cjs/mixins/OxyServices.payment.js +6 -6
- package/dist/cjs/mixins/OxyServices.popup.js +1 -1
- package/dist/cjs/mixins/OxyServices.privacy.js +6 -6
- package/dist/cjs/mixins/OxyServices.security.js +3 -3
- package/dist/cjs/mixins/OxyServices.user.js +22 -22
- package/dist/cjs/mixins/OxyServices.utility.js +39 -10
- package/dist/cjs/utils/authHelpers.js +114 -0
- package/dist/cjs/utils/platform.js +14 -0
- package/dist/esm/AuthManager.js +14 -1
- package/dist/esm/HttpService.js +87 -69
- package/dist/esm/OxyServices.base.js +5 -4
- package/dist/esm/crypto/keyManager.js +1 -13
- package/dist/esm/crypto/signatureService.js +2 -15
- package/dist/esm/index.js +2 -0
- package/dist/esm/mixins/OxyServices.analytics.js +2 -2
- package/dist/esm/mixins/OxyServices.assets.js +14 -14
- package/dist/esm/mixins/OxyServices.auth.js +19 -19
- package/dist/esm/mixins/OxyServices.developer.js +6 -6
- package/dist/esm/mixins/OxyServices.devices.js +7 -7
- package/dist/esm/mixins/OxyServices.features.js +23 -23
- package/dist/esm/mixins/OxyServices.fedcm.js +1 -1
- package/dist/esm/mixins/OxyServices.karma.js +6 -6
- package/dist/esm/mixins/OxyServices.location.js +2 -2
- package/dist/esm/mixins/OxyServices.payment.js +6 -6
- package/dist/esm/mixins/OxyServices.popup.js +1 -1
- package/dist/esm/mixins/OxyServices.privacy.js +6 -6
- package/dist/esm/mixins/OxyServices.security.js +3 -3
- package/dist/esm/mixins/OxyServices.user.js +22 -22
- package/dist/esm/mixins/OxyServices.utility.js +39 -10
- package/dist/esm/utils/authHelpers.js +105 -0
- package/dist/esm/utils/platform.js +12 -0
- package/dist/types/HttpService.d.ts +4 -1
- package/dist/types/OxyServices.base.d.ts +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -1
- package/dist/types/mixins/OxyServices.assets.d.ts +1 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -1
- package/dist/types/mixins/OxyServices.developer.d.ts +1 -1
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -1
- package/dist/types/mixins/OxyServices.features.d.ts +1 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -1
- package/dist/types/mixins/OxyServices.karma.d.ts +1 -1
- package/dist/types/mixins/OxyServices.language.d.ts +1 -1
- package/dist/types/mixins/OxyServices.location.d.ts +1 -1
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -1
- package/dist/types/mixins/OxyServices.popup.d.ts +1 -1
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -1
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -1
- package/dist/types/mixins/OxyServices.security.d.ts +1 -1
- package/dist/types/mixins/OxyServices.user.d.ts +1 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +20 -6
- package/dist/types/utils/authHelpers.d.ts +57 -0
- package/dist/types/utils/platform.d.ts +8 -0
- package/package.json +1 -1
- package/src/AuthManager.ts +14 -1
- package/src/HttpService.ts +85 -67
- package/src/OxyServices.base.ts +5 -4
- package/src/crypto/keyManager.ts +1 -15
- package/src/crypto/signatureService.ts +2 -17
- package/src/index.ts +11 -0
- package/src/mixins/OxyServices.analytics.ts +2 -2
- package/src/mixins/OxyServices.assets.ts +14 -14
- package/src/mixins/OxyServices.auth.ts +19 -19
- package/src/mixins/OxyServices.developer.ts +6 -6
- package/src/mixins/OxyServices.devices.ts +7 -7
- package/src/mixins/OxyServices.features.ts +23 -23
- package/src/mixins/OxyServices.fedcm.ts +1 -1
- package/src/mixins/OxyServices.karma.ts +6 -6
- package/src/mixins/OxyServices.location.ts +2 -2
- package/src/mixins/OxyServices.payment.ts +6 -6
- package/src/mixins/OxyServices.popup.ts +1 -1
- package/src/mixins/OxyServices.privacy.ts +6 -6
- package/src/mixins/OxyServices.security.ts +3 -3
- package/src/mixins/OxyServices.user.ts +22 -22
- package/src/mixins/OxyServices.utility.ts +41 -11
- package/src/utils/authHelpers.ts +140 -0
- package/src/utils/platform.ts +14 -0
|
@@ -20,6 +20,7 @@ export declare function OxyServicesLocationMixin<T extends typeof OxyServicesBas
|
|
|
20
20
|
httpService: import("../HttpService").HttpService;
|
|
21
21
|
cloudURL: string;
|
|
22
22
|
config: import("../OxyServices.base").OxyConfig;
|
|
23
|
+
__resetTokensForTests(): void;
|
|
23
24
|
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
24
25
|
getBaseURL(): string;
|
|
25
26
|
getClient(): import("../HttpService").HttpService;
|
|
@@ -62,5 +63,4 @@ export declare function OxyServicesLocationMixin<T extends typeof OxyServicesBas
|
|
|
62
63
|
[key: string]: any;
|
|
63
64
|
}>;
|
|
64
65
|
};
|
|
65
|
-
__resetTokensForTests(): void;
|
|
66
66
|
} & T;
|
|
@@ -67,6 +67,7 @@ export declare function OxyServicesPaymentMixin<T extends typeof OxyServicesBase
|
|
|
67
67
|
httpService: import("../HttpService").HttpService;
|
|
68
68
|
cloudURL: string;
|
|
69
69
|
config: import("../OxyServices.base").OxyConfig;
|
|
70
|
+
__resetTokensForTests(): void;
|
|
70
71
|
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
71
72
|
getBaseURL(): string;
|
|
72
73
|
getClient(): import("../HttpService").HttpService;
|
|
@@ -109,5 +110,4 @@ export declare function OxyServicesPaymentMixin<T extends typeof OxyServicesBase
|
|
|
109
110
|
[key: string]: any;
|
|
110
111
|
}>;
|
|
111
112
|
};
|
|
112
|
-
__resetTokensForTests(): void;
|
|
113
113
|
} & T;
|
|
@@ -155,6 +155,7 @@ export declare function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBa
|
|
|
155
155
|
httpService: import("../HttpService").HttpService;
|
|
156
156
|
cloudURL: string;
|
|
157
157
|
config: import("../OxyServices.base").OxyConfig;
|
|
158
|
+
__resetTokensForTests(): void;
|
|
158
159
|
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
159
160
|
getBaseURL(): string;
|
|
160
161
|
getClient(): import("../HttpService").HttpService;
|
|
@@ -202,6 +203,5 @@ export declare function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBa
|
|
|
202
203
|
readonly POPUP_HEIGHT: 700;
|
|
203
204
|
readonly POPUP_TIMEOUT: 60000;
|
|
204
205
|
readonly SILENT_TIMEOUT: 5000;
|
|
205
|
-
__resetTokensForTests(): void;
|
|
206
206
|
} & T;
|
|
207
207
|
export { OxyServicesPopupAuthMixin as PopupAuthMixin };
|
|
@@ -78,6 +78,7 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
|
|
|
78
78
|
httpService: import("../HttpService").HttpService;
|
|
79
79
|
cloudURL: string;
|
|
80
80
|
config: import("../OxyServices.base").OxyConfig;
|
|
81
|
+
__resetTokensForTests(): void;
|
|
81
82
|
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
82
83
|
getBaseURL(): string;
|
|
83
84
|
getClient(): import("../HttpService").HttpService;
|
|
@@ -120,5 +121,4 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
|
|
|
120
121
|
[key: string]: any;
|
|
121
122
|
}>;
|
|
122
123
|
};
|
|
123
|
-
__resetTokensForTests(): void;
|
|
124
124
|
} & T;
|
|
@@ -194,6 +194,7 @@ export declare function OxyServicesRedirectAuthMixin<T extends typeof OxyService
|
|
|
194
194
|
httpService: import("../HttpService").HttpService;
|
|
195
195
|
cloudURL: string;
|
|
196
196
|
config: import("../OxyServices.base").OxyConfig;
|
|
197
|
+
__resetTokensForTests(): void;
|
|
197
198
|
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
198
199
|
getBaseURL(): string;
|
|
199
200
|
getClient(): import("../HttpService").HttpService;
|
|
@@ -242,6 +243,5 @@ export declare function OxyServicesRedirectAuthMixin<T extends typeof OxyService
|
|
|
242
243
|
readonly STATE_STORAGE_KEY: "oxy_auth_state";
|
|
243
244
|
readonly PRE_AUTH_URL_KEY: "oxy_pre_auth_url";
|
|
244
245
|
readonly NONCE_STORAGE_KEY: "oxy_auth_nonce";
|
|
245
|
-
__resetTokensForTests(): void;
|
|
246
246
|
} & T;
|
|
247
247
|
export { OxyServicesRedirectAuthMixin as RedirectAuthMixin };
|
|
@@ -34,6 +34,7 @@ export declare function OxyServicesSecurityMixin<T extends typeof OxyServicesBas
|
|
|
34
34
|
httpService: import("../HttpService").HttpService;
|
|
35
35
|
cloudURL: string;
|
|
36
36
|
config: import("../OxyServices.base").OxyConfig;
|
|
37
|
+
__resetTokensForTests(): void;
|
|
37
38
|
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
38
39
|
getBaseURL(): string;
|
|
39
40
|
getClient(): import("../HttpService").HttpService;
|
|
@@ -76,5 +77,4 @@ export declare function OxyServicesSecurityMixin<T extends typeof OxyServicesBas
|
|
|
76
77
|
[key: string]: any;
|
|
77
78
|
}>;
|
|
78
79
|
};
|
|
79
|
-
__resetTokensForTests(): void;
|
|
80
80
|
} & T;
|
|
@@ -138,6 +138,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
138
138
|
httpService: import("../HttpService").HttpService;
|
|
139
139
|
cloudURL: string;
|
|
140
140
|
config: import("../OxyServices.base").OxyConfig;
|
|
141
|
+
__resetTokensForTests(): void;
|
|
141
142
|
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
142
143
|
getBaseURL(): string;
|
|
143
144
|
getClient(): import("../HttpService").HttpService;
|
|
@@ -180,5 +181,4 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
180
181
|
[key: string]: any;
|
|
181
182
|
}>;
|
|
182
183
|
};
|
|
183
|
-
__resetTokensForTests(): void;
|
|
184
184
|
} & T;
|
|
@@ -43,25 +43,36 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
|
|
|
43
43
|
* Validates JWT tokens against the Oxy API and attaches user data to requests.
|
|
44
44
|
* Uses server-side session validation for security (not just JWT decode).
|
|
45
45
|
*
|
|
46
|
+
* **Design note — jwtDecode vs jwt.verify:**
|
|
47
|
+
* This middleware intentionally uses `jwtDecode()` (decode-only, no signature
|
|
48
|
+
* verification) for user tokens. This is by design, NOT a security gap:
|
|
49
|
+
* - Third-party apps using `oxy.auth()` don't have the Oxy JWT secret
|
|
50
|
+
* - Security comes from API-based session validation (`validateSession()`)
|
|
51
|
+
* which checks the session server-side on every request
|
|
52
|
+
* - Service tokens (type: 'service') DO use cryptographic HMAC verification
|
|
53
|
+
* via the `jwtSecret` option, since they are stateless
|
|
54
|
+
* - The backend's own `authMiddleware` uses `jwt.verify()` because it has
|
|
55
|
+
* direct access to `ACCESS_TOKEN_SECRET`
|
|
56
|
+
*
|
|
46
57
|
* @example
|
|
47
58
|
* ```typescript
|
|
48
59
|
* import { OxyServices } from '@oxyhq/core';
|
|
49
60
|
*
|
|
50
61
|
* const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
51
62
|
*
|
|
52
|
-
* // Protect all routes under /
|
|
53
|
-
* app.use('/
|
|
63
|
+
* // Protect all routes under /protected
|
|
64
|
+
* app.use('/protected', oxy.auth());
|
|
54
65
|
*
|
|
55
66
|
* // Access user in route handler
|
|
56
|
-
* app.get('/
|
|
67
|
+
* app.get('/protected/me', (req, res) => {
|
|
57
68
|
* res.json({ userId: req.userId, user: req.user });
|
|
58
69
|
* });
|
|
59
70
|
*
|
|
60
71
|
* // Load full user profile from API
|
|
61
|
-
* app.use('/
|
|
72
|
+
* app.use('/admin', oxy.auth({ loadUser: true }));
|
|
62
73
|
*
|
|
63
74
|
* // Optional auth - attach user if present, don't block if absent
|
|
64
|
-
* app.use('/
|
|
75
|
+
* app.use('/public', oxy.auth({ optional: true }));
|
|
65
76
|
* ```
|
|
66
77
|
*
|
|
67
78
|
* @param options Optional configuration
|
|
@@ -74,6 +85,8 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
|
|
|
74
85
|
* Returns a middleware function for Socket.IO that validates JWT tokens
|
|
75
86
|
* from the handshake auth object and attaches user data to the socket.
|
|
76
87
|
*
|
|
88
|
+
* Uses `jwtDecode()` + API session validation (same rationale as `auth()`).
|
|
89
|
+
*
|
|
77
90
|
* @example
|
|
78
91
|
* ```typescript
|
|
79
92
|
* import { OxyServices } from '@oxyhq/core';
|
|
@@ -111,10 +124,12 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
|
|
|
111
124
|
*/
|
|
112
125
|
serviceAuth(options?: {
|
|
113
126
|
debug?: boolean;
|
|
127
|
+
jwtSecret?: string;
|
|
114
128
|
}): (req: any, res: any, next: any) => Promise<void>;
|
|
115
129
|
httpService: import("../HttpService").HttpService;
|
|
116
130
|
cloudURL: string;
|
|
117
131
|
config: import("../OxyServices.base").OxyConfig;
|
|
132
|
+
__resetTokensForTests(): void;
|
|
118
133
|
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
119
134
|
getBaseURL(): string;
|
|
120
135
|
getClient(): import("../HttpService").HttpService;
|
|
@@ -157,6 +172,5 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
|
|
|
157
172
|
[key: string]: any;
|
|
158
173
|
}>;
|
|
159
174
|
};
|
|
160
|
-
__resetTokensForTests(): void;
|
|
161
175
|
} & T;
|
|
162
176
|
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication helper utilities for common token validation
|
|
3
|
+
* and authentication error handling patterns.
|
|
4
|
+
*/
|
|
5
|
+
import type { OxyServices } from '../OxyServices';
|
|
6
|
+
/**
|
|
7
|
+
* Error thrown when session sync is required
|
|
8
|
+
*/
|
|
9
|
+
export declare class SessionSyncRequiredError extends Error {
|
|
10
|
+
constructor(message?: string);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Error thrown when authentication fails
|
|
14
|
+
*/
|
|
15
|
+
export declare class AuthenticationFailedError extends Error {
|
|
16
|
+
constructor(message?: string);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Ensures a valid token exists before making authenticated API calls.
|
|
20
|
+
* If no valid token exists and an active session ID is available,
|
|
21
|
+
* attempts to refresh the token using the session.
|
|
22
|
+
*
|
|
23
|
+
* @throws {SessionSyncRequiredError} If the session needs to be synced (offline session)
|
|
24
|
+
*/
|
|
25
|
+
export declare function ensureValidToken(oxyServices: OxyServices, activeSessionId: string | null | undefined): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Options for handling API authentication errors
|
|
28
|
+
*/
|
|
29
|
+
export interface HandleApiErrorOptions {
|
|
30
|
+
syncSession?: () => Promise<unknown>;
|
|
31
|
+
activeSessionId?: string | null;
|
|
32
|
+
oxyServices?: OxyServices;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Checks if an error is an authentication error (401 or auth-related message)
|
|
36
|
+
*/
|
|
37
|
+
export declare function isAuthenticationError(error: unknown): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Wraps an API call with authentication error handling.
|
|
40
|
+
* On auth failure, optionally attempts to sync the session and retry.
|
|
41
|
+
*
|
|
42
|
+
* @throws {AuthenticationFailedError} If authentication fails and cannot be recovered
|
|
43
|
+
*/
|
|
44
|
+
export declare function withAuthErrorHandling<T>(apiCall: () => Promise<T>, options?: HandleApiErrorOptions): Promise<T>;
|
|
45
|
+
/**
|
|
46
|
+
* Combines token validation and auth error handling for a complete authenticated API call.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* return await authenticatedApiCall(
|
|
51
|
+
* oxyServices,
|
|
52
|
+
* activeSessionId,
|
|
53
|
+
* () => oxyServices.updateProfile(updates)
|
|
54
|
+
* );
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare function authenticatedApiCall<T>(oxyServices: OxyServices, activeSessionId: string | null | undefined, apiCall: () => Promise<T>, syncSession?: () => Promise<unknown>): Promise<T>;
|
|
@@ -27,6 +27,14 @@ export declare function isIOS(): boolean;
|
|
|
27
27
|
* Check if running on Android
|
|
28
28
|
*/
|
|
29
29
|
export declare function isAndroid(): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Check if running in React Native
|
|
32
|
+
*/
|
|
33
|
+
export declare function isReactNative(): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Check if running in Node.js
|
|
36
|
+
*/
|
|
37
|
+
export declare function isNodeJS(): boolean;
|
|
30
38
|
/**
|
|
31
39
|
* Set the platform OS explicitly
|
|
32
40
|
* Called by React Native entry point to register the platform
|
package/package.json
CHANGED
package/src/AuthManager.ts
CHANGED
|
@@ -308,7 +308,7 @@ export class AuthManager {
|
|
|
308
308
|
// Use session-based token endpoint which handles auto-refresh server-side
|
|
309
309
|
const response = await httpService.request<{ accessToken: string; expiresAt: string }>({
|
|
310
310
|
method: 'GET',
|
|
311
|
-
url: `/
|
|
311
|
+
url: `/session/token/${sessionId}`,
|
|
312
312
|
cache: false,
|
|
313
313
|
retry: false,
|
|
314
314
|
});
|
|
@@ -366,6 +366,19 @@ export class AuthManager {
|
|
|
366
366
|
this.refreshTimer = null;
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
// Invalidate current session on the server (best-effort)
|
|
370
|
+
try {
|
|
371
|
+
const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
|
|
372
|
+
if (sessionJson) {
|
|
373
|
+
const session = JSON.parse(sessionJson);
|
|
374
|
+
if (session.sessionId && typeof (this.oxyServices as any).logoutSession === 'function') {
|
|
375
|
+
await (this.oxyServices as any).logoutSession(session.sessionId);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
// Best-effort: don't block local signout on network failure
|
|
380
|
+
}
|
|
381
|
+
|
|
369
382
|
// Revoke FedCM credential if supported
|
|
370
383
|
try {
|
|
371
384
|
const services = this.oxyServices as OxyServicesWithFedCM;
|
package/src/HttpService.ts
CHANGED
|
@@ -54,27 +54,21 @@ interface RequestConfig extends RequestOptions {
|
|
|
54
54
|
params?: Record<string, unknown>;
|
|
55
55
|
/** @internal Used to prevent infinite auth retry loops */
|
|
56
56
|
_isAuthRetry?: boolean;
|
|
57
|
+
/** @internal Used to prevent infinite CSRF retry loops */
|
|
58
|
+
_isCsrfRetry?: boolean;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
|
-
* Token store for authentication (
|
|
62
|
+
* Token store for authentication (instance-based)
|
|
63
|
+
* Each HttpService gets its own TokenStore to prevent conflicts
|
|
64
|
+
* when multiple OxyServices instances coexist server-side.
|
|
61
65
|
*/
|
|
62
66
|
class TokenStore {
|
|
63
|
-
private static instance: TokenStore;
|
|
64
67
|
private accessToken: string | null = null;
|
|
65
68
|
private refreshToken: string | null = null;
|
|
66
69
|
private csrfToken: string | null = null;
|
|
67
70
|
private csrfTokenFetchPromise: Promise<string | null> | null = null;
|
|
68
71
|
|
|
69
|
-
private constructor() {}
|
|
70
|
-
|
|
71
|
-
static getInstance(): TokenStore {
|
|
72
|
-
if (!TokenStore.instance) {
|
|
73
|
-
TokenStore.instance = new TokenStore();
|
|
74
|
-
}
|
|
75
|
-
return TokenStore.instance;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
72
|
setTokens(accessToken: string, refreshToken = ''): void {
|
|
79
73
|
this.accessToken = accessToken;
|
|
80
74
|
this.refreshToken = refreshToken;
|
|
@@ -134,6 +128,7 @@ export class HttpService {
|
|
|
134
128
|
private logger: SimpleLogger;
|
|
135
129
|
private config: OxyConfig;
|
|
136
130
|
private tokenRefreshPromise: Promise<string | null> | null = null;
|
|
131
|
+
private tokenRefreshCooldownUntil: number = 0;
|
|
137
132
|
private _onTokenRefreshed: ((accessToken: string) => void) | null = null;
|
|
138
133
|
|
|
139
134
|
// Performance monitoring
|
|
@@ -149,7 +144,7 @@ export class HttpService {
|
|
|
149
144
|
constructor(config: OxyConfig) {
|
|
150
145
|
this.config = config;
|
|
151
146
|
this.baseURL = config.baseURL;
|
|
152
|
-
this.tokenStore = TokenStore
|
|
147
|
+
this.tokenStore = new TokenStore();
|
|
153
148
|
|
|
154
149
|
this.logger = new SimpleLogger(
|
|
155
150
|
config.enableLogging || false,
|
|
@@ -346,6 +341,20 @@ export class HttpService {
|
|
|
346
341
|
this.tokenStore.clearTokens();
|
|
347
342
|
}
|
|
348
343
|
|
|
344
|
+
// On 403 with CSRF error, clear cached token and retry once
|
|
345
|
+
if (response.status === 403 && !config._isCsrfRetry) {
|
|
346
|
+
try {
|
|
347
|
+
const clonedResponse = response.clone();
|
|
348
|
+
const errBody = await clonedResponse.json() as { code?: string } | null;
|
|
349
|
+
if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
|
|
350
|
+
this.tokenStore.clearCsrfToken();
|
|
351
|
+
return this.request<T>({ ...config, _isCsrfRetry: true, retry: false });
|
|
352
|
+
}
|
|
353
|
+
} catch {
|
|
354
|
+
// Failed to parse error body — not a CSRF error
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
349
358
|
// Try to parse error response (handle empty/malformed JSON)
|
|
350
359
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
351
360
|
const contentType = response.headers.get('content-type');
|
|
@@ -518,52 +527,58 @@ export class HttpService {
|
|
|
518
527
|
}
|
|
519
528
|
|
|
520
529
|
const fetchPromise = (async () => {
|
|
521
|
-
|
|
522
|
-
|
|
530
|
+
const maxAttempts = 2;
|
|
531
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
532
|
+
try {
|
|
533
|
+
if (isDev()) console.log('[HttpService] Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
|
|
523
534
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
535
|
+
// Use AbortController for timeout (more compatible than AbortSignal.timeout)
|
|
536
|
+
const controller = new AbortController();
|
|
537
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
527
538
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
539
|
+
const response = await fetch(`${this.baseURL}/csrf-token`, {
|
|
540
|
+
method: 'GET',
|
|
541
|
+
headers: { 'Accept': 'application/json' },
|
|
542
|
+
credentials: 'include', // Required to receive and send cookies
|
|
543
|
+
signal: controller.signal,
|
|
544
|
+
});
|
|
534
545
|
|
|
535
|
-
|
|
546
|
+
clearTimeout(timeoutId);
|
|
536
547
|
|
|
537
|
-
|
|
548
|
+
if (isDev()) console.log('[HttpService] CSRF fetch response:', response.status, response.ok);
|
|
538
549
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
550
|
+
if (response.ok) {
|
|
551
|
+
const data = await response.json() as { csrfToken?: string };
|
|
552
|
+
if (isDev()) console.log('[HttpService] CSRF response data:', data);
|
|
553
|
+
const token = data.csrfToken || null;
|
|
554
|
+
this.tokenStore.setCsrfToken(token);
|
|
555
|
+
this.logger.debug('CSRF token fetched');
|
|
556
|
+
return token;
|
|
557
|
+
}
|
|
547
558
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
559
|
+
// Also check response header for CSRF token
|
|
560
|
+
const headerToken = response.headers.get('X-CSRF-Token');
|
|
561
|
+
if (headerToken) {
|
|
562
|
+
this.tokenStore.setCsrfToken(headerToken);
|
|
563
|
+
this.logger.debug('CSRF token from header');
|
|
564
|
+
return headerToken;
|
|
565
|
+
}
|
|
555
566
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
567
|
+
if (isDev()) console.log('[HttpService] CSRF fetch failed with status:', response.status);
|
|
568
|
+
this.logger.warn('Failed to fetch CSRF token:', response.status);
|
|
569
|
+
} catch (error) {
|
|
570
|
+
if (isDev()) console.log('[HttpService] CSRF fetch error:', error);
|
|
571
|
+
this.logger.warn('CSRF token fetch error:', error);
|
|
572
|
+
}
|
|
573
|
+
// Wait before retry (500ms)
|
|
574
|
+
if (attempt < maxAttempts) {
|
|
575
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
576
|
+
}
|
|
565
577
|
}
|
|
566
|
-
|
|
578
|
+
return null;
|
|
579
|
+
})().finally(() => {
|
|
580
|
+
this.tokenStore.setCsrfTokenFetchPromise(null);
|
|
581
|
+
});
|
|
567
582
|
|
|
568
583
|
this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
|
|
569
584
|
return fetchPromise;
|
|
@@ -584,16 +599,23 @@ export class HttpService {
|
|
|
584
599
|
|
|
585
600
|
// If token expires in less than 60 seconds, refresh it
|
|
586
601
|
if (decoded.exp && decoded.exp - currentTime < 60 && decoded.sessionId) {
|
|
587
|
-
//
|
|
588
|
-
if (
|
|
589
|
-
|
|
602
|
+
// Skip if we recently failed a refresh (5s cooldown to prevent storms)
|
|
603
|
+
if (Date.now() < this.tokenRefreshCooldownUntil) {
|
|
604
|
+
return `Bearer ${accessToken}`;
|
|
590
605
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
this.tokenRefreshPromise =
|
|
606
|
+
// Deduplicate concurrent refresh attempts. The promise is shared
|
|
607
|
+
// across all concurrent callers and cleared only after it settles,
|
|
608
|
+
// so every awaiter receives the same result.
|
|
609
|
+
if (!this.tokenRefreshPromise) {
|
|
610
|
+
this.tokenRefreshPromise = this._refreshTokenFromSession(decoded.sessionId)
|
|
611
|
+
.then((result) => {
|
|
612
|
+
if (!result) this.tokenRefreshCooldownUntil = Date.now() + 5000;
|
|
613
|
+
return result;
|
|
614
|
+
})
|
|
615
|
+
.finally(() => { this.tokenRefreshPromise = null; });
|
|
596
616
|
}
|
|
617
|
+
const result = await this.tokenRefreshPromise;
|
|
618
|
+
if (result) return result;
|
|
597
619
|
}
|
|
598
620
|
|
|
599
621
|
return `Bearer ${accessToken}`;
|
|
@@ -605,7 +627,7 @@ export class HttpService {
|
|
|
605
627
|
|
|
606
628
|
private async _refreshTokenFromSession(sessionId: string): Promise<string | null> {
|
|
607
629
|
try {
|
|
608
|
-
const refreshUrl = `${this.baseURL}/
|
|
630
|
+
const refreshUrl = `${this.baseURL}/session/token/${sessionId}`;
|
|
609
631
|
const response = await fetch(refreshUrl, {
|
|
610
632
|
method: 'GET',
|
|
611
633
|
headers: { 'Accept': 'application/json' },
|
|
@@ -778,14 +800,10 @@ export class HttpService {
|
|
|
778
800
|
return { ...this.requestMetrics };
|
|
779
801
|
}
|
|
780
802
|
|
|
781
|
-
// Test-only utility
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
} catch (error) {
|
|
786
|
-
// Silently fail in test cleanup - this is expected behavior
|
|
787
|
-
// TokenStore might not be initialized in some test scenarios
|
|
788
|
-
}
|
|
803
|
+
// Test-only utility — clears tokens on this instance
|
|
804
|
+
__resetTokensForTests(): void {
|
|
805
|
+
this.tokenStore.clearTokens();
|
|
806
|
+
this.tokenStore.clearCsrfToken();
|
|
789
807
|
}
|
|
790
808
|
}
|
|
791
809
|
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -41,9 +41,10 @@ export class OxyServicesBase {
|
|
|
41
41
|
this.httpService = new HttpService(config);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// Test-only utility to reset
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// Test-only utility to reset tokens on this instance between jest tests
|
|
45
|
+
// Note: tokens are now per-instance, so create new instances in tests for isolation
|
|
46
|
+
__resetTokensForTests(): void {
|
|
47
|
+
this.httpService.__resetTokensForTests();
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -304,7 +305,7 @@ export class OxyServicesBase {
|
|
|
304
305
|
}
|
|
305
306
|
|
|
306
307
|
try {
|
|
307
|
-
const res = await this.makeRequest<{ valid: boolean }>('GET', '/
|
|
308
|
+
const res = await this.makeRequest<{ valid: boolean }>('GET', '/auth/validate', undefined, {
|
|
308
309
|
cache: false,
|
|
309
310
|
retry: false,
|
|
310
311
|
});
|
package/src/crypto/keyManager.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { ec as EC } from 'elliptic';
|
|
9
9
|
import type { ECKeyPair } from 'elliptic';
|
|
10
|
-
import { isWeb, isIOS, isAndroid } from '../utils/platform';
|
|
10
|
+
import { isWeb, isIOS, isAndroid, isReactNative, isNodeJS } from '../utils/platform';
|
|
11
11
|
import { logger } from '../utils/loggerUtils';
|
|
12
12
|
import { isDev } from '../shared/utils/debugUtils';
|
|
13
13
|
|
|
@@ -64,20 +64,6 @@ async function initSecureStore(): Promise<typeof import('expo-secure-store')> {
|
|
|
64
64
|
return SecureStore;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
* Check if we're in a React Native environment
|
|
69
|
-
*/
|
|
70
|
-
function isReactNative(): boolean {
|
|
71
|
-
return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Check if we're in a Node.js environment
|
|
76
|
-
*/
|
|
77
|
-
function isNodeJS(): boolean {
|
|
78
|
-
return typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
67
|
/**
|
|
82
68
|
* Check if we're on web platform
|
|
83
69
|
* Identity storage is only available on native platforms (iOS/Android)
|
|
@@ -7,26 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import { ec as EC } from 'elliptic';
|
|
9
9
|
import { KeyManager } from './keyManager';
|
|
10
|
+
import { isReactNative, isNodeJS } from '../utils/platform';
|
|
10
11
|
|
|
11
12
|
// Lazy import for expo-crypto
|
|
12
13
|
let ExpoCrypto: typeof import('expo-crypto') | null = null;
|
|
13
14
|
|
|
14
15
|
const ec = new EC('secp256k1');
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
* Check if we're in a React Native environment
|
|
18
|
-
*/
|
|
19
|
-
function isReactNative(): boolean {
|
|
20
|
-
return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Check if we're in a Node.js environment
|
|
25
|
-
*/
|
|
26
|
-
function isNodeJS(): boolean {
|
|
27
|
-
return typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
17
|
/**
|
|
31
18
|
* Initialize expo-crypto module
|
|
32
19
|
*/
|
|
@@ -55,9 +42,7 @@ async function sha256(message: string): Promise<string> {
|
|
|
55
42
|
// In Node.js, use Node's crypto module
|
|
56
43
|
if (isNodeJS()) {
|
|
57
44
|
try {
|
|
58
|
-
|
|
59
|
-
const getCrypto = new Function('return require("crypto")');
|
|
60
|
-
const nodeCrypto = getCrypto();
|
|
45
|
+
const nodeCrypto = await import('crypto');
|
|
61
46
|
return nodeCrypto.createHash('sha256').update(message).digest('hex');
|
|
62
47
|
} catch {
|
|
63
48
|
// Fall through to Web Crypto API
|
package/src/index.ts
CHANGED
|
@@ -123,6 +123,17 @@ export {
|
|
|
123
123
|
// --- i18n ---
|
|
124
124
|
export { translate } from './i18n';
|
|
125
125
|
|
|
126
|
+
// --- Auth Helpers ---
|
|
127
|
+
export {
|
|
128
|
+
SessionSyncRequiredError,
|
|
129
|
+
AuthenticationFailedError,
|
|
130
|
+
ensureValidToken,
|
|
131
|
+
isAuthenticationError,
|
|
132
|
+
withAuthErrorHandling,
|
|
133
|
+
authenticatedApiCall,
|
|
134
|
+
} from './utils/authHelpers';
|
|
135
|
+
export type { HandleApiErrorOptions } from './utils/authHelpers';
|
|
136
|
+
|
|
126
137
|
// --- Session Utilities ---
|
|
127
138
|
export { mergeSessions, normalizeAndSortSessions, sessionsArraysEqual } from './utils/sessionUtils';
|
|
128
139
|
|
|
@@ -19,7 +19,7 @@ export function OxyServicesAnalyticsMixin<T extends typeof OxyServicesBase>(Base
|
|
|
19
19
|
*/
|
|
20
20
|
async trackEvent(eventName: string, properties?: Record<string, any>): Promise<void> {
|
|
21
21
|
try {
|
|
22
|
-
await this.makeRequest('POST', '/
|
|
22
|
+
await this.makeRequest('POST', '/analytics/events', {
|
|
23
23
|
event: eventName,
|
|
24
24
|
properties
|
|
25
25
|
}, { cache: false, retry: false }); // Don't retry analytics events
|
|
@@ -40,7 +40,7 @@ export function OxyServicesAnalyticsMixin<T extends typeof OxyServicesBase>(Base
|
|
|
40
40
|
if (startDate) params.startDate = startDate;
|
|
41
41
|
if (endDate) params.endDate = endDate;
|
|
42
42
|
|
|
43
|
-
return await this.makeRequest('GET', '/
|
|
43
|
+
return await this.makeRequest('GET', '/analytics', params, {
|
|
44
44
|
cache: true,
|
|
45
45
|
cacheTTL: CACHE_TIMES.LONG,
|
|
46
46
|
});
|