@oxyhq/core 12.9.0 → 12.10.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 +22 -12
- package/dist/cjs/crypto/keyManager.js +89 -0
- package/dist/cjs/i18n/locales/ar-SA.json +4 -0
- package/dist/cjs/i18n/locales/ca-ES.json +4 -0
- package/dist/cjs/i18n/locales/de-DE.json +4 -0
- package/dist/cjs/i18n/locales/en-US.json +69 -3
- package/dist/cjs/i18n/locales/es-ES.json +68 -2
- package/dist/cjs/i18n/locales/fr-FR.json +4 -0
- package/dist/cjs/i18n/locales/it-IT.json +4 -0
- package/dist/cjs/i18n/locales/ja-JP.json +4 -0
- package/dist/cjs/i18n/locales/ko-KR.json +4 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +4 -0
- package/dist/cjs/i18n/locales/locales/ca-ES.json +4 -0
- package/dist/cjs/i18n/locales/locales/de-DE.json +4 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +69 -3
- package/dist/cjs/i18n/locales/locales/es-ES.json +68 -2
- package/dist/cjs/i18n/locales/locales/fr-FR.json +4 -0
- package/dist/cjs/i18n/locales/locales/it-IT.json +4 -0
- package/dist/cjs/i18n/locales/locales/ja-JP.json +4 -0
- package/dist/cjs/i18n/locales/locales/ko-KR.json +4 -0
- package/dist/cjs/i18n/locales/locales/pt-PT.json +4 -0
- package/dist/cjs/i18n/locales/locales/zh-CN.json +4 -0
- package/dist/cjs/i18n/locales/pt-PT.json +4 -0
- package/dist/cjs/i18n/locales/zh-CN.json +4 -0
- package/dist/cjs/index.js +7 -4
- package/dist/cjs/mixins/OxyServices.auth.js +51 -20
- package/dist/cjs/mixins/OxyServices.connectedApps.js +6 -1
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -2
- package/dist/cjs/mixins/OxyServices.identityBackup.js +11 -0
- package/dist/cjs/mixins/OxyServices.user.js +6 -0
- package/dist/cjs/session/accountDialogController.js +7 -5
- package/dist/cjs/utils/commonsApproval.js +31 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +22 -12
- package/dist/esm/crypto/keyManager.js +89 -0
- package/dist/esm/i18n/locales/ar-SA.json +4 -0
- package/dist/esm/i18n/locales/ca-ES.json +4 -0
- package/dist/esm/i18n/locales/de-DE.json +4 -0
- package/dist/esm/i18n/locales/en-US.json +69 -3
- package/dist/esm/i18n/locales/es-ES.json +68 -2
- package/dist/esm/i18n/locales/fr-FR.json +4 -0
- package/dist/esm/i18n/locales/it-IT.json +4 -0
- package/dist/esm/i18n/locales/ja-JP.json +4 -0
- package/dist/esm/i18n/locales/ko-KR.json +4 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +4 -0
- package/dist/esm/i18n/locales/locales/ca-ES.json +4 -0
- package/dist/esm/i18n/locales/locales/de-DE.json +4 -0
- package/dist/esm/i18n/locales/locales/en-US.json +69 -3
- package/dist/esm/i18n/locales/locales/es-ES.json +68 -2
- package/dist/esm/i18n/locales/locales/fr-FR.json +4 -0
- package/dist/esm/i18n/locales/locales/it-IT.json +4 -0
- package/dist/esm/i18n/locales/locales/ja-JP.json +4 -0
- package/dist/esm/i18n/locales/locales/ko-KR.json +4 -0
- package/dist/esm/i18n/locales/locales/pt-PT.json +4 -0
- package/dist/esm/i18n/locales/locales/zh-CN.json +4 -0
- package/dist/esm/i18n/locales/pt-PT.json +4 -0
- package/dist/esm/i18n/locales/zh-CN.json +4 -0
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +48 -19
- package/dist/esm/mixins/OxyServices.connectedApps.js +6 -1
- package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -2
- package/dist/esm/mixins/OxyServices.identityBackup.js +11 -0
- package/dist/esm/mixins/OxyServices.user.js +6 -0
- package/dist/esm/session/accountDialogController.js +7 -5
- package/dist/esm/utils/commonsApproval.js +27 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +14 -0
- package/dist/types/crypto/keyManager.d.ts +33 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -4
- package/dist/types/session/accountDialogController.d.ts +7 -2
- package/dist/types/utils/commonsApproval.d.ts +13 -0
- package/package.json +3 -3
- package/src/HttpService.ts +37 -14
- package/src/__tests__/httpServiceAuthSelfAwait.test.ts +108 -0
- package/src/__tests__/httpServiceQueueDeadlock.test.ts +128 -0
- package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
- package/src/crypto/keyManager.ts +99 -0
- package/src/i18n/locales/ar-SA.json +4 -0
- package/src/i18n/locales/ca-ES.json +4 -0
- package/src/i18n/locales/de-DE.json +4 -0
- package/src/i18n/locales/en-US.json +69 -3
- package/src/i18n/locales/es-ES.json +68 -2
- package/src/i18n/locales/fr-FR.json +4 -0
- package/src/i18n/locales/it-IT.json +4 -0
- package/src/i18n/locales/ja-JP.json +4 -0
- package/src/i18n/locales/ko-KR.json +4 -0
- package/src/i18n/locales/pt-PT.json +4 -0
- package/src/i18n/locales/zh-CN.json +4 -0
- package/src/index.ts +7 -1
- package/src/mixins/OxyServices.auth.ts +48 -24
- package/src/mixins/OxyServices.connectedApps.ts +6 -1
- package/src/mixins/OxyServices.deviceBoot.ts +7 -2
- package/src/mixins/OxyServices.identityBackup.ts +13 -0
- package/src/mixins/OxyServices.user.ts +6 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
- package/src/mixins/__tests__/commonsSignIn.test.ts +4 -4
- package/src/mixins/__tests__/identityBackup.test.ts +57 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +152 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +6 -0
- package/src/mixins/__tests__/webauthnAuth.test.ts +6 -6
- package/src/session/__tests__/accountDialogController.test.ts +3 -3
- package/src/session/accountDialogController.ts +7 -6
- package/src/utils/__tests__/commonsApproval.test.ts +60 -0
- package/src/utils/commonsApproval.ts +39 -0
|
@@ -40,6 +40,20 @@ export interface RequestOptions {
|
|
|
40
40
|
* mint call fully independent of the current (near-expired) bearer.
|
|
41
41
|
*/
|
|
42
42
|
skipAuth?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Execute this request WITHOUT taking a `RequestQueue` slot — run it directly.
|
|
45
|
+
*
|
|
46
|
+
* A queue slot represents network occupancy for ordinary DATA requests. The
|
|
47
|
+
* CONTROL-PLANE calls the auth lane depends on (the device-secret mint, `POST
|
|
48
|
+
* /session/device/token`, reached from `getAuthHeader` → `refreshAccessToken`)
|
|
49
|
+
* must never compete for a slot: when `maxConcurrentRequests` requests are all
|
|
50
|
+
* parked awaiting that very mint, a queued mint could never acquire a slot to
|
|
51
|
+
* run — a systemic deadlock. `bypassQueue` lets the mint run even when the
|
|
52
|
+
* queue is saturated. (The auth preflight is also resolved OUTSIDE the slot in
|
|
53
|
+
* `request()`, so ordinary requests never hold a slot while awaiting the mint;
|
|
54
|
+
* this flag is the explicit guarantee for the mint itself.)
|
|
55
|
+
*/
|
|
56
|
+
bypassQueue?: boolean;
|
|
43
57
|
}
|
|
44
58
|
interface RequestConfig extends RequestOptions {
|
|
45
59
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
@@ -396,6 +396,39 @@ export declare class KeyManager {
|
|
|
396
396
|
* and NEVER cached, so a poisoned "no identity" verdict can no longer stick.
|
|
397
397
|
*/
|
|
398
398
|
static getPublicKey(): Promise<string | null>;
|
|
399
|
+
/**
|
|
400
|
+
* Persist the recovery mnemonic (the 12-word phrase) into its dedicated,
|
|
401
|
+
* device-only keychain slot so the user can re-reveal it from Settings after
|
|
402
|
+
* onboarding.
|
|
403
|
+
*
|
|
404
|
+
* Called best-effort at identity creation/import, where the phrase is already
|
|
405
|
+
* in memory: a failure to persist it must NEVER fail the identity itself, so
|
|
406
|
+
* callers deliberately swallow the thrown error (logging it). Storage errors
|
|
407
|
+
* throw {@link IdentityUnavailableError} — same "cannot determine" semantics as
|
|
408
|
+
* the other getters — so a caller MAY observe/log the failure.
|
|
409
|
+
*
|
|
410
|
+
* The mnemonic is stored ONLY here — never in the marker, `getIdentityStatus`,
|
|
411
|
+
* logs, or any exported bundle.
|
|
412
|
+
*/
|
|
413
|
+
static storeRecoveryMnemonic(mnemonic: string): Promise<void>;
|
|
414
|
+
/**
|
|
415
|
+
* Read the stored recovery mnemonic for re-reveal in Settings.
|
|
416
|
+
*
|
|
417
|
+
* Returns the phrase, or `null` when a read SUCCEEDS and finds none — the
|
|
418
|
+
* expected result for any identity created/imported before this feature
|
|
419
|
+
* existed, since the phrase was never captured for those. THROWS
|
|
420
|
+
* {@link IdentityUnavailableError} when storage is unreadable (keychain locked
|
|
421
|
+
* / module load failure), matching {@link getPublicKey}'s contract — a thrown
|
|
422
|
+
* read is never flattened to `null`, so a caller distinguishes "phrase was
|
|
423
|
+
* never stored" from "keychain temporarily locked, retry".
|
|
424
|
+
*/
|
|
425
|
+
static getRecoveryMnemonic(): Promise<string | null>;
|
|
426
|
+
/**
|
|
427
|
+
* Delete the stored recovery mnemonic. Best-effort: a delete failure is logged
|
|
428
|
+
* and swallowed, never thrown — it runs inside the identity-deletion path where
|
|
429
|
+
* an unreadable keychain must not abort the wider teardown.
|
|
430
|
+
*/
|
|
431
|
+
static deleteRecoveryMnemonic(): Promise<void>;
|
|
399
432
|
/**
|
|
400
433
|
* Check if a complete, parseable identity exists on this device.
|
|
401
434
|
*
|
package/dist/types/index.d.ts
CHANGED
|
@@ -21,7 +21,8 @@ export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthen
|
|
|
21
21
|
export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
|
|
22
22
|
export type { LinkedHttpClient } from './OxyServices.base';
|
|
23
23
|
export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
|
|
24
|
-
export { ServiceCredentialMismatchError } from './mixins/OxyServices.auth';
|
|
24
|
+
export { ServiceCredentialMismatchError, } from './mixins/OxyServices.auth';
|
|
25
|
+
export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from './utils/commonsApproval';
|
|
25
26
|
export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
26
27
|
export type { CommonsSignInHandle, CommonsSignInStatus, CommonsApprovalInfo, CommonsSignInActionResult, } from './mixins/OxyServices.auth';
|
|
27
28
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
@@ -8,6 +8,7 @@ import type { LoginResult, LoginSessionResult } from '@oxyhq/contracts';
|
|
|
8
8
|
import type { SessionLoginResponse } from '../models/session';
|
|
9
9
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
10
10
|
import type { PublicApplication } from './OxyServices.connectedApps';
|
|
11
|
+
export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from '../utils/commonsApproval';
|
|
11
12
|
export interface ChallengeResponse {
|
|
12
13
|
challenge: string;
|
|
13
14
|
expiresAt: string;
|
|
@@ -71,7 +72,7 @@ export interface CommonsSignInStatus {
|
|
|
71
72
|
*/
|
|
72
73
|
export interface CommonsApprovalInfo {
|
|
73
74
|
/** Sanitized, display-safe identity of the requesting application. */
|
|
74
|
-
application: PublicApplication;
|
|
75
|
+
application: PublicApplication | null;
|
|
75
76
|
/** OAuth scopes the application is requesting. */
|
|
76
77
|
scopes: string[];
|
|
77
78
|
/** The origin the session is bound to (the RP web origin), when applicable. */
|
|
@@ -84,8 +85,8 @@ export interface CommonsApprovalInfo {
|
|
|
84
85
|
* "not verified") by {@link OxyServicesAuthMixin.getCommonsApprovalInfo}.
|
|
85
86
|
*/
|
|
86
87
|
originVerified: boolean;
|
|
87
|
-
/** Server-authoritative expiry (epoch
|
|
88
|
-
expiresAt: number;
|
|
88
|
+
/** Server-authoritative expiry (epoch ms or ISO-8601 string from the API). */
|
|
89
|
+
expiresAt: number | string;
|
|
89
90
|
/** Session lifecycle status. */
|
|
90
91
|
status: string;
|
|
91
92
|
}
|
|
@@ -596,4 +597,3 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
596
597
|
}>;
|
|
597
598
|
};
|
|
598
599
|
} & T;
|
|
599
|
-
export {};
|
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
* password, social login, and 2FA were removed ecosystem-wide. Account
|
|
36
36
|
* creation (`signup` view) is the same two identity backends: a passkey
|
|
37
37
|
* ceremony on web, or a Commons-created identity.
|
|
38
|
+
*
|
|
39
|
+
* The controller owns NO surface PRESENTATION: whether the account dialog is
|
|
40
|
+
* mounted, visible, or dismissed is the job of the shared surface stack
|
|
41
|
+
* (`@oxyhq/services` presents the `AccountDialog` surface into `@oxyhq/bloom`'s
|
|
42
|
+
* stack). This is a headless state machine only — it exposes `setView` /
|
|
43
|
+
* `add` / `startSignup` (the view axis) and `cancelSignIn` (device-flow
|
|
44
|
+
* teardown), never an `open` / `close` / `visible`.
|
|
38
45
|
*/
|
|
39
46
|
import type { OxyServices } from '../OxyServices';
|
|
40
47
|
import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
@@ -294,8 +301,6 @@ export declare class AccountDialogController {
|
|
|
294
301
|
private reconcileAuth;
|
|
295
302
|
/** Set the dialog view directly. */
|
|
296
303
|
setView(view: AccountDialogView): void;
|
|
297
|
-
/** Return to the account list and cancel any in-flight sign-in flow. */
|
|
298
|
-
close(): void;
|
|
299
304
|
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
300
305
|
add(): void;
|
|
301
306
|
/** Switch to the "create account" view (passkey / Commons signup entry). */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PublicApplication } from '../mixins/OxyServices.connectedApps';
|
|
2
|
+
export interface CommonsApprovalValidationInput {
|
|
3
|
+
application: PublicApplication | null;
|
|
4
|
+
status: string;
|
|
5
|
+
expiresAt: number | string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Returns a user-facing blocking reason when an approval payload must not be
|
|
9
|
+
* shown as actionable, or `null` when the request is still pending and valid.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getCommonsApprovalBlockingReason(info: CommonsApprovalValidationInput): string | null;
|
|
12
|
+
/** Normalize API `expiresAt` (number or ISO string) to epoch ms. */
|
|
13
|
+
export declare function parseCommonsApprovalExpiresAt(expiresAt: CommonsApprovalValidationInput['expiresAt']): number | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.10.1",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -115,8 +115,8 @@
|
|
|
115
115
|
"dependencies": {
|
|
116
116
|
"@noble/ciphers": "^1.3.0",
|
|
117
117
|
"@noble/hashes": "^1.8.0",
|
|
118
|
-
"@oxyhq/contracts": "
|
|
119
|
-
"@oxyhq/protocol": "
|
|
118
|
+
"@oxyhq/contracts": "workspace:^",
|
|
119
|
+
"@oxyhq/protocol": "workspace:^",
|
|
120
120
|
"bip39": "^3.1.0",
|
|
121
121
|
"buffer": "^6.0.3",
|
|
122
122
|
"elliptic": "^6.6.1",
|
package/src/HttpService.ts
CHANGED
|
@@ -85,6 +85,20 @@ export interface RequestOptions {
|
|
|
85
85
|
* mint call fully independent of the current (near-expired) bearer.
|
|
86
86
|
*/
|
|
87
87
|
skipAuth?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Execute this request WITHOUT taking a `RequestQueue` slot — run it directly.
|
|
90
|
+
*
|
|
91
|
+
* A queue slot represents network occupancy for ordinary DATA requests. The
|
|
92
|
+
* CONTROL-PLANE calls the auth lane depends on (the device-secret mint, `POST
|
|
93
|
+
* /session/device/token`, reached from `getAuthHeader` → `refreshAccessToken`)
|
|
94
|
+
* must never compete for a slot: when `maxConcurrentRequests` requests are all
|
|
95
|
+
* parked awaiting that very mint, a queued mint could never acquire a slot to
|
|
96
|
+
* run — a systemic deadlock. `bypassQueue` lets the mint run even when the
|
|
97
|
+
* queue is saturated. (The auth preflight is also resolved OUTSIDE the slot in
|
|
98
|
+
* `request()`, so ordinary requests never hold a slot while awaiting the mint;
|
|
99
|
+
* this flag is the explicit guarantee for the mint itself.)
|
|
100
|
+
*/
|
|
101
|
+
bypassQueue?: boolean;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
interface RequestConfig extends RequestOptions {
|
|
@@ -414,24 +428,29 @@ export class HttpService {
|
|
|
414
428
|
this.requestMetrics.cacheMisses++;
|
|
415
429
|
}
|
|
416
430
|
|
|
431
|
+
// Resolve the auth preflight OUTSIDE the queue slot. A slot represents
|
|
432
|
+
// NETWORK OCCUPANCY only. `getAuthHeader` may await the single-flight token
|
|
433
|
+
// refresh/mint — itself a request — so if a slot-holding request awaited it
|
|
434
|
+
// here and every slot were held by requests all waiting on that same mint,
|
|
435
|
+
// the mint could never acquire a slot to run (systemic deadlock). Resolving
|
|
436
|
+
// it before enqueue means auth-blocked requests hold NO slot while the shared
|
|
437
|
+
// mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
|
|
438
|
+
// and skip the near-expiry preflight — see RequestOptions.skipAuth. The
|
|
439
|
+
// 401/CSRF retry re-enters request() with a fresh config, so it re-resolves
|
|
440
|
+
// these here with the refreshed token / cleared CSRF.
|
|
441
|
+
const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
|
442
|
+
const authHeader = config.skipAuth ? null : await this.getAuthHeader();
|
|
443
|
+
// CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
|
|
444
|
+
// clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
|
|
445
|
+
// should not need to implement a duplicate `/csrf-token` route.
|
|
446
|
+
const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
|
|
447
|
+
|
|
417
448
|
// Request function
|
|
418
449
|
const requestFn = async (): Promise<T> => {
|
|
419
450
|
const startTime = Date.now();
|
|
420
451
|
try {
|
|
421
452
|
// Build URL with params
|
|
422
453
|
const fullUrl = this.buildURL(url, params);
|
|
423
|
-
|
|
424
|
-
// Get auth token (with auto-refresh). `skipAuth` requests (the
|
|
425
|
-
// body-authenticated refresh endpoint) send NO bearer and skip the
|
|
426
|
-
// near-expiry preflight — see RequestOptions.skipAuth for the deadlock
|
|
427
|
-
// this avoids.
|
|
428
|
-
const authHeader = config.skipAuth ? null : await this.getAuthHeader();
|
|
429
|
-
|
|
430
|
-
// CSRF protects cookie-authenticated browser writes. Bearer-authenticated
|
|
431
|
-
// SDK clients are not vulnerable to ambient-cookie CSRF, and linked app
|
|
432
|
-
// APIs should not need to implement a duplicate `/csrf-token` route.
|
|
433
|
-
const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
|
434
|
-
const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
|
|
435
454
|
|
|
436
455
|
// Determine if data is FormData using robust detection
|
|
437
456
|
const isFormData = this.isFormData(data);
|
|
@@ -665,8 +684,12 @@ export class HttpService {
|
|
|
665
684
|
? () => this.deduplicator.deduplicate(dedupeKey, requestWithRetry)
|
|
666
685
|
: requestWithRetry;
|
|
667
686
|
|
|
668
|
-
// Execute request
|
|
669
|
-
|
|
687
|
+
// Execute the request. Control-plane calls the auth lane depends on
|
|
688
|
+
// (`bypassQueue`, e.g. the device-secret mint) run DIRECTLY — a queued mint
|
|
689
|
+
// could never acquire a slot when every slot is parked awaiting it.
|
|
690
|
+
const result = config.bypassQueue
|
|
691
|
+
? await finalRequest()
|
|
692
|
+
: await this.requestQueue.enqueue(finalRequest);
|
|
670
693
|
|
|
671
694
|
// Cache the result if caching is enabled
|
|
672
695
|
if (cache && cacheKey && result) {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth self-await regression.
|
|
3
|
+
*
|
|
4
|
+
* The refresh handler (arm 2 shared-key sign-in) calls PRE-SESSION public
|
|
5
|
+
* endpoints (`/auth/challenge`, `/auth/verify`, the commons-signin surface, …)
|
|
6
|
+
* from INSIDE the single-flight `tokenRefreshPromise`. If any of those carried
|
|
7
|
+
* an auth preflight (no `skipAuth`), its `getAuthHeader` would call
|
|
8
|
+
* `refreshAccessToken`, find the handler's own promise already in flight, and
|
|
9
|
+
* await it — the handler awaiting itself → permanent hang (no deadline on the
|
|
10
|
+
* auto-connect lane makes it visible). `skipAuth` on every pre-session public
|
|
11
|
+
* endpoint is what prevents this; these tests lock the invariant.
|
|
12
|
+
*/
|
|
13
|
+
import { HttpService } from '../HttpService';
|
|
14
|
+
|
|
15
|
+
function createJwt(payload: Record<string, unknown>): string {
|
|
16
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
17
|
+
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function jsonResponse(data: unknown): Response {
|
|
21
|
+
return new Response(JSON.stringify({ data }), {
|
|
22
|
+
status: 200,
|
|
23
|
+
headers: { 'content-type': 'application/json' },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const nowSec = (): number => Math.floor(Date.now() / 1000);
|
|
28
|
+
|
|
29
|
+
/** Resolve to `true` if `promise` settles within `ms`, else `false`. Always
|
|
30
|
+
* clears its timer so no pending timeout leaks into jest's teardown. */
|
|
31
|
+
async function settlesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
|
|
32
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
33
|
+
const timeout = new Promise<false>((resolve) => {
|
|
34
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
return await Promise.race([promise.then(() => true), timeout]);
|
|
38
|
+
} finally {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('HttpService auth self-await', () => {
|
|
44
|
+
const originalFetch = globalThis.fetch;
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
globalThis.fetch = originalFetch;
|
|
48
|
+
jest.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('a skipAuth request issued from INSIDE the refresh handler resolves (no self-await)', async () => {
|
|
52
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
53
|
+
const url = String(input);
|
|
54
|
+
if (url.includes('/auth/challenge')) return jsonResponse({ challenge: 'c' });
|
|
55
|
+
return jsonResponse({ ok: true });
|
|
56
|
+
}) as typeof fetch;
|
|
57
|
+
|
|
58
|
+
const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
|
|
59
|
+
// Near-expiry → the data request's preflight starts the refresh.
|
|
60
|
+
http.setTokens(createJwt({ userId: 'u', exp: nowSec() + 30 }));
|
|
61
|
+
|
|
62
|
+
http.setAuthRefreshHandler(async () => {
|
|
63
|
+
// Mirror arm 2: from inside the handler, hit a pre-session public endpoint.
|
|
64
|
+
// With skipAuth it does NOT re-enter refreshAccessToken; without it, its
|
|
65
|
+
// preflight would await THIS handler's own pending promise → self-await.
|
|
66
|
+
await http.post('/auth/challenge', { publicKey: 'p' }, { skipAuth: true, retry: false });
|
|
67
|
+
const fresh = createJwt({ userId: 'u', exp: nowSec() + 3600, jti: 'fresh' });
|
|
68
|
+
http.setTokens(fresh);
|
|
69
|
+
return fresh;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(await settlesWithin(http.get('/data'), 500)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('a skipAuth public request does NOT await a pending refresh', async () => {
|
|
76
|
+
let releaseRefresh: (() => void) | undefined;
|
|
77
|
+
const refreshGate = new Promise<void>((resolve) => {
|
|
78
|
+
releaseRefresh = resolve;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
82
|
+
const url = String(input);
|
|
83
|
+
if (url.includes('/auth/challenge')) return jsonResponse({ challenge: 'c' });
|
|
84
|
+
return jsonResponse({ ok: true });
|
|
85
|
+
}) as typeof fetch;
|
|
86
|
+
|
|
87
|
+
const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
|
|
88
|
+
http.setTokens(createJwt({ userId: 'u', exp: nowSec() + 30 }));
|
|
89
|
+
|
|
90
|
+
http.setAuthRefreshHandler(async () => {
|
|
91
|
+
await refreshGate; // hold the refresh open
|
|
92
|
+
const fresh = createJwt({ userId: 'u', exp: nowSec() + 3600, jti: 'fresh' });
|
|
93
|
+
http.setTokens(fresh);
|
|
94
|
+
return fresh;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// A data request whose preflight starts (and blocks on) the held refresh.
|
|
98
|
+
const data = http.get('/data');
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
100
|
+
|
|
101
|
+
// A skipAuth public request must settle WITHOUT waiting for the held refresh.
|
|
102
|
+
const pub = http.post('/auth/challenge', { publicKey: 'p' }, { skipAuth: true, retry: false });
|
|
103
|
+
expect(await settlesWithin(pub, 300)).toBe(true);
|
|
104
|
+
|
|
105
|
+
releaseRefresh?.();
|
|
106
|
+
await data;
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RequestQueue deadlock regression.
|
|
3
|
+
*
|
|
4
|
+
* The auth preflight (`getAuthHeader` → single-flight `refreshAccessToken` → the
|
|
5
|
+
* device-secret mint) used to run INSIDE a RequestQueue slot, and the mint itself
|
|
6
|
+
* went through the queue. When `maxConcurrentRequests` requests all parked in
|
|
7
|
+
* slots awaiting that same mint, the mint could never acquire a slot to run —
|
|
8
|
+
* a systemic deadlock in which NOTHING settles (observed on a real device: the
|
|
9
|
+
* running count climbs to the pool size with zero settled requests, including a
|
|
10
|
+
* public GET /health). The fix: (1) `bypassQueue` runs control-plane calls (the
|
|
11
|
+
* mint) directly, and (2) the preflight is resolved OUTSIDE the slot, so an
|
|
12
|
+
* auth-blocked request never holds a slot while the shared mint runs.
|
|
13
|
+
*/
|
|
14
|
+
import { HttpService } from '../HttpService';
|
|
15
|
+
|
|
16
|
+
function createJwt(payload: Record<string, unknown>): string {
|
|
17
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
18
|
+
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function jsonResponse(data: unknown): Response {
|
|
22
|
+
return new Response(JSON.stringify({ data }), {
|
|
23
|
+
status: 200,
|
|
24
|
+
headers: { 'content-type': 'application/json' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Resolve to `true` if `promise` settles within `ms`, else `false`. Always
|
|
29
|
+
* clears its timer so no pending timeout leaks into jest's teardown. */
|
|
30
|
+
async function settlesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
|
|
31
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
const timeout = new Promise<false>((resolve) => {
|
|
33
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
return await Promise.race([promise.then(() => true), timeout]);
|
|
37
|
+
} finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('HttpService RequestQueue deadlock', () => {
|
|
43
|
+
const originalFetch = globalThis.fetch;
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
globalThis.fetch = originalFetch;
|
|
47
|
+
jest.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('runs a bypassQueue control-plane request even when every slot is held', async () => {
|
|
51
|
+
const releases: Array<() => void> = [];
|
|
52
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
53
|
+
const url = String(input);
|
|
54
|
+
if (url.includes('/slow')) {
|
|
55
|
+
// Occupy the slot indefinitely until explicitly released.
|
|
56
|
+
await new Promise<void>((resolve) => releases.push(resolve));
|
|
57
|
+
return jsonResponse({ slow: true });
|
|
58
|
+
}
|
|
59
|
+
return jsonResponse({ minted: true });
|
|
60
|
+
}) as typeof fetch;
|
|
61
|
+
|
|
62
|
+
const http = new HttpService({
|
|
63
|
+
baseURL: 'https://api.oxy.so',
|
|
64
|
+
maxConcurrentRequests: 2,
|
|
65
|
+
enableRetry: false,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Saturate both slots with requests whose fetch never settles.
|
|
69
|
+
const slowA = http.get('/slow/a', { skipAuth: true });
|
|
70
|
+
const slowB = http.get('/slow/b', { skipAuth: true });
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
72
|
+
|
|
73
|
+
// The device-secret mint (bypassQueue) must still run despite full saturation.
|
|
74
|
+
const mint = http.post(
|
|
75
|
+
'/session/device/token',
|
|
76
|
+
{ deviceId: 'd', deviceSecret: 's' },
|
|
77
|
+
{ skipAuth: true, bypassQueue: true, retry: false },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(await settlesWithin(mint, 500)).toBe(true);
|
|
81
|
+
|
|
82
|
+
releases.forEach((release) => release());
|
|
83
|
+
await Promise.all([slowA, slowB]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does not deadlock when a boot burst larger than the pool all trigger the same preflight mint', async () => {
|
|
87
|
+
const now = Math.floor(Date.now() / 1000);
|
|
88
|
+
// Within the 60s refresh lead window → every request's preflight refreshes.
|
|
89
|
+
const nearExpiry = createJwt({ userId: 'u', exp: now + 30 });
|
|
90
|
+
const fresh = createJwt({ userId: 'u', exp: now + 3600, jti: 'fresh' });
|
|
91
|
+
|
|
92
|
+
let mintCalls = 0;
|
|
93
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
94
|
+
const url = String(input);
|
|
95
|
+
if (url.includes('/session/device/token')) {
|
|
96
|
+
mintCalls += 1;
|
|
97
|
+
return jsonResponse({ minted: true });
|
|
98
|
+
}
|
|
99
|
+
return jsonResponse({ ok: true });
|
|
100
|
+
}) as typeof fetch;
|
|
101
|
+
|
|
102
|
+
const http = new HttpService({
|
|
103
|
+
baseURL: 'https://api.oxy.so',
|
|
104
|
+
maxConcurrentRequests: 2,
|
|
105
|
+
enableRetry: false,
|
|
106
|
+
});
|
|
107
|
+
http.setTokens(nearExpiry);
|
|
108
|
+
|
|
109
|
+
http.setAuthRefreshHandler(async () => {
|
|
110
|
+
// Mirror refreshDeviceSecretArm: mint via a bypassQueue request through the
|
|
111
|
+
// SAME instance, then plant + return the fresh token.
|
|
112
|
+
await http.post(
|
|
113
|
+
'/session/device/token',
|
|
114
|
+
{ deviceId: 'd', deviceSecret: 's' },
|
|
115
|
+
{ skipAuth: true, bypassQueue: true, retry: false },
|
|
116
|
+
);
|
|
117
|
+
http.setTokens(fresh);
|
|
118
|
+
return fresh;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// A boot burst of 4 against a pool of 2, all in the near-expiry window.
|
|
122
|
+
const burst = Promise.all([http.get('/a'), http.get('/b'), http.get('/c'), http.get('/d')]);
|
|
123
|
+
|
|
124
|
+
expect(await settlesWithin(burst, 800)).toBe(true);
|
|
125
|
+
// The single-flight refresh coalesces the whole burst into ONE mint.
|
|
126
|
+
expect(mintCalls).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery-mnemonic storage: a dedicated, device-only keychain slot that lets a
|
|
3
|
+
* user re-reveal their 12-word phrase from Settings. Isolated from the identity
|
|
4
|
+
* primary/backup slots (its own keychain service), null-on-absent, typed-throw
|
|
5
|
+
* on a locked keychain, and wiped when the identity is deleted.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { setPlatformOS } from '../../utils/platform';
|
|
9
|
+
|
|
10
|
+
jest.mock(
|
|
11
|
+
'expo-secure-store',
|
|
12
|
+
() => {
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
|
+
const { createSecureStoreMock } = require('./identityMocks');
|
|
15
|
+
return createSecureStoreMock();
|
|
16
|
+
},
|
|
17
|
+
{ virtual: true },
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
jest.mock(
|
|
21
|
+
'expo-crypto',
|
|
22
|
+
() => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
getRandomBytes: (length: number) => {
|
|
25
|
+
const out = new Uint8Array(length);
|
|
26
|
+
for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
|
|
27
|
+
return out;
|
|
28
|
+
},
|
|
29
|
+
digestStringAsync: async () => '0'.repeat(64),
|
|
30
|
+
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
|
31
|
+
}),
|
|
32
|
+
{ virtual: true },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
jest.mock('@oxyhq/protocol', () => {
|
|
36
|
+
const actual = jest.requireActual('@oxyhq/protocol');
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
|
+
const { createAsyncStorageMock } = require('./identityMocks');
|
|
39
|
+
const asyncStorage = createAsyncStorageMock();
|
|
40
|
+
return {
|
|
41
|
+
__esModule: true,
|
|
42
|
+
...actual,
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
44
|
+
loadExpoCrypto: async () => require('expo-crypto'),
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
46
|
+
loadSecureStore: async () => require('expo-secure-store'),
|
|
47
|
+
loadAsyncStorage: async () => ({ default: asyncStorage }),
|
|
48
|
+
loadSharedIdentityBridge: async () => null,
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
50
|
+
loadNodeCrypto: async () => require('crypto'),
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
52
|
+
getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const MNEMONIC_SVC = 'oxy_identity_mnemonic';
|
|
57
|
+
const MNEMONIC_KEY = 'oxy_identity_mnemonic_v1';
|
|
58
|
+
const PRIMARY_SVC = 'oxy_identity';
|
|
59
|
+
const PHRASE = 'legal winner thank year wave sausage worth useful legal winner thank yellow';
|
|
60
|
+
|
|
61
|
+
interface SecureStoreTestHandle {
|
|
62
|
+
__resetStore__: () => void;
|
|
63
|
+
__getRaw__: (key: string, service?: string) => string | null;
|
|
64
|
+
__simulateKeystoreDeath__: (service: string) => void;
|
|
65
|
+
__failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number; failService?: string };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('KeyManager recovery mnemonic storage', () => {
|
|
69
|
+
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
70
|
+
let IdentityUnavailableError: typeof import('../keyManager').IdentityUnavailableError;
|
|
71
|
+
let ss: SecureStoreTestHandle;
|
|
72
|
+
|
|
73
|
+
beforeAll(() => {
|
|
74
|
+
setPlatformOS('ios');
|
|
75
|
+
(globalThis as unknown as { navigator: unknown }).navigator = { product: 'ReactNative' };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
beforeEach(async () => {
|
|
79
|
+
jest.resetModules();
|
|
80
|
+
setPlatformOS('ios');
|
|
81
|
+
ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
82
|
+
ss.__resetStore__();
|
|
83
|
+
const km = await import('../keyManager');
|
|
84
|
+
KeyManager = km.KeyManager;
|
|
85
|
+
IdentityUnavailableError = km.IdentityUnavailableError;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('round-trips a stored mnemonic', async () => {
|
|
89
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
90
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBe(PHRASE);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('persists under its OWN keychain service, isolated from the identity slots', async () => {
|
|
94
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
95
|
+
// Present under the mnemonic service, invisible under the primary service.
|
|
96
|
+
expect(ss.__getRaw__(MNEMONIC_KEY, MNEMONIC_SVC)).toBe(PHRASE);
|
|
97
|
+
expect(ss.__getRaw__(MNEMONIC_KEY, PRIMARY_SVC)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns null when no mnemonic was ever stored (pre-feature identity)', async () => {
|
|
101
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('survives a keystore death of the identity primary service', async () => {
|
|
105
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
106
|
+
// The identity primary slot dies; the mnemonic lives in a distinct service.
|
|
107
|
+
ss.__simulateKeystoreDeath__(PRIMARY_SVC);
|
|
108
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBe(PHRASE);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws IdentityUnavailableError (never null) when the read throws', async () => {
|
|
112
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
113
|
+
ss.__failPlan__.failOp = 'get';
|
|
114
|
+
ss.__failPlan__.failKey = MNEMONIC_KEY;
|
|
115
|
+
ss.__failPlan__.failService = MNEMONIC_SVC;
|
|
116
|
+
await expect(KeyManager.getRecoveryMnemonic()).rejects.toBeInstanceOf(IdentityUnavailableError);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws IdentityUnavailableError when the write throws', async () => {
|
|
120
|
+
ss.__failPlan__.failOp = 'set';
|
|
121
|
+
ss.__failPlan__.failKey = MNEMONIC_KEY;
|
|
122
|
+
ss.__failPlan__.failService = MNEMONIC_SVC;
|
|
123
|
+
await expect(KeyManager.storeRecoveryMnemonic(PHRASE)).rejects.toBeInstanceOf(IdentityUnavailableError);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('deleteRecoveryMnemonic removes the stored phrase', async () => {
|
|
127
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
128
|
+
await KeyManager.deleteRecoveryMnemonic();
|
|
129
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('deleteIdentity(force) wipes the stored mnemonic', async () => {
|
|
133
|
+
await KeyManager.createIdentity();
|
|
134
|
+
await KeyManager.storeRecoveryMnemonic(PHRASE);
|
|
135
|
+
await KeyManager.deleteIdentity(true, true, true);
|
|
136
|
+
expect(await KeyManager.getRecoveryMnemonic()).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|