@oxyhq/core 5.5.0 → 7.0.0
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 +7 -4
- package/dist/cjs/OxyServices.base.js +7 -102
- package/dist/cjs/boot/coldBootV2.js +350 -0
- package/dist/cjs/boot/deviceBootReturn.js +152 -0
- package/dist/cjs/crypto/keyManager.js +95 -0
- package/dist/cjs/i18n/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/es-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
- package/dist/cjs/index.js +54 -45
- package/dist/cjs/mixins/OxyServices.accounts.js +13 -26
- package/dist/cjs/mixins/OxyServices.auth.js +66 -201
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
- package/dist/cjs/mixins/index.js +8 -17
- package/dist/cjs/server/index.js +2 -2
- package/dist/cjs/session/SessionClient.js +40 -1
- package/dist/cjs/session/authStateStore.js +284 -0
- package/dist/cjs/session/refresh.js +264 -0
- package/dist/cjs/shared/utils/debugUtils.js +3 -3
- package/dist/cjs/utils/accountUtils.js +1 -55
- package/dist/cjs/utils/authWebUrl.js +10 -39
- package/dist/cjs/utils/coldBoot.js +10 -8
- package/dist/cjs/utils/platform.js +19 -0
- package/dist/cjs/utils/registrableApex.js +49 -0
- package/dist/cjs/utils/ssoBounce.js +15 -362
- package/dist/cjs/utils/validationUtils.js +57 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +7 -4
- package/dist/esm/OxyServices.base.js +7 -102
- package/dist/esm/boot/coldBootV2.js +344 -0
- package/dist/esm/boot/deviceBootReturn.js +146 -0
- package/dist/esm/crypto/keyManager.js +95 -0
- package/dist/esm/i18n/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/es-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
- package/dist/esm/index.js +34 -17
- package/dist/esm/mixins/OxyServices.accounts.js +13 -26
- package/dist/esm/mixins/OxyServices.auth.js +66 -201
- package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
- package/dist/esm/mixins/index.js +8 -17
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/session/SessionClient.js +40 -1
- package/dist/esm/session/authStateStore.js +278 -0
- package/dist/esm/session/refresh.js +257 -0
- package/dist/esm/shared/utils/debugUtils.js +3 -3
- package/dist/esm/utils/accountUtils.js +0 -53
- package/dist/esm/utils/authWebUrl.js +9 -37
- package/dist/esm/utils/coldBoot.js +10 -8
- package/dist/esm/utils/platform.js +18 -0
- package/dist/esm/utils/registrableApex.js +46 -0
- package/dist/esm/utils/ssoBounce.js +14 -345
- package/dist/esm/utils/validationUtils.js +56 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +14 -1
- package/dist/types/OxyServices.base.d.ts +0 -52
- package/dist/types/OxyServices.d.ts +0 -25
- package/dist/types/boot/coldBootV2.d.ts +76 -0
- package/dist/types/boot/deviceBootReturn.d.ts +83 -0
- package/dist/types/crypto/keyManager.d.ts +21 -0
- package/dist/types/index.d.ts +14 -21
- package/dist/types/mixins/OxyServices.accounts.d.ts +24 -29
- package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
- package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
- package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -2
- package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
- package/dist/types/mixins/OxyServices.language.d.ts +0 -2
- package/dist/types/mixins/OxyServices.links.d.ts +0 -2
- package/dist/types/mixins/OxyServices.location.d.ts +0 -2
- package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
- package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
- package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
- package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
- package/dist/types/mixins/OxyServices.security.d.ts +0 -2
- package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.user.d.ts +0 -2
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
- package/dist/types/mixins/index.d.ts +5 -9
- package/dist/types/models/interfaces.d.ts +0 -67
- package/dist/types/models/session.d.ts +4 -5
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/session/SessionClient.d.ts +26 -1
- package/dist/types/session/authStateStore.d.ts +119 -0
- package/dist/types/session/refresh.d.ts +93 -0
- package/dist/types/shared/utils/debugUtils.d.ts +3 -3
- package/dist/types/utils/accountUtils.d.ts +2 -17
- package/dist/types/utils/authWebUrl.d.ts +9 -35
- package/dist/types/utils/coldBoot.d.ts +17 -14
- package/dist/types/utils/platform.d.ts +14 -0
- package/dist/types/utils/registrableApex.d.ts +31 -0
- package/dist/types/utils/ssoBounce.d.ts +14 -280
- package/dist/types/utils/validationUtils.d.ts +15 -0
- package/package.json +2 -2
- package/src/HttpService.ts +20 -4
- package/src/OxyServices.base.ts +7 -112
- package/src/OxyServices.ts +0 -38
- package/src/boot/__tests__/coldBootV2.test.ts +317 -0
- package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
- package/src/boot/coldBootV2.ts +426 -0
- package/src/boot/deviceBootReturn.ts +195 -0
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
- package/src/crypto/keyManager.ts +101 -0
- package/src/i18n/locales/en-US.json +13 -1
- package/src/i18n/locales/es-ES.json +13 -1
- package/src/index.ts +75 -65
- package/src/mixins/OxyServices.accounts.ts +27 -39
- package/src/mixins/OxyServices.auth.ts +78 -253
- package/src/mixins/OxyServices.deviceBoot.ts +146 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
- package/src/mixins/__tests__/accounts.test.ts +17 -44
- package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
- package/src/mixins/index.ts +10 -23
- package/src/models/interfaces.ts +0 -79
- package/src/models/session.ts +4 -5
- package/src/server/index.ts +1 -1
- package/src/session/SessionClient.ts +53 -2
- package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
- package/src/session/__tests__/SessionClient.state.test.ts +18 -5
- package/src/session/__tests__/authStateStore.test.ts +209 -0
- package/src/session/__tests__/refresh.test.ts +256 -0
- package/src/session/authStateStore.ts +335 -0
- package/src/session/refresh.ts +334 -0
- package/src/shared/utils/debugUtils.ts +3 -3
- package/src/utils/__tests__/authWebUrl.test.ts +5 -40
- package/src/utils/__tests__/registrableApex.test.ts +62 -0
- package/src/utils/__tests__/validationUtils.test.ts +30 -0
- package/src/utils/accountUtils.ts +2 -65
- package/src/utils/authWebUrl.ts +9 -39
- package/src/utils/coldBoot.ts +17 -14
- package/src/utils/platform.ts +21 -0
- package/src/utils/registrableApex.ts +45 -0
- package/src/utils/ssoBounce.ts +14 -393
- package/src/utils/validationUtils.ts +62 -0
- package/dist/cjs/AuthManager.js +0 -1110
- package/dist/cjs/AuthManagerTypes.js +0 -13
- package/dist/cjs/CrossDomainAuth.js +0 -206
- package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
- package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
- package/dist/cjs/mixins/OxyServices.silent.js +0 -204
- package/dist/cjs/mixins/OxyServices.sso.js +0 -208
- package/dist/cjs/utils/fapiAutoDetect.js +0 -99
- package/dist/cjs/utils/ssoEstablish.js +0 -110
- package/dist/cjs/utils/ssoReturn.js +0 -275
- package/dist/esm/AuthManager.js +0 -1105
- package/dist/esm/AuthManagerTypes.js +0 -12
- package/dist/esm/CrossDomainAuth.js +0 -201
- package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
- package/dist/esm/mixins/OxyServices.redirect.js +0 -92
- package/dist/esm/mixins/OxyServices.silent.js +0 -202
- package/dist/esm/mixins/OxyServices.sso.js +0 -204
- package/dist/esm/utils/fapiAutoDetect.js +0 -95
- package/dist/esm/utils/ssoEstablish.js +0 -107
- package/dist/esm/utils/ssoReturn.js +0 -271
- package/dist/types/AuthManager.d.ts +0 -380
- package/dist/types/AuthManagerTypes.d.ts +0 -81
- package/dist/types/CrossDomainAuth.d.ts +0 -164
- package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
- package/dist/types/mixins/OxyServices.redirect.d.ts +0 -92
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
- package/dist/types/utils/fapiAutoDetect.d.ts +0 -56
- package/dist/types/utils/ssoEstablish.d.ts +0 -85
- package/dist/types/utils/ssoReturn.d.ts +0 -156
- package/src/AuthManager.ts +0 -1269
- package/src/AuthManagerTypes.ts +0 -86
- package/src/CrossDomainAuth.ts +0 -243
- package/src/__tests__/authManager.cookiePath.test.ts +0 -390
- package/src/__tests__/authManager.security.test.ts +0 -377
- package/src/__tests__/crossDomainAuth.test.ts +0 -116
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
- package/src/mixins/OxyServices.fedcm.ts +0 -1026
- package/src/mixins/OxyServices.redirect.ts +0 -122
- package/src/mixins/OxyServices.silent.ts +0 -272
- package/src/mixins/OxyServices.sso.ts +0 -261
- package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
- package/src/mixins/__tests__/fedcm.test.ts +0 -667
- package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
- package/src/mixins/__tests__/silent.test.ts +0 -102
- package/src/mixins/__tests__/sso.test.ts +0 -228
- package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
- package/src/utils/__tests__/fapiAutoDetect.test.ts +0 -183
- package/src/utils/__tests__/ssoBounce.test.ts +0 -219
- package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
- package/src/utils/__tests__/ssoReturn.test.ts +0 -276
- package/src/utils/fapiAutoDetect.ts +0 -91
- package/src/utils/ssoEstablish.ts +0 -174
- package/src/utils/ssoReturn.ts +0 -389
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted auth state — ONE shape, web + native.
|
|
3
|
+
*
|
|
4
|
+
* The device-first session model (auth-centralization wave 1) persists the
|
|
5
|
+
* rotating refresh-token family head per ORIGIN so a reload restores the
|
|
6
|
+
* session locally without a redirect. This module is the storage seam: a tiny
|
|
7
|
+
* `load / save / clear` interface plus platform factories, so the cold boot
|
|
8
|
+
* (`coldBootV2`) and the unified refresh handler (`refresh.ts`) never touch a
|
|
9
|
+
* platform storage API directly.
|
|
10
|
+
*
|
|
11
|
+
* Platform-agnostic — the native factory takes an INJECTED key/value store
|
|
12
|
+
* (`@oxyhq/services` passes a SecureStore-backed adapter) so `@oxyhq/core`
|
|
13
|
+
* never imports `expo-secure-store`. The web factory is self-contained
|
|
14
|
+
* (`localStorage`) and degrades to in-memory when storage is unavailable
|
|
15
|
+
* (sandboxed iframe `SecurityError`, private-mode quota, SSR).
|
|
16
|
+
*
|
|
17
|
+
* ESM-safe (no `require()`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The persisted session credential set for a single origin.
|
|
22
|
+
*
|
|
23
|
+
* `refreshToken` is the rotating single-use family head; `sessionId` + `userId`
|
|
24
|
+
* identify the owning device session and account. `deviceToken` is the opaque,
|
|
25
|
+
* add-only device attribution token (mirrored to the shared keychain on native
|
|
26
|
+
* so every Oxy app on one phone shares one DeviceSession).
|
|
27
|
+
*
|
|
28
|
+
* `accessToken` + `expiresAt` are OPTIONAL warm-boot fields. Persisting them
|
|
29
|
+
* lets the cold boot plant a still-valid access token on the very first paint
|
|
30
|
+
* WITHOUT a blocking `/auth/refresh-token` round-trip — the proactive scheduler
|
|
31
|
+
* then rotates it in the background. They are a strict optimization: the store
|
|
32
|
+
* is fully functional (via `refreshToken`) when they are absent or stale, and
|
|
33
|
+
* the access token is short-lived, so persisting it adds no exposure the
|
|
34
|
+
* already-persisted refresh token does not (see the plan's XSS risk note — the
|
|
35
|
+
* refresh token is the dominant secret either way).
|
|
36
|
+
*/
|
|
37
|
+
export interface PersistedAuthState {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
refreshToken: string;
|
|
40
|
+
userId: string;
|
|
41
|
+
deviceToken?: string;
|
|
42
|
+
/** Optional warm-boot access token (short-lived; see interface docs). */
|
|
43
|
+
accessToken?: string;
|
|
44
|
+
/** Optional warm-boot access-token expiry, ISO-8601. */
|
|
45
|
+
expiresAt?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The storage seam consumed by the cold boot and the refresh handler. Async
|
|
50
|
+
* throughout so one interface fits both synchronous web `localStorage` and
|
|
51
|
+
* asynchronous native SecureStore/AsyncStorage.
|
|
52
|
+
*
|
|
53
|
+
* Two lifetimes:
|
|
54
|
+
* - The SESSION credential blob (`load`/`save`/`clear`) is per-sign-in and is
|
|
55
|
+
* wiped on `clear()` (sign-out).
|
|
56
|
+
* - The DEVICE token (`loadDeviceToken`/`saveDeviceToken`/`clearDeviceToken`)
|
|
57
|
+
* is long-lived device attribution that SURVIVES `clear()`: a signed-out
|
|
58
|
+
* browser is still the same device, and a later in-app (cross-apex,
|
|
59
|
+
* cookie-less) login sends this token so the new session joins the SAME
|
|
60
|
+
* server-side DeviceSession. Only an explicit device signout-all
|
|
61
|
+
* (`clearDeviceToken`) removes it.
|
|
62
|
+
*/
|
|
63
|
+
export interface AuthStateStore {
|
|
64
|
+
load(): Promise<PersistedAuthState | null>;
|
|
65
|
+
save(state: PersistedAuthState): Promise<void>;
|
|
66
|
+
clear(): Promise<void>;
|
|
67
|
+
loadDeviceToken(): Promise<string | null>;
|
|
68
|
+
saveDeviceToken(token: string): Promise<void>;
|
|
69
|
+
clearDeviceToken(): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The minimal async key/value surface a native store must provide. Matches
|
|
74
|
+
* both `expo-secure-store` (wrapped) and `@react-native-async-storage`.
|
|
75
|
+
*/
|
|
76
|
+
export interface NativeKeyValueStorage {
|
|
77
|
+
getItem(key: string): Promise<string | null>;
|
|
78
|
+
setItem(key: string, value: string): Promise<void>;
|
|
79
|
+
removeItem(key: string): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Versioned storage key. The `.v1` suffix lets a future shape change ship a
|
|
84
|
+
* `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
|
|
85
|
+
* `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
|
|
86
|
+
*/
|
|
87
|
+
export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Storage key for the long-lived device-attribution token. Separate from
|
|
91
|
+
* {@link AUTH_STATE_STORAGE_KEY} because it must OUTLIVE a session `clear()`
|
|
92
|
+
* (sign-out) — the device is unchanged across sign-ins.
|
|
93
|
+
*/
|
|
94
|
+
export const DEVICE_TOKEN_STORAGE_KEY = 'oxy.device.v1';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse + shape-validate a stored blob. Returns `null` for anything that is
|
|
98
|
+
* not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
|
|
99
|
+
* types) so a corrupt entry degrades to "signed out" rather than throwing.
|
|
100
|
+
*/
|
|
101
|
+
function deserialize(raw: string | null): PersistedAuthState | null {
|
|
102
|
+
if (!raw) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
let parsed: unknown;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(raw);
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const candidate = parsed as Record<string, unknown>;
|
|
115
|
+
if (
|
|
116
|
+
typeof candidate.sessionId !== 'string' ||
|
|
117
|
+
typeof candidate.refreshToken !== 'string' ||
|
|
118
|
+
typeof candidate.userId !== 'string' ||
|
|
119
|
+
candidate.sessionId.length === 0 ||
|
|
120
|
+
candidate.refreshToken.length === 0 ||
|
|
121
|
+
candidate.userId.length === 0
|
|
122
|
+
) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const state: PersistedAuthState = {
|
|
126
|
+
sessionId: candidate.sessionId,
|
|
127
|
+
refreshToken: candidate.refreshToken,
|
|
128
|
+
userId: candidate.userId,
|
|
129
|
+
};
|
|
130
|
+
if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
|
|
131
|
+
state.deviceToken = candidate.deviceToken;
|
|
132
|
+
}
|
|
133
|
+
if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
|
|
134
|
+
state.accessToken = candidate.accessToken;
|
|
135
|
+
}
|
|
136
|
+
if (typeof candidate.expiresAt === 'string' && candidate.expiresAt.length > 0) {
|
|
137
|
+
state.expiresAt = candidate.expiresAt;
|
|
138
|
+
}
|
|
139
|
+
return state;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
|
|
144
|
+
* tests/SSR and as the degraded fallback of the web store when `localStorage`
|
|
145
|
+
* is unreachable. Not durable across reloads — that is acceptable for the
|
|
146
|
+
* fallback because the alternative (throwing) would break cold boot entirely.
|
|
147
|
+
*/
|
|
148
|
+
export function createMemoryAuthStateStore(): AuthStateStore {
|
|
149
|
+
let current: PersistedAuthState | null = null;
|
|
150
|
+
let deviceToken: string | null = null;
|
|
151
|
+
return {
|
|
152
|
+
load: async () => current,
|
|
153
|
+
save: async (state) => {
|
|
154
|
+
current = state;
|
|
155
|
+
},
|
|
156
|
+
clear: async () => {
|
|
157
|
+
current = null;
|
|
158
|
+
},
|
|
159
|
+
loadDeviceToken: async () => deviceToken,
|
|
160
|
+
saveDeviceToken: async (token) => {
|
|
161
|
+
deviceToken = token;
|
|
162
|
+
},
|
|
163
|
+
clearDeviceToken: async () => {
|
|
164
|
+
deviceToken = null;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Read the ambient `localStorage`, tolerating the case where merely ACCESSING
|
|
171
|
+
* `window.localStorage` throws. In a sandboxed / cross-origin iframe the getter
|
|
172
|
+
* itself raises `SecurityError` (not the method calls), so the access must be
|
|
173
|
+
* inside the try. Returns `null` when storage is unavailable.
|
|
174
|
+
*/
|
|
175
|
+
function safeGetLocalStorage(): Storage | null {
|
|
176
|
+
try {
|
|
177
|
+
if (typeof globalThis === 'undefined') {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const store = (globalThis as { localStorage?: Storage }).localStorage;
|
|
181
|
+
return store ?? null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A `localStorage`-backed {@link AuthStateStore} under the versioned
|
|
189
|
+
* {@link AUTH_STATE_STORAGE_KEY}.
|
|
190
|
+
*
|
|
191
|
+
* Resilience:
|
|
192
|
+
* - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
|
|
193
|
+
* the whole store degrades to an in-memory {@link createMemoryAuthStateStore}
|
|
194
|
+
* for this page's lifetime — never throws on construction.
|
|
195
|
+
* - Individual `getItem`/`setItem`/`removeItem` are each wrapped: a read that
|
|
196
|
+
* throws yields `null`; a write that throws (quota, private mode) is
|
|
197
|
+
* swallowed. The persisted-refresh lane treats a failed persist as "no
|
|
198
|
+
* durable state" (falls back to the bootstrap hop) rather than crashing.
|
|
199
|
+
*/
|
|
200
|
+
export function createWebAuthStateStore(): AuthStateStore {
|
|
201
|
+
const storage = safeGetLocalStorage();
|
|
202
|
+
if (!storage) {
|
|
203
|
+
return createMemoryAuthStateStore();
|
|
204
|
+
}
|
|
205
|
+
// In-memory mirror so a FAILED persist (quota / private mode / locked store)
|
|
206
|
+
// does not silently lose the session for this page's lifetime. `undefined`
|
|
207
|
+
// means "never written this session" → fall back to storage; any set value
|
|
208
|
+
// (including `null` after clear) is authoritative and preferred over storage.
|
|
209
|
+
let sessionMirror: PersistedAuthState | null | undefined;
|
|
210
|
+
let deviceTokenMirror: string | null | undefined;
|
|
211
|
+
return {
|
|
212
|
+
load: async () => {
|
|
213
|
+
if (sessionMirror !== undefined) {
|
|
214
|
+
return sessionMirror;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
return deserialize(storage.getItem(AUTH_STATE_STORAGE_KEY));
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
save: async (state) => {
|
|
223
|
+
sessionMirror = state; // mirror FIRST — authoritative even if persist fails
|
|
224
|
+
try {
|
|
225
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
|
|
226
|
+
} catch {
|
|
227
|
+
// Quota / private-mode / disabled storage — non-fatal. The session
|
|
228
|
+
// stays live via the in-memory mirror; only reload durability is lost.
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
clear: async () => {
|
|
232
|
+
sessionMirror = null;
|
|
233
|
+
try {
|
|
234
|
+
storage.removeItem(AUTH_STATE_STORAGE_KEY);
|
|
235
|
+
} catch {
|
|
236
|
+
// Non-fatal — see save().
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
loadDeviceToken: async () => {
|
|
240
|
+
if (deviceTokenMirror !== undefined) {
|
|
241
|
+
return deviceTokenMirror;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
return storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
saveDeviceToken: async (token) => {
|
|
250
|
+
deviceTokenMirror = token;
|
|
251
|
+
try {
|
|
252
|
+
storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
|
|
253
|
+
} catch {
|
|
254
|
+
// Non-fatal — see save().
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
clearDeviceToken: async () => {
|
|
258
|
+
deviceTokenMirror = null;
|
|
259
|
+
try {
|
|
260
|
+
storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
261
|
+
} catch {
|
|
262
|
+
// Non-fatal — see save().
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A native {@link AuthStateStore} over an injected async key/value store.
|
|
270
|
+
*
|
|
271
|
+
* `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
|
|
272
|
+
* the SecureStore-backed adapter and passes it here. Every operation is wrapped
|
|
273
|
+
* so a storage exception degrades gracefully (read → `null`, write → swallowed)
|
|
274
|
+
* exactly like the web store.
|
|
275
|
+
*/
|
|
276
|
+
export function createNativeAuthStateStore(storage: NativeKeyValueStorage): AuthStateStore {
|
|
277
|
+
// Same in-memory mirror as the web store — a locked/failed SecureStore write
|
|
278
|
+
// must not silently lose the session for the app's lifetime.
|
|
279
|
+
let sessionMirror: PersistedAuthState | null | undefined;
|
|
280
|
+
let deviceTokenMirror: string | null | undefined;
|
|
281
|
+
return {
|
|
282
|
+
load: async () => {
|
|
283
|
+
if (sessionMirror !== undefined) {
|
|
284
|
+
return sessionMirror;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
return deserialize(await storage.getItem(AUTH_STATE_STORAGE_KEY));
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
save: async (state) => {
|
|
293
|
+
sessionMirror = state;
|
|
294
|
+
try {
|
|
295
|
+
await storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
|
|
296
|
+
} catch {
|
|
297
|
+
// Non-fatal — session stays live via the in-memory mirror.
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
clear: async () => {
|
|
301
|
+
sessionMirror = null;
|
|
302
|
+
try {
|
|
303
|
+
await storage.removeItem(AUTH_STATE_STORAGE_KEY);
|
|
304
|
+
} catch {
|
|
305
|
+
// Non-fatal.
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
loadDeviceToken: async () => {
|
|
309
|
+
if (deviceTokenMirror !== undefined) {
|
|
310
|
+
return deviceTokenMirror;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
return await storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
saveDeviceToken: async (token) => {
|
|
319
|
+
deviceTokenMirror = token;
|
|
320
|
+
try {
|
|
321
|
+
await storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
|
|
322
|
+
} catch {
|
|
323
|
+
// Non-fatal.
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
clearDeviceToken: async () => {
|
|
327
|
+
deviceTokenMirror = null;
|
|
328
|
+
try {
|
|
329
|
+
await storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
330
|
+
} catch {
|
|
331
|
+
// Non-fatal.
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified token refresh — THE single refresh implementation for web + native.
|
|
3
|
+
*
|
|
4
|
+
* Before device-first, refresh was duplicated: `@oxyhq/auth`'s
|
|
5
|
+
* `session/tokenRefresh.ts` (per-apex `/auth/silent` iframe) and
|
|
6
|
+
* `@oxyhq/services`'s `inSessionTokenRefresh.ts` (native shared-key). This
|
|
7
|
+
* module replaces both with ONE persisted-refresh-token rotation shared by
|
|
8
|
+
* every consumer:
|
|
9
|
+
*
|
|
10
|
+
* - `refreshPersistedSession` — arm 1 rotates the stored refresh-token family
|
|
11
|
+
* (`POST /auth/refresh-token`), planting + persisting the rotated pair; arm 2
|
|
12
|
+
* (native only) re-mints via the shared-keychain identity when there is no
|
|
13
|
+
* live refresh token. It is used BOTH reactively (wrapped as the
|
|
14
|
+
* `AuthRefreshHandler` installed on `HttpService`) AND proactively (the
|
|
15
|
+
* cold-boot `stored-tokens` step calls it directly).
|
|
16
|
+
* - `createAuthRefreshHandler` / `installAuthRefreshHandler` wire arm 1+2 into
|
|
17
|
+
* `HttpService.setAuthRefreshHandler`, keeping that layer's single-flight
|
|
18
|
+
* dedup + cooldown (this module does NOT reimplement them).
|
|
19
|
+
* - `startTokenRefreshScheduler` — a proactive scheduler (lifted from the
|
|
20
|
+
* better of the two prior duplicates, `@oxyhq/auth`'s `tokenRefresh.ts`),
|
|
21
|
+
* decoupled from any React / auth-sdk type: refreshes ~60s before `exp`,
|
|
22
|
+
* re-arms on token change + web tab-focus, `.unref?.()`s its timer in Node.
|
|
23
|
+
*
|
|
24
|
+
* Framework-free; no module-level mutable state.
|
|
25
|
+
*/
|
|
26
|
+
import type { OxyServices } from '../OxyServices';
|
|
27
|
+
import type { AuthRefreshHandler, AuthRefreshReason } from '../HttpService';
|
|
28
|
+
import type { AuthStateStore, PersistedAuthState } from './authStateStore';
|
|
29
|
+
import { isNative } from '../utils/platform';
|
|
30
|
+
import { logger } from '../utils/loggerUtils';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lead time (ms) before access-token expiry at which the proactive scheduler
|
|
34
|
+
* refreshes. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
|
|
35
|
+
* (60s) so the scheduled refresh and the request-time preflight refresh use
|
|
36
|
+
* the same window — the scheduler just fires it during idle/background.
|
|
37
|
+
*/
|
|
38
|
+
export const TOKEN_REFRESH_LEAD_MS = 60_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Max `setTimeout` delay (2^31 − 1 ms, ~24.8 days). A larger delay overflows
|
|
42
|
+
* the int32 timer field and fires IMMEDIATELY — with a long-TTL token that
|
|
43
|
+
* turns the reschedule-on-finish loop into a tight busy refresh. Clamp to it.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_TIMEOUT_DELAY_MS = 2_147_483_647;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Floor (ms) on ANY scheduled delay. An already-expired / in-lead-window token
|
|
49
|
+
* computes a non-positive `exp − now − lead`; without this floor that becomes
|
|
50
|
+
* `setTimeout(…, 0)`, and a FAILING refresh (offline / server error) would
|
|
51
|
+
* re-arm at 0 in the finally block → a tight 100%-CPU busy loop. The floor
|
|
52
|
+
* guarantees every re-arm yields the event loop.
|
|
53
|
+
*/
|
|
54
|
+
const MIN_SCHEDULE_DELAY_MS = 1_000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Backoff schedule (ms) applied when a scheduled refresh FAILS: first retry
|
|
58
|
+
* after {@link MIN_FAILURE_BACKOFF_MS}, doubling up to {@link MAX_FAILURE_BACKOFF_MS}.
|
|
59
|
+
* Reset to 0 on any success or token change. This is what converts the former
|
|
60
|
+
* zero-delay failure loop into a bounded, backing-off retry.
|
|
61
|
+
*/
|
|
62
|
+
const MIN_FAILURE_BACKOFF_MS = 5_000;
|
|
63
|
+
const MAX_FAILURE_BACKOFF_MS = 5 * 60_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Error codes (in addition to HTTP 401/403) that mean the stored refresh token
|
|
67
|
+
* is permanently unusable — the family was revoked or a reuse was detected. On
|
|
68
|
+
* any of these the persisted store is CLEARED (the session is truly over);
|
|
69
|
+
* transient failures (network, 5xx) leave the store intact so a later attempt
|
|
70
|
+
* can still succeed.
|
|
71
|
+
*/
|
|
72
|
+
const REVOKED_REFRESH_CODES = new Set([
|
|
73
|
+
'invalid_grant',
|
|
74
|
+
'refresh_token_revoked',
|
|
75
|
+
'refresh_token_reuse',
|
|
76
|
+
'token_reuse',
|
|
77
|
+
'invalid_token',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
interface HttpishError {
|
|
81
|
+
status?: number;
|
|
82
|
+
code?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Does this error mean the refresh token is permanently dead (vs. transient)? */
|
|
86
|
+
function isRevokedRefreshError(error: unknown): boolean {
|
|
87
|
+
if (!error || typeof error !== 'object') {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const e = error as HttpishError;
|
|
91
|
+
if (e.status === 401 || e.status === 403) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return typeof e.code === 'string' && REVOKED_REFRESH_CODES.has(e.code);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface RefreshDeps {
|
|
98
|
+
oxy: OxyServices;
|
|
99
|
+
store: AuthStateStore;
|
|
100
|
+
/**
|
|
101
|
+
* Whether to fall back to the native shared-keychain re-mint (arm 2) when
|
|
102
|
+
* there is no live refresh token / arm 1 is revoked. Defaults to `isNative()`
|
|
103
|
+
* — web has no shared keychain. Exposed for tests.
|
|
104
|
+
*/
|
|
105
|
+
allowSharedKeyFallback?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rotate the persisted session and return the fresh access token, or `null`
|
|
110
|
+
* when no arm could produce one.
|
|
111
|
+
*
|
|
112
|
+
* Arm 1 (`POST /auth/refresh-token`): if the store holds a refresh token, rotate
|
|
113
|
+
* it — on success plant + persist the rotated pair; on a REVOKED error clear the
|
|
114
|
+
* store; on a transient error leave the store and return `null`.
|
|
115
|
+
*
|
|
116
|
+
* Arm 2 (native shared-keychain): when there is no refresh token or arm 1 was
|
|
117
|
+
* revoked, re-mint via `signInWithSharedIdentity` (which plants tokens). The
|
|
118
|
+
* shared keychain — not the per-origin store — is the durable native credential,
|
|
119
|
+
* so this arm does not write the store.
|
|
120
|
+
*/
|
|
121
|
+
export async function refreshPersistedSession(deps: RefreshDeps): Promise<string | null> {
|
|
122
|
+
const { oxy, store } = deps;
|
|
123
|
+
const allowSharedKeyFallback = deps.allowSharedKeyFallback ?? isNative();
|
|
124
|
+
|
|
125
|
+
const persisted = await store.load();
|
|
126
|
+
|
|
127
|
+
if (persisted?.refreshToken) {
|
|
128
|
+
try {
|
|
129
|
+
const rotated = await oxy.refreshWithToken(persisted.refreshToken);
|
|
130
|
+
oxy.setTokens(rotated.accessToken);
|
|
131
|
+
const next: PersistedAuthState = {
|
|
132
|
+
sessionId: rotated.sessionId,
|
|
133
|
+
refreshToken: rotated.refreshToken,
|
|
134
|
+
userId: persisted.userId,
|
|
135
|
+
accessToken: rotated.accessToken,
|
|
136
|
+
expiresAt: rotated.expiresAt,
|
|
137
|
+
};
|
|
138
|
+
if (persisted.deviceToken) {
|
|
139
|
+
next.deviceToken = persisted.deviceToken;
|
|
140
|
+
}
|
|
141
|
+
await store.save(next);
|
|
142
|
+
return rotated.accessToken;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (isRevokedRefreshError(error)) {
|
|
145
|
+
await store.clear();
|
|
146
|
+
// Fall through to the native shared-key arm below — on a shared-key
|
|
147
|
+
// device the refresh family being revoked does not end the session.
|
|
148
|
+
} else {
|
|
149
|
+
logger.debug(
|
|
150
|
+
'Persisted refresh failed (transient) — keeping store',
|
|
151
|
+
{ component: 'refresh', method: 'refreshPersistedSession' },
|
|
152
|
+
error,
|
|
153
|
+
);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (allowSharedKeyFallback) {
|
|
160
|
+
try {
|
|
161
|
+
const session = await oxy.signInWithSharedIdentity();
|
|
162
|
+
if (session?.accessToken) {
|
|
163
|
+
return session.accessToken;
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
logger.debug(
|
|
167
|
+
'Shared-key refresh fallback failed',
|
|
168
|
+
{ component: 'refresh', method: 'refreshPersistedSession' },
|
|
169
|
+
error,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build the reactive `AuthRefreshHandler` (arm 1 + arm 2). Install it via
|
|
179
|
+
* {@link installAuthRefreshHandler} or directly on
|
|
180
|
+
* `oxy.httpService.setAuthRefreshHandler`. `HttpService` owns single-flight
|
|
181
|
+
* dedup + cooldown, so the timer, the request-time preflight, and a 401 all
|
|
182
|
+
* collapse to one network attempt.
|
|
183
|
+
*/
|
|
184
|
+
export function createAuthRefreshHandler(deps: RefreshDeps): AuthRefreshHandler {
|
|
185
|
+
return async (_reason: AuthRefreshReason): Promise<string | null> => {
|
|
186
|
+
return refreshPersistedSession(deps);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Install the unified refresh handler on the owner client's `HttpService`.
|
|
192
|
+
* Returns a disposer that removes it.
|
|
193
|
+
*/
|
|
194
|
+
export function installAuthRefreshHandler(deps: RefreshDeps): () => void {
|
|
195
|
+
deps.oxy.httpService.setAuthRefreshHandler(createAuthRefreshHandler(deps));
|
|
196
|
+
return () => {
|
|
197
|
+
deps.oxy.httpService.setAuthRefreshHandler(null);
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Handle returned by {@link startTokenRefreshScheduler}; `dispose()` tears it down. */
|
|
202
|
+
export interface TokenRefreshSchedulerHandle {
|
|
203
|
+
dispose(): void;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Start the proactive refresh scheduler against `oxy`.
|
|
208
|
+
*
|
|
209
|
+
* Schedules a single timer to fire {@link TOKEN_REFRESH_LEAD_MS} before the
|
|
210
|
+
* current access token's `exp`, calling
|
|
211
|
+
* `oxy.httpService.refreshAccessToken('preflight')` (which runs the installed
|
|
212
|
+
* handler; deduped + cooldown-guarded). After every attempt it reschedules
|
|
213
|
+
* from the possibly-rotated token. It also reschedules whenever the token
|
|
214
|
+
* changes (a sign-out that clears the token cancels the timer) and, on web
|
|
215
|
+
* tab-focus, refreshes immediately if already inside the lead window (a
|
|
216
|
+
* long-hidden tab throttles timers, so the token can be expired on return).
|
|
217
|
+
*
|
|
218
|
+
* No-ops cleanly when there is no token or an opaque/no-`exp` token — the
|
|
219
|
+
* reactive 401 path stays the only refresh trigger in that case. The timer is
|
|
220
|
+
* `.unref?.()`-ed so it never keeps a Node/Jest event loop alive.
|
|
221
|
+
*/
|
|
222
|
+
export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedulerHandle {
|
|
223
|
+
let disposed = false;
|
|
224
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
225
|
+
// 0 = no active backoff; grows on consecutive failures, resets on success /
|
|
226
|
+
// token change. Keeps a failing refresh from re-arming at zero delay.
|
|
227
|
+
let failureBackoffMs = 0;
|
|
228
|
+
|
|
229
|
+
const clearTimer = (): void => {
|
|
230
|
+
if (timer !== null) {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
timer = null;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Arm the timer for `delayMs`, flooring at {@link MIN_SCHEDULE_DELAY_MS} and capping at the int32 max. */
|
|
237
|
+
const armTimer = (delayMs: number): void => {
|
|
238
|
+
clearTimer();
|
|
239
|
+
const clamped = Math.min(Math.max(delayMs, MIN_SCHEDULE_DELAY_MS), MAX_TIMEOUT_DELAY_MS);
|
|
240
|
+
timer = setTimeout(runRefresh, clamped);
|
|
241
|
+
// Never keep a Node/Jest event loop alive for a background refresh timer.
|
|
242
|
+
timer.unref?.();
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** Schedule the next refresh from the current token's expiry (the healthy path). */
|
|
246
|
+
const scheduleFromExpiry = (): void => {
|
|
247
|
+
clearTimer();
|
|
248
|
+
if (disposed || !oxy.getAccessToken()) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const expSeconds = oxy.getAccessTokenExpiry();
|
|
252
|
+
if (expSeconds === null) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
armTimer(expSeconds * 1000 - Date.now() - TOKEN_REFRESH_LEAD_MS);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const runRefresh = (): void => {
|
|
259
|
+
// Clear any pending timer up front so an out-of-band trigger (focus) plus
|
|
260
|
+
// a fired timer can never double-run.
|
|
261
|
+
clearTimer();
|
|
262
|
+
void oxy.httpService.refreshAccessToken('preflight')
|
|
263
|
+
.then((token) => Boolean(token))
|
|
264
|
+
.catch(() => false)
|
|
265
|
+
.then((ok) => {
|
|
266
|
+
if (disposed) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (ok) {
|
|
270
|
+
// Success — drop any backoff and re-arm from the rotated token's exp.
|
|
271
|
+
failureBackoffMs = 0;
|
|
272
|
+
scheduleFromExpiry();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Failure — back off (never re-arm at zero) and retry.
|
|
276
|
+
failureBackoffMs =
|
|
277
|
+
failureBackoffMs === 0
|
|
278
|
+
? MIN_FAILURE_BACKOFF_MS
|
|
279
|
+
: Math.min(failureBackoffMs * 2, MAX_FAILURE_BACKOFF_MS);
|
|
280
|
+
armTimer(failureBackoffMs);
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/** Public (re)schedule entry: a fresh token / focus signal — drop backoff and arm from expiry. */
|
|
285
|
+
const schedule = (): void => {
|
|
286
|
+
failureBackoffMs = 0;
|
|
287
|
+
scheduleFromExpiry();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const onFocus = (): void => {
|
|
291
|
+
if (disposed || !oxy.getAccessToken()) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const expSeconds = oxy.getAccessTokenExpiry();
|
|
295
|
+
if (expSeconds === null) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const remainingMs = expSeconds * 1000 - Date.now();
|
|
299
|
+
if (remainingMs <= TOKEN_REFRESH_LEAD_MS) {
|
|
300
|
+
runRefresh();
|
|
301
|
+
} else {
|
|
302
|
+
schedule();
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const unsubscribeTokens = oxy.onTokensChanged(() => {
|
|
307
|
+
if (!disposed) {
|
|
308
|
+
schedule();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
let removeFocusListener: (() => void) | null = null;
|
|
313
|
+
if (typeof document !== 'undefined') {
|
|
314
|
+
const handler = (): void => {
|
|
315
|
+
if (document.visibilityState === 'visible') {
|
|
316
|
+
onFocus();
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
document.addEventListener('visibilitychange', handler);
|
|
320
|
+
removeFocusListener = () => document.removeEventListener('visibilitychange', handler);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
schedule();
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
dispose(): void {
|
|
327
|
+
disposed = true;
|
|
328
|
+
clearTimer();
|
|
329
|
+
unsubscribeTokens();
|
|
330
|
+
removeFocusListener?.();
|
|
331
|
+
removeFocusListener = null;
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
@@ -24,7 +24,7 @@ export const isDev = (): boolean => {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Log a debug message (only in development)
|
|
27
|
-
* @param prefix - Log prefix (e.g., '[
|
|
27
|
+
* @param prefix - Log prefix (e.g., '[ColdBoot]')
|
|
28
28
|
* @param args - Arguments to log
|
|
29
29
|
*/
|
|
30
30
|
export const debugLog = (prefix: string, ...args: unknown[]): void => {
|
|
@@ -57,12 +57,12 @@ export const debugError = (prefix: string, ...args: unknown[]): void => {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Create a namespaced debug logger
|
|
60
|
-
* @param namespace - Logger namespace (e.g., '
|
|
60
|
+
* @param namespace - Logger namespace (e.g., 'ColdBoot', 'DeviceAuth')
|
|
61
61
|
* @returns Object with log, warn, error methods
|
|
62
62
|
*
|
|
63
63
|
* @example
|
|
64
64
|
* ```ts
|
|
65
|
-
* const debug = createDebugLogger('
|
|
65
|
+
* const debug = createDebugLogger('ColdBoot');
|
|
66
66
|
* debug.log('Starting authentication');
|
|
67
67
|
* debug.warn('Token expires soon');
|
|
68
68
|
* debug.error('Authentication failed', error);
|