@oxyhq/core 12.10.6 → 12.11.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 +66 -6
- package/dist/cjs/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +66 -6
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/OxyServices.privacy.js +6 -0
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +18 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/OxyServices.user.d.ts +2 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +3 -3
- package/src/HttpService.ts +67 -6
- package/src/__tests__/inSessionRefresh.test.ts +67 -0
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/OxyServices.privacy.ts +6 -0
- package/src/mixins/OxyServices.user.ts +3 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/oauthPkce.ts +56 -3
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity-bound session — pin reconciliation + establishment.
|
|
3
|
+
*
|
|
4
|
+
* This is the SDK half of "an identity vault's authenticated user is whoever
|
|
5
|
+
* owns the local primary key". Two operations, both consumed by the cold boot
|
|
6
|
+
* (`boot/sessionColdBoot.ts`) and the re-mint lane (`session/refresh.ts`):
|
|
7
|
+
*
|
|
8
|
+
* - {@link resolveIdentityPin} — read the persisted `{publicKey, accountId}`
|
|
9
|
+
* pin and reconcile it against the identity currently on this device. A
|
|
10
|
+
* definitive mismatch (the key was replaced, or is gone) CLEARS the pin; an
|
|
11
|
+
* INDETERMINATE read (keychain locked / storage threw) leaves it untouched.
|
|
12
|
+
* - {@link establishIdentitySession} — mint a session from the PRIMARY local
|
|
13
|
+
* key (`getPublicKey` → `requestChallenge` → `signChallenge` →
|
|
14
|
+
* `verifyChallenge`), persist the resulting device credential, and write the
|
|
15
|
+
* pin. This is the identity-mode replacement for the shared-keychain lane,
|
|
16
|
+
* which reads the CROSS-APP shared slot and may therefore hold a different
|
|
17
|
+
* identity than this device's primary.
|
|
18
|
+
*
|
|
19
|
+
* Both take their key/signature functions from an injectable {@link IdentityBinding}
|
|
20
|
+
* (defaulting to `KeyManager` / `SignatureService`) so the lanes above stay
|
|
21
|
+
* testable without a keychain.
|
|
22
|
+
*
|
|
23
|
+
* ESM-safe (no `require()`); no react/react-native/expo imports.
|
|
24
|
+
*/
|
|
25
|
+
import type { OxyServices } from '../OxyServices';
|
|
26
|
+
import type { AuthStateStore } from './authStateStore';
|
|
27
|
+
import type { SessionLoginResponse } from '../models/session';
|
|
28
|
+
import { type AuthChallenge } from '../crypto/signatureService';
|
|
29
|
+
import { type IdentityPin, type IdentityPinStore } from './identityPin';
|
|
30
|
+
/** Per-call transport overrides forwarded to the challenge/verify round-trips. */
|
|
31
|
+
export interface IdentityRequestOptions {
|
|
32
|
+
retry?: boolean;
|
|
33
|
+
timeout?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Everything an identity-bound client needs to resolve and re-establish its
|
|
37
|
+
* session. `@oxyhq/services` builds one of these (platform-appropriate pin
|
|
38
|
+
* store) and passes it to the cold boot and the refresh handler.
|
|
39
|
+
*/
|
|
40
|
+
export interface IdentityBinding {
|
|
41
|
+
/** Where the `{publicKey, accountId}` pin is persisted. */
|
|
42
|
+
pinStore: IdentityPinStore;
|
|
43
|
+
/**
|
|
44
|
+
* Reads this device's PRIMARY identity public key. Defaults to
|
|
45
|
+
* `KeyManager.getPublicKey()`. NEVER the shared-slot key.
|
|
46
|
+
*/
|
|
47
|
+
readPublicKey?: () => Promise<string | null>;
|
|
48
|
+
/**
|
|
49
|
+
* Signs a server challenge with the PRIMARY local private key. Defaults to
|
|
50
|
+
* `SignatureService.signChallenge`.
|
|
51
|
+
*/
|
|
52
|
+
signChallenge?: (challenge: string) => Promise<AuthChallenge>;
|
|
53
|
+
/** Optional device labels forwarded to `verifyChallenge`. */
|
|
54
|
+
deviceName?: string;
|
|
55
|
+
deviceFingerprint?: string;
|
|
56
|
+
}
|
|
57
|
+
/** The result of a successful {@link establishIdentitySession}. */
|
|
58
|
+
export interface EstablishedIdentitySession {
|
|
59
|
+
session: SessionLoginResponse;
|
|
60
|
+
/** The pin as written: the local key plus the account the server resolved for it. */
|
|
61
|
+
pin: IdentityPin;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read the persisted pin and reconcile it against the identity on this device.
|
|
65
|
+
*
|
|
66
|
+
* Returns the pin ONLY when the local primary public key still matches it — that
|
|
67
|
+
* is the single condition under which a client may bind its token and its
|
|
68
|
+
* rendered user to that account.
|
|
69
|
+
*
|
|
70
|
+
* Outcomes:
|
|
71
|
+
* - no pin stored → `null` (nothing to clear).
|
|
72
|
+
* - local key MATCHES → the pin (trusted).
|
|
73
|
+
* - local key read succeeded and differs / is absent → the identity was
|
|
74
|
+
* replaced or lost: CLEAR the pin and return `null`. Keeping it would pin the
|
|
75
|
+
* client to an account it can no longer prove control of.
|
|
76
|
+
* - the local key read THREW (keychain locked, storage unavailable) → return
|
|
77
|
+
* `null` WITHOUT clearing. A read that produced no verdict is never evidence
|
|
78
|
+
* of an identity change (the same rule `KeyManager` applies to its own
|
|
79
|
+
* transient errors); the caller falls through to the identity sign-in lane,
|
|
80
|
+
* which fails closed on a locked keychain rather than adopting a foreign
|
|
81
|
+
* account.
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolveIdentityPin(binding: IdentityBinding): Promise<IdentityPin | null>;
|
|
84
|
+
/**
|
|
85
|
+
* Establish a session for the device's PRIMARY identity key and pin it.
|
|
86
|
+
*
|
|
87
|
+
* `requestChallenge` → `signChallenge` → `verifyChallenge`; `verifyChallenge`
|
|
88
|
+
* plants the access token itself. The server resolves the account from the
|
|
89
|
+
* VERIFIED SIGNER, so the returned session is identity-authoritative — it
|
|
90
|
+
* ignores the device's `activeAccountId` entirely.
|
|
91
|
+
*
|
|
92
|
+
* Side effects on success: the durable device credential is persisted (so the
|
|
93
|
+
* next boot can take the fast pinned-mint lane) and the pin is written.
|
|
94
|
+
*
|
|
95
|
+
* Returns `null` — never throws — for the two "nothing to do" cases: no local
|
|
96
|
+
* identity (including every web caller, where `KeyManager` has no key), and a
|
|
97
|
+
* verify that produced no access token. Network/crypto failures propagate so the
|
|
98
|
+
* caller can classify them.
|
|
99
|
+
*/
|
|
100
|
+
export declare function establishIdentitySession(args: {
|
|
101
|
+
oxy: OxyServices;
|
|
102
|
+
store: AuthStateStore;
|
|
103
|
+
binding: IdentityBinding;
|
|
104
|
+
requestOptions?: IdentityRequestOptions;
|
|
105
|
+
}): Promise<EstablishedIdentitySession | null>;
|
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
2
|
import type { ClientSession } from '../models/session';
|
|
3
3
|
import type { User } from '../models/interfaces';
|
|
4
|
-
/**
|
|
5
|
-
* Pure projection helpers: `DeviceSessionState` (the device-scoped
|
|
6
|
-
* multi-account session-sync state produced by `SessionClient`) -> the
|
|
7
|
-
* shapes `@oxyhq/services` consumers render today
|
|
8
|
-
* (`ClientSession[]`, an active session id, an active `User`).
|
|
9
|
-
*
|
|
10
|
-
* No I/O. The caller fetches profiles via
|
|
11
|
-
* `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
|
|
12
|
-
* from the result before calling `deviceStateToClientSessions` /
|
|
13
|
-
* `activeUserOf`.
|
|
14
|
-
*/
|
|
15
4
|
/**
|
|
16
5
|
* Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
|
|
17
6
|
*
|
|
@@ -23,19 +12,32 @@ import type { User } from '../models/interfaces';
|
|
|
23
12
|
* though `ClientSession` only stores `userId` — a session is still
|
|
24
13
|
* projected for an account whose id is absent from `usersById` (no
|
|
25
14
|
* placeholder user is fabricated).
|
|
15
|
+
*
|
|
16
|
+
* `isCurrent` marks the PINNED account when one is supplied, so it can never
|
|
17
|
+
* disagree with {@link activeSessionIdOf} / {@link activeUserOf} for the same
|
|
18
|
+
* pin.
|
|
26
19
|
*/
|
|
27
|
-
export declare function deviceStateToClientSessions(state: DeviceSessionState, usersById: Map<string, User
|
|
20
|
+
export declare function deviceStateToClientSessions(state: DeviceSessionState, usersById: Map<string, User>, pinnedAccountId?: string | null): ClientSession[];
|
|
28
21
|
/**
|
|
29
|
-
* The
|
|
30
|
-
*
|
|
22
|
+
* The bound account's `sessionId`, or `null` when there is no state, no bound
|
|
23
|
+
* account, or that account has no session on this device.
|
|
24
|
+
*
|
|
25
|
+
* A pinned account that is ABSENT from `state.accounts` yields `null` — the
|
|
26
|
+
* honest signal that this device no longer carries a session for the pinned
|
|
27
|
+
* identity, which the caller answers by re-establishing the identity session
|
|
28
|
+
* (never by adopting the device's active account).
|
|
31
29
|
*/
|
|
32
|
-
export declare function activeSessionIdOf(state: DeviceSessionState | null): string | null;
|
|
30
|
+
export declare function activeSessionIdOf(state: DeviceSessionState | null, pinnedAccountId?: string | null): string | null;
|
|
33
31
|
/**
|
|
34
|
-
* The
|
|
35
|
-
*
|
|
36
|
-
*
|
|
32
|
+
* The bound account's `User`, resolved from `usersById`. `null` when there is no
|
|
33
|
+
* state, no bound account, or the bound account id is absent from `usersById`.
|
|
34
|
+
*
|
|
35
|
+
* A pinned user is resolved from `usersById` alone — deliberately NOT gated on
|
|
36
|
+
* device membership — so a transient device-state gap cannot flicker the
|
|
37
|
+
* identity vault's rendered user. Whether the pinned session still exists on the
|
|
38
|
+
* device is answered by {@link activeSessionIdOf}.
|
|
37
39
|
*/
|
|
38
|
-
export declare function activeUserOf(state: DeviceSessionState | null, usersById: Map<string, User
|
|
40
|
+
export declare function activeUserOf(state: DeviceSessionState | null, usersById: Map<string, User>, pinnedAccountId?: string | null): User | null;
|
|
39
41
|
/**
|
|
40
42
|
* All account ids in `state`, suitable for an `oxyServices.getUsersByIds(...)`
|
|
41
43
|
* fetch. `[]` for `null` state.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { OxyServices } from '../OxyServices';
|
|
2
2
|
import type { AuthRefreshHandler } from '../HttpService';
|
|
3
3
|
import type { AuthStateStore } from './authStateStore';
|
|
4
|
+
import type { IdentityPin } from './identityPin';
|
|
5
|
+
import { type IdentityBinding } from './identitySession';
|
|
4
6
|
/**
|
|
5
7
|
* Lead time (ms) before access-token expiry at which the proactive scheduler
|
|
6
8
|
* re-mints. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
|
|
@@ -14,9 +16,19 @@ export interface RefreshDeps {
|
|
|
14
16
|
/**
|
|
15
17
|
* Whether to fall back to the native shared-keychain re-mint (arm 2) when the
|
|
16
18
|
* persisted secret is absent / rejected. Defaults to `isNative()` — web has no
|
|
17
|
-
* shared keychain. Exposed for tests.
|
|
19
|
+
* shared keychain. Exposed for tests. IGNORED when {@link identity} is set: an
|
|
20
|
+
* identity-bound client must never adopt the CROSS-APP shared slot, which may
|
|
21
|
+
* hold a different identity than this device's primary key.
|
|
18
22
|
*/
|
|
19
23
|
allowSharedKeyFallback?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Identity-bound (pinned) mode. When present, every re-mint targets the
|
|
26
|
+
* PINNED account — resolved fresh from the pin store on each call, since a
|
|
27
|
+
* re-established session can move it — instead of the device's active
|
|
28
|
+
* account, and arm 2 becomes the PRIMARY-key identity sign-in rather than the
|
|
29
|
+
* shared-keychain one.
|
|
30
|
+
*/
|
|
31
|
+
identity?: IdentityBinding;
|
|
20
32
|
}
|
|
21
33
|
/**
|
|
22
34
|
* The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
|
|
@@ -28,6 +40,10 @@ export interface RefreshDeps {
|
|
|
28
40
|
* diverged (another tab/device rotated it past the grace window).
|
|
29
41
|
* - `no-session` — 401 `no_active_session`: the device is known but has no live
|
|
30
42
|
* session (authoritative signed-out).
|
|
43
|
+
* - `account-not-on-device` — 401 `account_not_on_device` for a PINNED mint: the
|
|
44
|
+
* pinned account is not (or no longer) a live account of this device session.
|
|
45
|
+
* The device secret is FINE — it is the identity binding that went stale, so
|
|
46
|
+
* the caller must re-establish from the local key, never drop the credential.
|
|
31
47
|
* - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
|
|
32
48
|
* - `persist-failed` — the mint succeeded (the SERVER rotated the secret) but
|
|
33
49
|
* the rotated `nextDeviceSecret` could NOT be durably persisted. The token is
|
|
@@ -45,6 +61,8 @@ export type DeviceSecretMintOutcome = {
|
|
|
45
61
|
status: 'invalid-secret';
|
|
46
62
|
} | {
|
|
47
63
|
status: 'no-session';
|
|
64
|
+
} | {
|
|
65
|
+
status: 'account-not-on-device';
|
|
48
66
|
} | {
|
|
49
67
|
status: 'transient';
|
|
50
68
|
} | {
|
|
@@ -70,10 +88,18 @@ export type DeviceSecretMintOutcome = {
|
|
|
70
88
|
* planting. This function performs NO store mutation on failure — the caller
|
|
71
89
|
* applies the drop/clear policy (which differs web vs native) from the returned
|
|
72
90
|
* status.
|
|
91
|
+
*
|
|
92
|
+
* `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
|
|
93
|
+
* `accountId` (so the server mints that account's token without touching
|
|
94
|
+
* `activeAccountId`), and the persisted `sessionId`/`userId` are resolved from
|
|
95
|
+
* the PINNED account entry — never from `state.activeAccountId`, whose drift is
|
|
96
|
+
* exactly what the pin exists to stop.
|
|
73
97
|
*/
|
|
74
98
|
export declare function refreshDeviceSecretArm(deps: {
|
|
75
99
|
oxy: OxyServices;
|
|
76
100
|
store: AuthStateStore;
|
|
101
|
+
/** The identity pin, when this client is identity-bound. */
|
|
102
|
+
pin?: IdentityPin | null;
|
|
77
103
|
}): Promise<DeviceSecretMintOutcome>;
|
|
78
104
|
/**
|
|
79
105
|
* Re-mint the persisted session and return the fresh access token, or `null`
|
|
@@ -91,6 +117,11 @@ export declare function refreshDeviceSecretArm(deps: {
|
|
|
91
117
|
* recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
|
|
92
118
|
* lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
|
|
93
119
|
* in-session shared-key recovery must not leave the fast-lane credential empty.
|
|
120
|
+
*
|
|
121
|
+
* IDENTITY-BOUND clients (`deps.identity`) run a different arm 2: the
|
|
122
|
+
* shared-keychain lane is DISABLED (its cross-app slot may hold a different
|
|
123
|
+
* identity) and replaced by {@link establishIdentitySession}, which re-signs a
|
|
124
|
+
* challenge with the PRIMARY local key and rewrites the pin. Arm 1 is pinned.
|
|
94
125
|
*/
|
|
95
126
|
export declare function refreshPersistedSession(deps: RefreshDeps): Promise<string | null>;
|
|
96
127
|
/**
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic "Sign in with Oxy" delivery selection (issue #691, Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* The user performs ONE action ("Continue with Oxy"); Oxy — not the user —
|
|
5
|
+
* decides how the approval request reaches their Commons identity. This module
|
|
6
|
+
* is that decision, and nothing else: a single pure function mapping the facts
|
|
7
|
+
* the caller has gathered onto exactly ONE primary route.
|
|
8
|
+
*
|
|
9
|
+
* Deliberate design constraints:
|
|
10
|
+
*
|
|
11
|
+
* - **Pure.** No I/O, no platform sniffing, no clock, no globals. Every input
|
|
12
|
+
* is passed in by the caller, which is what makes the decision exhaustively
|
|
13
|
+
* unit-testable and identical on web, native, and server.
|
|
14
|
+
* - **One route, never a chain.** The result is the PRIMARY route only.
|
|
15
|
+
* Alternatives stay hidden behind a "Having trouble?" affordance and are
|
|
16
|
+
* revealed by the UI only once the primary route fails or is unavailable —
|
|
17
|
+
* the SDK never silently cascades from one delivery surface to the next.
|
|
18
|
+
* - **Fail-safe to QR.** QR is the route that works with no prior knowledge of
|
|
19
|
+
* the device, so every ambiguous or malformed input degrades to it rather
|
|
20
|
+
* than to a route that could leave the user staring at a dead end.
|
|
21
|
+
*
|
|
22
|
+
* Every route drives the SAME `AuthSession`, resolved from the same public
|
|
23
|
+
* `authorizeCode`; the route only decides how that code travels.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The kind of surface the sign-in was initiated from.
|
|
27
|
+
*
|
|
28
|
+
* `'unknown'` is a first-class value, not an error: a caller that cannot
|
|
29
|
+
* confidently classify the surface must say so rather than guess, and an
|
|
30
|
+
* unknown surface never opts into the deep-link route (a custom-scheme
|
|
31
|
+
* navigation that does not resolve is a dead end with no automatic way back).
|
|
32
|
+
*/
|
|
33
|
+
export type CommonsDeliveryPlatform = 'mobile' | 'desktop' | 'unknown';
|
|
34
|
+
/**
|
|
35
|
+
* The single primary route chosen for this request.
|
|
36
|
+
*
|
|
37
|
+
* - `'open-commons'` — navigate to the verified Commons app/universal link on
|
|
38
|
+
* THIS device and let the user confirm there.
|
|
39
|
+
* - `'await-push'` — the request was pushed to a known Commons installation;
|
|
40
|
+
* show "Check Commons on your phone" and wait for the authorization.
|
|
41
|
+
* - `'qr'` — render the QR carrying the public `authorizeCode` for the user to
|
|
42
|
+
* scan with Commons on another device.
|
|
43
|
+
*/
|
|
44
|
+
export type CommonsDeliveryRoute = 'open-commons' | 'await-push' | 'qr';
|
|
45
|
+
/**
|
|
46
|
+
* The facts the caller must gather before asking for a route. All of them are
|
|
47
|
+
* observations, never decisions — the caller owns the platform detection, the
|
|
48
|
+
* app-link verification, and the delivery round-trip; this module owns only the
|
|
49
|
+
* choice between them.
|
|
50
|
+
*/
|
|
51
|
+
export interface CommonsDeliveryFacts {
|
|
52
|
+
/** Which surface the sign-in was initiated from (see {@link CommonsDeliveryPlatform}). */
|
|
53
|
+
platform: CommonsDeliveryPlatform;
|
|
54
|
+
/**
|
|
55
|
+
* `true` only when a VERIFIED Commons app/universal link can be opened on
|
|
56
|
+
* this very device (an installed, link-verified Commons). "The user probably
|
|
57
|
+
* has Commons somewhere" is not this fact — that is what `pushTargets`
|
|
58
|
+
* expresses.
|
|
59
|
+
*/
|
|
60
|
+
commonsAvailable: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* How many eligible Commons installations the server said it delivered the
|
|
63
|
+
* request to — the `targets` field of `deliverCommonsSignIn`. Zero is a
|
|
64
|
+
* NORMAL outcome (no capable Commons install is registered for this
|
|
65
|
+
* identity), never an error; it simply means push is not a usable route.
|
|
66
|
+
*/
|
|
67
|
+
pushTargets: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Choose the ONE primary delivery route for a "Sign in with Oxy" request.
|
|
71
|
+
*
|
|
72
|
+
* Decision order (issue #691, "Automatic delivery selection"):
|
|
73
|
+
*
|
|
74
|
+
* 1. Mobile with a verified Commons link available → open Commons directly.
|
|
75
|
+
* No push is needed when the identity is already reachable on this device.
|
|
76
|
+
* 2. At least one eligible Commons installation was pushed to → await the push.
|
|
77
|
+
* 3. Otherwise → QR.
|
|
78
|
+
*
|
|
79
|
+
* A caller that already knows it is on step 1 does not need to call
|
|
80
|
+
* `deliverCommonsSignIn` at all; every other caller runs the delivery
|
|
81
|
+
* round-trip first and passes its `targets` in here.
|
|
82
|
+
*
|
|
83
|
+
* `pushTargets` is server-derived, so it is validated rather than trusted: a
|
|
84
|
+
* negative, fractional, non-finite, or otherwise malformed count is treated as
|
|
85
|
+
* "no targets" and degrades to QR instead of parking the user on a "check your
|
|
86
|
+
* phone" screen that nothing will ever wake.
|
|
87
|
+
*/
|
|
88
|
+
export declare function selectCommonsDelivery(facts: CommonsDeliveryFacts): CommonsDeliveryRoute;
|
|
89
|
+
/**
|
|
90
|
+
* Map a `deliverCommonsSignIn` result onto the `pushTargets` fact for
|
|
91
|
+
* {@link selectCommonsDelivery}. The server reports eligible install *count*
|
|
92
|
+
* separately from whether any push was accepted — a transport failure can leave
|
|
93
|
+
* `delivered: false` with `targets > 0`, which must NOT park the user on
|
|
94
|
+
* "check your phone".
|
|
95
|
+
*/
|
|
96
|
+
export declare function pushTargetsFromDelivery(result: {
|
|
97
|
+
delivered: boolean;
|
|
98
|
+
targets: number;
|
|
99
|
+
}): number;
|
|
100
|
+
/**
|
|
101
|
+
* Classify the current runtime surface for delivery selection. Callers that
|
|
102
|
+
* cannot confidently detect the platform should not use this — pass
|
|
103
|
+
* `'unknown'` explicitly instead.
|
|
104
|
+
*/
|
|
105
|
+
export declare function commonsDeliveryPlatform(): CommonsDeliveryPlatform;
|
|
@@ -47,6 +47,18 @@ export interface BuildOAuthAuthorizeUrlParams {
|
|
|
47
47
|
* (no UI when the IdP hub already has a session + grant).
|
|
48
48
|
*/
|
|
49
49
|
prompt?: 'none' | 'login' | 'consent';
|
|
50
|
+
/**
|
|
51
|
+
* How the IdP should deliver the authorization response. Omitted (the
|
|
52
|
+
* default) means the ordinary top-level redirect back to `redirectUri`.
|
|
53
|
+
*
|
|
54
|
+
* `web_message` asks the IdP to `postMessage` the result to its opener
|
|
55
|
+
* instead, so a popup sign-in never navigates the relying party's tab. It is
|
|
56
|
+
* a REQUEST, not a guarantee: an IdP with no opener still redirects, which is
|
|
57
|
+
* why the popup transport must handle both outcomes. Only the authorization
|
|
58
|
+
* code, the `state`, and a typed OAuth error ever cross that channel — never
|
|
59
|
+
* a token, device secret, or the PKCE verifier, which the opener keeps.
|
|
60
|
+
*/
|
|
61
|
+
responseMode?: 'web_message';
|
|
50
62
|
}
|
|
51
63
|
/**
|
|
52
64
|
* Compute the PKCE S256 `code_challenge` for a given verifier:
|
|
@@ -81,6 +93,8 @@ export declare function buildOAuthAuthorizeUrl(params: BuildOAuthAuthorizeUrlPar
|
|
|
81
93
|
export declare const OXY_OAUTH_STATE_STORAGE_KEY = "oxy_oauth_state";
|
|
82
94
|
/** `sessionStorage` key for the PKCE `code_verifier` across an authorize redirect. */
|
|
83
95
|
export declare const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = "oxy_oauth_code_verifier";
|
|
96
|
+
/** `sessionStorage` key — the exact `redirect_uri` sent on the authorize request. */
|
|
97
|
+
export declare const OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = "oxy.oauth_redirect_uri";
|
|
84
98
|
/** `sessionStorage` key — at most one silent OAuth attempt per navigation. */
|
|
85
99
|
export declare const OXY_SILENT_OAUTH_ATTEMPTED_KEY = "oxy.silent_oauth_attempted";
|
|
86
100
|
/** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
|
|
@@ -100,6 +114,11 @@ export declare const OXY_OAUTH_RETURN_PATH_STORAGE_KEY = "oxy.oauth_return_path"
|
|
|
100
114
|
* out-of-band and restored on return.
|
|
101
115
|
*/
|
|
102
116
|
export declare function normalizeOAuthRedirectUri(input: string): string;
|
|
117
|
+
/**
|
|
118
|
+
* Collapse `https://app.example/` → `https://app.example` for OAuth binding.
|
|
119
|
+
* Path-qualified redirect URIs are preserved — matches the API token exchange.
|
|
120
|
+
*/
|
|
121
|
+
export declare function canonicalizeOAuthRedirectUri(redirectUri: string): string;
|
|
103
122
|
/**
|
|
104
123
|
* Remember the page the user was on before a full-page authorize redirect.
|
|
105
124
|
*
|
|
@@ -123,11 +142,12 @@ export declare function persistOAuthReturnPath(path: string): void;
|
|
|
123
142
|
*/
|
|
124
143
|
export declare function consumeOAuthReturnPath(): string | null;
|
|
125
144
|
/** Persist the OAuth handshake for a full-page redirect return (web only). */
|
|
126
|
-
export declare function persistOAuthHandshake(state: string, codeVerifier: string): boolean;
|
|
145
|
+
export declare function persistOAuthHandshake(state: string, codeVerifier: string, redirectUri?: string): boolean;
|
|
127
146
|
/** Read the persisted OAuth handshake, or `null` when absent. */
|
|
128
147
|
export declare function readOAuthHandshake(): {
|
|
129
148
|
state: string;
|
|
130
149
|
codeVerifier: string;
|
|
150
|
+
redirectUri?: string;
|
|
131
151
|
} | null;
|
|
132
152
|
/** Drop persisted OAuth handshake keys after a successful or aborted return. */
|
|
133
153
|
export declare function clearOAuthHandshake(): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.11.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,7 +115,7 @@
|
|
|
115
115
|
"dependencies": {
|
|
116
116
|
"@noble/ciphers": "^1.3.0",
|
|
117
117
|
"@noble/hashes": "^1.8.0",
|
|
118
|
-
"@oxyhq/contracts": "^0.
|
|
118
|
+
"@oxyhq/contracts": "^0.18.0",
|
|
119
119
|
"@oxyhq/protocol": "^0.1.5",
|
|
120
120
|
"bip39": "^3.1.0",
|
|
121
121
|
"buffer": "^6.0.3",
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
"expo-crypto": "~56.0.3",
|
|
158
158
|
"expo-secure-store": "~56.0.4",
|
|
159
159
|
"express": "^4.22.2",
|
|
160
|
-
"express-rate-limit": "^
|
|
160
|
+
"express-rate-limit": "^8.6.0",
|
|
161
161
|
"regexpu-core": "^6.4.0",
|
|
162
162
|
"release-it": "^19.0.6",
|
|
163
163
|
"typescript": "^5.9.2"
|
package/src/HttpService.ts
CHANGED
|
@@ -143,12 +143,32 @@ const CSRF_FETCH_RETRY_DELAY_MS = 500;
|
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
145
|
* Cooldown (ms) applied after a failed access-token refresh before another
|
|
146
|
-
* refresh is attempted
|
|
146
|
+
* refresh is attempted while the CURRENT token is still valid (a proactive,
|
|
147
|
+
* near-expiry refresh). Prevents a refresh storm (and server hammering) when
|
|
147
148
|
* the auth refresh handler is failing — every in-flight request that
|
|
148
|
-
* hits a 401 would otherwise trigger its own refresh.
|
|
149
|
+
* hits a 401 would otherwise trigger its own refresh. A still-valid token can
|
|
150
|
+
* afford to wait this out; the request keeps carrying it in the meantime.
|
|
149
151
|
*/
|
|
150
152
|
const TOKEN_REFRESH_COOLDOWN_MS = 15000;
|
|
151
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Cooldown (ms) applied after a failed refresh when the CURRENT access token is
|
|
156
|
+
* already past its `exp`. Much shorter than {@link TOKEN_REFRESH_COOLDOWN_MS}:
|
|
157
|
+
* an expired token is UNUSABLE, so the client must re-mint as soon as the mint
|
|
158
|
+
* endpoint is reachable again (e.g. a few seconds after an ECS rolling-deploy
|
|
159
|
+
* blip drains/restarts a task) instead of waiting out the full proactive
|
|
160
|
+
* cooldown while every request forwards or omits a stale bearer → server 401.
|
|
161
|
+
*
|
|
162
|
+
* Still NON-ZERO on purpose: it bounds the request-driven retry rate to at most
|
|
163
|
+
* one attempt per this interval so a PROLONGED outage cannot become a tight
|
|
164
|
+
* network storm. Combined with the process-wide single-flight below (concurrent
|
|
165
|
+
* requests coalesce to one in-flight mint) and the refresh handler's own
|
|
166
|
+
* terminal-state handling (a genuinely revoked session clears its device
|
|
167
|
+
* credential and stops issuing network mints), this recovers a transient blip
|
|
168
|
+
* ~15× faster without weakening the storm guard.
|
|
169
|
+
*/
|
|
170
|
+
const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000;
|
|
171
|
+
|
|
152
172
|
/**
|
|
153
173
|
* Lead time (seconds) before access-token expiry at which a preflight refresh
|
|
154
174
|
* is triggered. A token within this window of `exp` is treated as effectively
|
|
@@ -247,7 +267,15 @@ export class HttpService {
|
|
|
247
267
|
private logger: SimpleLogger;
|
|
248
268
|
private config: OxyConfig;
|
|
249
269
|
private tokenRefreshPromise: Promise<string | null> | null = null;
|
|
250
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Epoch ms of the last FAILED refresh (0 = none since the last success). The
|
|
272
|
+
* post-failure cooldown is measured from here; its length depends on whether
|
|
273
|
+
* the current token is still valid ({@link TOKEN_REFRESH_COOLDOWN_MS}) or
|
|
274
|
+
* already expired ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}), so an expired
|
|
275
|
+
* token recovers promptly the instant it crosses `exp` — without storing a
|
|
276
|
+
* fixed deadline that could not shrink once the token expired mid-cooldown.
|
|
277
|
+
*/
|
|
278
|
+
private lastRefreshFailureAt = 0;
|
|
251
279
|
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
252
280
|
private accessTokenProvider: AccessTokenProvider | null = null;
|
|
253
281
|
private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
@@ -1058,7 +1086,16 @@ export class HttpService {
|
|
|
1058
1086
|
return null;
|
|
1059
1087
|
}
|
|
1060
1088
|
|
|
1061
|
-
|
|
1089
|
+
// Post-failure cooldown. A genuinely EXPIRED current token uses a much
|
|
1090
|
+
// shorter cooldown than a still-valid (proactive, near-expiry) one: an
|
|
1091
|
+
// expired token is unusable, so re-mint as soon as the endpoint is reachable
|
|
1092
|
+
// again rather than waiting out the full window while requests carry a stale
|
|
1093
|
+
// bearer. Both cooldowns are measured from the last failure, so the moment a
|
|
1094
|
+
// still-valid token crosses `exp` mid-cooldown the shorter window applies.
|
|
1095
|
+
const cooldownMs = this.isAccessTokenExpired()
|
|
1096
|
+
? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
|
|
1097
|
+
: TOKEN_REFRESH_COOLDOWN_MS;
|
|
1098
|
+
if (Date.now() - this.lastRefreshFailureAt < cooldownMs) {
|
|
1062
1099
|
return null;
|
|
1063
1100
|
}
|
|
1064
1101
|
|
|
@@ -1066,19 +1103,22 @@ export class HttpService {
|
|
|
1066
1103
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1067
1104
|
.then((newToken) => {
|
|
1068
1105
|
if (!newToken) {
|
|
1069
|
-
this.
|
|
1106
|
+
this.lastRefreshFailureAt = Date.now();
|
|
1070
1107
|
return null;
|
|
1071
1108
|
}
|
|
1072
1109
|
if (this.tokenStore.getAccessToken() !== newToken) {
|
|
1073
1110
|
this.tokenStore.setTokens(newToken);
|
|
1074
1111
|
this.notifyTokenChange();
|
|
1075
1112
|
}
|
|
1113
|
+
// A success clears the failure timestamp so the next refresh is never
|
|
1114
|
+
// throttled by a stale cooldown.
|
|
1115
|
+
this.lastRefreshFailureAt = 0;
|
|
1076
1116
|
this.logger.debug('Token refreshed via the auth refresh handler');
|
|
1077
1117
|
return newToken;
|
|
1078
1118
|
})
|
|
1079
1119
|
.catch((error) => {
|
|
1080
1120
|
this.logger.warn('Token refresh failed:', error);
|
|
1081
|
-
this.
|
|
1121
|
+
this.lastRefreshFailureAt = Date.now();
|
|
1082
1122
|
return null;
|
|
1083
1123
|
})
|
|
1084
1124
|
.finally(() => {
|
|
@@ -1089,6 +1129,27 @@ export class HttpService {
|
|
|
1089
1129
|
return this.tokenRefreshPromise;
|
|
1090
1130
|
}
|
|
1091
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
1134
|
+
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
1135
|
+
* a still-valid (near-expiry) token can wait out the full cooldown, but an
|
|
1136
|
+
* expired one must re-mint promptly. Returns `false` for an absent or
|
|
1137
|
+
* opaque/no-`exp` token — no proof it is expired, so keep the conservative
|
|
1138
|
+
* (longer) cooldown and avoid an unnecessary retry loop.
|
|
1139
|
+
*/
|
|
1140
|
+
private isAccessTokenExpired(): boolean {
|
|
1141
|
+
const token = this.tokenStore.getAccessToken();
|
|
1142
|
+
if (!token) {
|
|
1143
|
+
return false;
|
|
1144
|
+
}
|
|
1145
|
+
try {
|
|
1146
|
+
const decoded = jwtDecode<JwtPayload>(token);
|
|
1147
|
+
return typeof decoded.exp === 'number' && decoded.exp <= Math.floor(Date.now() / 1000);
|
|
1148
|
+
} catch {
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1092
1153
|
/**
|
|
1093
1154
|
* PROCESS-WIDE single-flight for the rotating device-secret mint
|
|
1094
1155
|
* (`POST /session/device/token`).
|
|
@@ -164,6 +164,73 @@ describe('HttpService in-session refresh handler', () => {
|
|
|
164
164
|
// The second call is inside the post-failure cooldown → handler not re-run.
|
|
165
165
|
expect(handlerCalls).toBe(1);
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
it('lets an EXPIRED token re-mint promptly instead of waiting out the long proactive cooldown', async () => {
|
|
169
|
+
// Regression: an ECS rolling-deploy blip briefly fails a mint; the 15s
|
|
170
|
+
// proactive cooldown then left the client forwarding/omitting a now-expired
|
|
171
|
+
// bearer for up to 15s after the endpoint recovered (Mention /privacy 401s).
|
|
172
|
+
// An already-expired token must recover on the short cooldown instead.
|
|
173
|
+
globalThis.fetch = async () => jsonResponse({ ok: true });
|
|
174
|
+
const nowSpy = jest.spyOn(Date, 'now');
|
|
175
|
+
const T0 = 1_000_000_000_000;
|
|
176
|
+
nowSpy.mockReturnValue(T0);
|
|
177
|
+
|
|
178
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
179
|
+
// Current token is already 10s past exp (exp is in SECONDS).
|
|
180
|
+
http.setTokens(createJwt({ userId: 'u', exp: Math.floor(T0 / 1000) - 10 }));
|
|
181
|
+
|
|
182
|
+
let handlerCalls = 0;
|
|
183
|
+
http.setAuthRefreshHandler(async () => {
|
|
184
|
+
handlerCalls += 1;
|
|
185
|
+
return null;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// First attempt fails → records the failure timestamp.
|
|
189
|
+
await http.refreshAccessToken('preflight');
|
|
190
|
+
expect(handlerCalls).toBe(1);
|
|
191
|
+
|
|
192
|
+
// 500ms later: still inside the SHORT expired cooldown → NOT re-run. This is
|
|
193
|
+
// the storm guard — an expired token does not fully bypass the cooldown.
|
|
194
|
+
nowSpy.mockReturnValue(T0 + 500);
|
|
195
|
+
await http.refreshAccessToken('preflight');
|
|
196
|
+
expect(handlerCalls).toBe(1);
|
|
197
|
+
|
|
198
|
+
// 1.5s later: past the short expired cooldown but WELL inside the 15s
|
|
199
|
+
// proactive cooldown → the expired token re-mints promptly.
|
|
200
|
+
nowSpy.mockReturnValue(T0 + 1500);
|
|
201
|
+
await http.refreshAccessToken('preflight');
|
|
202
|
+
expect(handlerCalls).toBe(2);
|
|
203
|
+
|
|
204
|
+
nowSpy.mockRestore();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('keeps the full 15s cooldown for a still-valid (proactive) near-expiry refresh', async () => {
|
|
208
|
+
globalThis.fetch = async () => jsonResponse({ ok: true });
|
|
209
|
+
const nowSpy = jest.spyOn(Date, 'now');
|
|
210
|
+
const T0 = 1_000_000_000_000;
|
|
211
|
+
nowSpy.mockReturnValue(T0);
|
|
212
|
+
|
|
213
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
214
|
+
// Token is still valid for another hour — a proactive refresh, not expired.
|
|
215
|
+
http.setTokens(createJwt({ userId: 'u', exp: Math.floor(T0 / 1000) + 3600 }));
|
|
216
|
+
|
|
217
|
+
let handlerCalls = 0;
|
|
218
|
+
http.setAuthRefreshHandler(async () => {
|
|
219
|
+
handlerCalls += 1;
|
|
220
|
+
return null;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await http.refreshAccessToken('preflight');
|
|
224
|
+
expect(handlerCalls).toBe(1);
|
|
225
|
+
|
|
226
|
+
// 1.5s later: past the short expired cooldown, but the token is NOT expired,
|
|
227
|
+
// so the full proactive cooldown still applies → no re-run (no storm).
|
|
228
|
+
nowSpy.mockReturnValue(T0 + 1500);
|
|
229
|
+
await http.refreshAccessToken('preflight');
|
|
230
|
+
expect(handlerCalls).toBe(1);
|
|
231
|
+
|
|
232
|
+
nowSpy.mockRestore();
|
|
233
|
+
});
|
|
167
234
|
});
|
|
168
235
|
|
|
169
236
|
describe('OxyServices.getAccessTokenExpiry', () => {
|