@oxy.so/core 1.9.1 → 1.11.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/README.md +4 -4
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +56 -163
- package/dist/cjs/OxyServices.base.js +4 -2
- package/dist/cjs/boot/sessionColdBoot.js +3 -0
- package/dist/cjs/crypto/deviceBackup.js +75 -0
- package/dist/cjs/crypto/keyManager.js +285 -12
- package/dist/cjs/mixins/OxyServices.linkedAccounts.js +79 -0
- package/dist/cjs/mixins/OxyServices.user.js +7 -6
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/server/cors.js +0 -1
- package/dist/cjs/server/securityHeaders.js +3 -0
- package/dist/cjs/session/refresh.js +32 -2
- package/dist/cjs/utils/errorUtils.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +56 -163
- package/dist/esm/OxyServices.base.js +4 -2
- package/dist/esm/boot/sessionColdBoot.js +3 -0
- package/dist/esm/crypto/deviceBackup.js +70 -0
- package/dist/esm/crypto/keyManager.js +252 -12
- package/dist/esm/mixins/OxyServices.linkedAccounts.js +76 -0
- package/dist/esm/mixins/OxyServices.user.js +7 -6
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/server/cors.js +0 -1
- package/dist/esm/server/securityHeaders.js +3 -0
- package/dist/esm/session/refresh.js +32 -2
- package/dist/esm/utils/errorUtils.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +31 -7
- package/dist/types/OxyServices.base.d.ts +3 -1
- package/dist/types/crypto/deviceBackup.d.ts +60 -0
- package/dist/types/crypto/keyManager.d.ts +79 -12
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
- package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
- package/dist/types/mixins/OxyServices.user.d.ts +8 -6
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +30 -0
- package/dist/types/server/securityHeaders.d.ts +5 -0
- package/dist/types/session/refresh.d.ts +7 -0
- package/dist/types/utils/errorUtils.d.ts +1 -1
- package/package.json +2 -2
- package/src/HttpService.ts +59 -187
- package/src/OxyServices.base.ts +4 -2
- package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
- package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
- package/src/__tests__/inSessionRefresh.test.ts +53 -0
- package/src/__tests__/linkedClient.test.ts +0 -7
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
- package/src/boot/sessionColdBoot.ts +3 -0
- package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +310 -0
- package/src/crypto/deviceBackup.ts +108 -0
- package/src/crypto/keyManager.ts +278 -13
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
- package/src/mixins/OxyServices.user.ts +9 -7
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
- package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
- package/src/mixins/__tests__/topics.test.ts +2 -3
- package/src/mixins/index.ts +6 -0
- package/src/models/interfaces.ts +30 -1
- package/src/server/__tests__/securityHeaders.test.ts +7 -0
- package/src/server/cors.ts +0 -1
- package/src/server/securityHeaders.ts +8 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
- package/src/session/__tests__/refresh.test.ts +115 -2
- package/src/session/refresh.ts +38 -2
- package/src/utils/errorUtils.ts +1 -1
package/dist/cjs/HttpService.js
CHANGED
|
@@ -20,18 +20,12 @@ const requestUtils_1 = require("./utils/requestUtils");
|
|
|
20
20
|
const asyncUtils_1 = require("./utils/asyncUtils");
|
|
21
21
|
const errorUtils_1 = require("./utils/errorUtils");
|
|
22
22
|
const jwt_decode_1 = require("jwt-decode");
|
|
23
|
-
const platform_1 = require("./utils/platform");
|
|
24
23
|
const protocol_1 = require("@oxy.so/protocol");
|
|
25
24
|
const cacheKey_1 = require("./utils/cacheKey");
|
|
26
25
|
const redactUrl_1 = require("./utils/redactUrl");
|
|
27
26
|
const OxyServices_errors_1 = require("./OxyServices.errors");
|
|
28
27
|
const edgeRegion_1 = require("./utils/edgeRegion");
|
|
29
28
|
const activityId_1 = require("./utils/activityId");
|
|
30
|
-
/**
|
|
31
|
-
* Check if we're running in a native app environment (React Native, not web)
|
|
32
|
-
* This is used to determine CSRF handling mode
|
|
33
|
-
*/
|
|
34
|
-
const isNativeApp = (0, platform_1.isNative)();
|
|
35
29
|
/**
|
|
36
30
|
* Default per-request timeout (ms) when neither the call site nor
|
|
37
31
|
* {@link OxyConfig.requestTimeout} overrides it. Kept tight so a stalled
|
|
@@ -39,24 +33,6 @@ const isNativeApp = (0, platform_1.isNative)();
|
|
|
39
33
|
* request queue.
|
|
40
34
|
*/
|
|
41
35
|
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
|
|
42
|
-
/**
|
|
43
|
-
* Timeout (ms) for the dedicated `GET /csrf-token` fetch. Independent of the
|
|
44
|
-
* regular request timeout: this is a small, fast, unauthenticated call and
|
|
45
|
-
* should never inherit a longer per-request budget.
|
|
46
|
-
*/
|
|
47
|
-
const CSRF_FETCH_TIMEOUT_MS = 5000;
|
|
48
|
-
/**
|
|
49
|
-
* Number of attempts for fetching a CSRF token before giving up. The first
|
|
50
|
-
* failure is usually a cold edge/cookie race; a single retry recovers it
|
|
51
|
-
* without masking a genuinely broken `/csrf-token` route.
|
|
52
|
-
*/
|
|
53
|
-
const CSRF_FETCH_MAX_ATTEMPTS = 2;
|
|
54
|
-
/**
|
|
55
|
-
* Backoff (ms) between CSRF-token fetch attempts. Short by design — a CSRF
|
|
56
|
-
* fetch sits in the critical path of a state-changing request, so the retry
|
|
57
|
-
* must add minimal latency.
|
|
58
|
-
*/
|
|
59
|
-
const CSRF_FETCH_RETRY_DELAY_MS = 500;
|
|
60
36
|
/**
|
|
61
37
|
* Cooldown (ms) applied after a failed access-token refresh before another
|
|
62
38
|
* refresh is attempted while the CURRENT token is still valid (a proactive,
|
|
@@ -159,8 +135,6 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
|
|
|
159
135
|
class TokenStore {
|
|
160
136
|
constructor() {
|
|
161
137
|
this.accessToken = null;
|
|
162
|
-
this.csrfToken = null;
|
|
163
|
-
this.csrfTokenFetchPromise = null;
|
|
164
138
|
}
|
|
165
139
|
setTokens(accessToken) {
|
|
166
140
|
this.accessToken = accessToken;
|
|
@@ -174,22 +148,6 @@ class TokenStore {
|
|
|
174
148
|
hasAccessToken() {
|
|
175
149
|
return !!this.accessToken;
|
|
176
150
|
}
|
|
177
|
-
setCsrfToken(token) {
|
|
178
|
-
this.csrfToken = token;
|
|
179
|
-
}
|
|
180
|
-
getCsrfToken() {
|
|
181
|
-
return this.csrfToken;
|
|
182
|
-
}
|
|
183
|
-
setCsrfTokenFetchPromise(promise) {
|
|
184
|
-
this.csrfTokenFetchPromise = promise;
|
|
185
|
-
}
|
|
186
|
-
getCsrfTokenFetchPromise() {
|
|
187
|
-
return this.csrfTokenFetchPromise;
|
|
188
|
-
}
|
|
189
|
-
clearCsrfToken() {
|
|
190
|
-
this.csrfToken = null;
|
|
191
|
-
this.csrfTokenFetchPromise = null;
|
|
192
|
-
}
|
|
193
151
|
}
|
|
194
152
|
/**
|
|
195
153
|
* Propagate `parent`'s abort to `child`, returning a disposer.
|
|
@@ -279,6 +237,21 @@ class HttpService {
|
|
|
279
237
|
this.authRefreshHandler = null;
|
|
280
238
|
this.accessTokenProvider = null;
|
|
281
239
|
this.deviceSecretMintInFlight = null;
|
|
240
|
+
/**
|
|
241
|
+
* Bumped by every {@link endSession}. A re-mint captures it before its first
|
|
242
|
+
* await and plants nothing if it moved: a refresh that was already in flight
|
|
243
|
+
* when the user signed out would otherwise complete a moment later and put a
|
|
244
|
+
* bearer — and through the token listeners, the account — back.
|
|
245
|
+
*/
|
|
246
|
+
this.sessionEpoch = 0;
|
|
247
|
+
/**
|
|
248
|
+
* Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
|
|
249
|
+
* native shared-keychain re-mint arm: after a sign-out a 401 is the expected
|
|
250
|
+
* answer, not a cue to sign back in with the identity key. The device-secret
|
|
251
|
+
* arm is NOT gated on it — minting from a credential the store still holds is
|
|
252
|
+
* how a signed-out tab joins a sign-in made in another tab.
|
|
253
|
+
*/
|
|
254
|
+
this.sessionEnded = false;
|
|
282
255
|
/**
|
|
283
256
|
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
284
257
|
* re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
|
|
@@ -432,14 +405,9 @@ class HttpService {
|
|
|
432
405
|
// it before enqueue means auth-blocked requests hold NO slot while the shared
|
|
433
406
|
// mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
|
|
434
407
|
// and skip the near-expiry preflight — see RequestOptions.skipAuth. The
|
|
435
|
-
// 401
|
|
436
|
-
//
|
|
437
|
-
const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
|
408
|
+
// 401 retry re-enters request() with a fresh config, so it re-resolves
|
|
409
|
+
// this here with the refreshed token.
|
|
438
410
|
const authHeader = config.skipAuth ? null : await this.getAuthHeader();
|
|
439
|
-
// CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
|
|
440
|
-
// clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
|
|
441
|
-
// should not need to implement a duplicate `/csrf-token` route.
|
|
442
|
-
const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
|
|
443
411
|
const edgeRegionHeader = await (0, edgeRegion_1.getBrowserEdgeRegionHeader)();
|
|
444
412
|
const activityIdHeader = (0, activityId_1.getBrowserActivityIdHeader)();
|
|
445
413
|
// A request the caller has ALREADY abandoned takes no queue slot and makes
|
|
@@ -510,29 +478,6 @@ class HttpService {
|
|
|
510
478
|
if (authHeader) {
|
|
511
479
|
headers['Authorization'] = authHeader;
|
|
512
480
|
}
|
|
513
|
-
// Add CSRF token header for state-changing requests
|
|
514
|
-
if (csrfToken) {
|
|
515
|
-
headers['X-CSRF-Token'] = csrfToken;
|
|
516
|
-
}
|
|
517
|
-
// Add native app header for React Native (required for CSRF validation)
|
|
518
|
-
// Native apps can't persist cookies like browsers, so the server uses
|
|
519
|
-
// header-only CSRF validation when this header is present
|
|
520
|
-
if (isNativeApp && isStateChangingMethod) {
|
|
521
|
-
headers['X-Native-App'] = 'true';
|
|
522
|
-
}
|
|
523
|
-
// Debug logging for CSRF issues, routed through SimpleLogger so it only
|
|
524
|
-
// fires when consumers opt in via `enableLogging`.
|
|
525
|
-
if (isStateChangingMethod) {
|
|
526
|
-
this.logger.debug('CSRF Debug:', {
|
|
527
|
-
url,
|
|
528
|
-
method,
|
|
529
|
-
isNativeApp,
|
|
530
|
-
platformOS: (0, platform_1.getPlatformOS)(),
|
|
531
|
-
hasCsrfToken: !!csrfToken,
|
|
532
|
-
csrfTokenLength: csrfToken?.length,
|
|
533
|
-
hasNativeAppHeader: headers['X-Native-App'] === 'true',
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
481
|
// Merge custom headers if provided
|
|
537
482
|
if (config.headers) {
|
|
538
483
|
Object.entries(config.headers).forEach(([key, value]) => {
|
|
@@ -584,34 +529,19 @@ class HttpService {
|
|
|
584
529
|
if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
|
|
585
530
|
const refreshed = await this.refreshAccessToken('response-401');
|
|
586
531
|
if (refreshed) {
|
|
587
|
-
// `deduplicate: false` is REQUIRED on the retry
|
|
588
|
-
//
|
|
589
|
-
//
|
|
532
|
+
// `deduplicate: false` is REQUIRED on the retry. This re-issue runs
|
|
533
|
+
// while the ORIGINAL request is still in-flight under its dedupe
|
|
534
|
+
// key; the refreshed token is
|
|
590
535
|
// for the SAME user, so the identity-scoped key is UNCHANGED — a
|
|
591
536
|
// deduplicated retry would resolve to the still-pending original
|
|
592
537
|
// and await itself (deadlock). Opting the retry out of dedupe makes
|
|
593
538
|
// it a fresh request.
|
|
594
539
|
return this.request({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
|
|
595
540
|
}
|
|
596
|
-
// Refresh failed or no token — clear tokens
|
|
541
|
+
// Refresh failed or no token — clear tokens
|
|
597
542
|
this.tokenStore.clearTokens();
|
|
598
|
-
this.tokenStore.clearCsrfToken();
|
|
599
543
|
this.notifyTokenChange();
|
|
600
544
|
}
|
|
601
|
-
// On 403 with CSRF error, clear cached token and retry once
|
|
602
|
-
if (response.status === 403 && !config._isCsrfRetry) {
|
|
603
|
-
try {
|
|
604
|
-
const clonedResponse = response.clone();
|
|
605
|
-
const errBody = await clonedResponse.json();
|
|
606
|
-
if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
|
|
607
|
-
this.tokenStore.clearCsrfToken();
|
|
608
|
-
return this.request({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
catch {
|
|
612
|
-
// Failed to parse error body — not a CSRF error
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
545
|
// Read the error body (may be absent, non-JSON, empty or malformed).
|
|
616
546
|
// Anything unreadable leaves `errorBody` undefined and degrades to the
|
|
617
547
|
// status-based message — an error path that throws its own error is
|
|
@@ -985,75 +915,6 @@ class HttpService {
|
|
|
985
915
|
return false;
|
|
986
916
|
}
|
|
987
917
|
}
|
|
988
|
-
/**
|
|
989
|
-
* Fetch CSRF token from server (with deduplication)
|
|
990
|
-
* Required for state-changing requests (POST, PUT, PATCH, DELETE)
|
|
991
|
-
*/
|
|
992
|
-
async fetchCsrfToken() {
|
|
993
|
-
// Return cached token if available
|
|
994
|
-
const cachedToken = this.tokenStore.getCsrfToken();
|
|
995
|
-
if (cachedToken) {
|
|
996
|
-
this.logger.debug('Using cached CSRF token');
|
|
997
|
-
return cachedToken;
|
|
998
|
-
}
|
|
999
|
-
// Deduplicate concurrent CSRF token fetches
|
|
1000
|
-
const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
|
|
1001
|
-
if (existingPromise) {
|
|
1002
|
-
this.logger.debug('Waiting for existing CSRF fetch');
|
|
1003
|
-
return existingPromise;
|
|
1004
|
-
}
|
|
1005
|
-
const fetchPromise = (async () => {
|
|
1006
|
-
for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
|
|
1007
|
-
try {
|
|
1008
|
-
this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
|
|
1009
|
-
// Use AbortController for timeout (more compatible than AbortSignal.timeout)
|
|
1010
|
-
const controller = new AbortController();
|
|
1011
|
-
const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
|
|
1012
|
-
const response = await fetch(`${this.baseURL}/csrf-token`, {
|
|
1013
|
-
method: 'GET',
|
|
1014
|
-
headers: { 'Accept': 'application/json' },
|
|
1015
|
-
credentials: 'include', // Required to receive and send cookies
|
|
1016
|
-
signal: controller.signal,
|
|
1017
|
-
});
|
|
1018
|
-
clearTimeout(timeoutId);
|
|
1019
|
-
this.logger.debug('CSRF fetch response:', response.status, response.ok);
|
|
1020
|
-
if (response.ok) {
|
|
1021
|
-
const data = await response.json();
|
|
1022
|
-
const token = data.csrfToken || null;
|
|
1023
|
-
this.logger.debug('CSRF response data:', {
|
|
1024
|
-
hasCsrfToken: typeof token === 'string' && token.length > 0,
|
|
1025
|
-
csrfTokenLength: token?.length,
|
|
1026
|
-
});
|
|
1027
|
-
this.tokenStore.setCsrfToken(token);
|
|
1028
|
-
this.logger.debug('CSRF token fetched');
|
|
1029
|
-
return token;
|
|
1030
|
-
}
|
|
1031
|
-
// Also check response header for CSRF token
|
|
1032
|
-
const headerToken = response.headers.get('X-CSRF-Token');
|
|
1033
|
-
if (headerToken) {
|
|
1034
|
-
this.tokenStore.setCsrfToken(headerToken);
|
|
1035
|
-
this.logger.debug('CSRF token from header');
|
|
1036
|
-
return headerToken;
|
|
1037
|
-
}
|
|
1038
|
-
this.logger.debug('CSRF fetch failed with status:', response.status);
|
|
1039
|
-
this.logger.warn('Failed to fetch CSRF token:', response.status);
|
|
1040
|
-
}
|
|
1041
|
-
catch (error) {
|
|
1042
|
-
this.logger.debug('CSRF fetch error:', error);
|
|
1043
|
-
this.logger.warn('CSRF token fetch error:', error);
|
|
1044
|
-
}
|
|
1045
|
-
// Brief backoff before the next attempt.
|
|
1046
|
-
if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
|
|
1047
|
-
await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
return null;
|
|
1051
|
-
})().finally(() => {
|
|
1052
|
-
this.tokenStore.setCsrfTokenFetchPromise(null);
|
|
1053
|
-
});
|
|
1054
|
-
this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
|
|
1055
|
-
return fetchPromise;
|
|
1056
|
-
}
|
|
1057
918
|
/**
|
|
1058
919
|
* Get auth header with automatic token refresh
|
|
1059
920
|
*/
|
|
@@ -1115,12 +976,21 @@ class HttpService {
|
|
|
1115
976
|
// by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
|
|
1116
977
|
// on the way out would discard the flag it just set.
|
|
1117
978
|
this.lastRefreshWasRateLimited = false;
|
|
979
|
+
const epoch = this.sessionEpoch;
|
|
1118
980
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1119
981
|
.then((newToken) => {
|
|
982
|
+
if (epoch !== this.sessionEpoch) {
|
|
983
|
+
// The session ended while this re-mint was in flight. Not a failure
|
|
984
|
+
// (no cooldown), and nothing to plant.
|
|
985
|
+
this.logger.debug('Discarded a token refresh that outlived its session');
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
1120
988
|
if (!newToken) {
|
|
1121
989
|
this.lastRefreshFailureAt = Date.now();
|
|
1122
990
|
return null;
|
|
1123
991
|
}
|
|
992
|
+
// A token is planted again, so there is a session again.
|
|
993
|
+
this.sessionEnded = false;
|
|
1124
994
|
if (this.tokenStore.getAccessToken() !== newToken) {
|
|
1125
995
|
this.tokenStore.setTokens(newToken);
|
|
1126
996
|
this.notifyTokenChange();
|
|
@@ -1320,7 +1190,6 @@ class HttpService {
|
|
|
1320
1190
|
return this.requestAuthenticatedResponseAttempt(config, `Bearer ${refreshed}`, true);
|
|
1321
1191
|
}
|
|
1322
1192
|
this.tokenStore.clearTokens();
|
|
1323
|
-
this.tokenStore.clearCsrfToken();
|
|
1324
1193
|
this.notifyTokenChange();
|
|
1325
1194
|
}
|
|
1326
1195
|
const duration = Date.now() - startTime;
|
|
@@ -1355,9 +1224,34 @@ class HttpService {
|
|
|
1355
1224
|
}
|
|
1356
1225
|
// Token management
|
|
1357
1226
|
setTokens(accessToken) {
|
|
1227
|
+
this.sessionEnded = false;
|
|
1358
1228
|
this.tokenStore.setTokens(accessToken);
|
|
1359
1229
|
this.notifyTokenChange();
|
|
1360
1230
|
}
|
|
1231
|
+
/**
|
|
1232
|
+
* End the local session: clear the bearer and abandon every re-mint already
|
|
1233
|
+
* in flight, so none of them can plant a token after the sign-out.
|
|
1234
|
+
*
|
|
1235
|
+
* {@link clearTokens} alone only drops the bearer, which is right for a
|
|
1236
|
+
* mirror (a linked client following its parent) but not for a sign-out: a
|
|
1237
|
+
* refresh started a moment earlier would finish and plant a new one.
|
|
1238
|
+
*/
|
|
1239
|
+
endSession() {
|
|
1240
|
+
this.sessionEpoch += 1;
|
|
1241
|
+
this.sessionEnded = true;
|
|
1242
|
+
this.clearTokens();
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* The current session epoch — see {@link endSession}. A re-mint lane that
|
|
1246
|
+
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
1247
|
+
*/
|
|
1248
|
+
getSessionEpoch() {
|
|
1249
|
+
return this.sessionEpoch;
|
|
1250
|
+
}
|
|
1251
|
+
/** Whether {@link endSession} ran and no token has been planted since. */
|
|
1252
|
+
hasSessionEnded() {
|
|
1253
|
+
return this.sessionEnded;
|
|
1254
|
+
}
|
|
1361
1255
|
setAuthRefreshHandler(handler) {
|
|
1362
1256
|
this.authRefreshHandler = handler;
|
|
1363
1257
|
}
|
|
@@ -1366,7 +1260,6 @@ class HttpService {
|
|
|
1366
1260
|
}
|
|
1367
1261
|
clearTokens() {
|
|
1368
1262
|
this.tokenStore.clearTokens();
|
|
1369
|
-
this.tokenStore.clearCsrfToken();
|
|
1370
1263
|
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
1371
1264
|
// different user could never read these entries, but a logged-out client
|
|
1372
1265
|
// must not keep the previous session's personalized data resident in
|
|
@@ -1482,8 +1375,8 @@ class HttpService {
|
|
|
1482
1375
|
}
|
|
1483
1376
|
// Test-only utility — clears tokens on this instance
|
|
1484
1377
|
__resetTokensForTests() {
|
|
1378
|
+
this.sessionEnded = false;
|
|
1485
1379
|
this.tokenStore.clearTokens();
|
|
1486
|
-
this.tokenStore.clearCsrfToken();
|
|
1487
1380
|
}
|
|
1488
1381
|
}
|
|
1489
1382
|
exports.HttpService = HttpService;
|
|
@@ -168,10 +168,12 @@ class OxyServicesBase {
|
|
|
168
168
|
this.httpService.setTokens(accessToken);
|
|
169
169
|
}
|
|
170
170
|
/**
|
|
171
|
-
* Clear stored authentication tokens
|
|
171
|
+
* Clear stored authentication tokens and end the local session: a re-mint
|
|
172
|
+
* already in flight plants nothing, and no new one runs until a token is set
|
|
173
|
+
* again (see `HttpService.endSession`).
|
|
172
174
|
*/
|
|
173
175
|
clearTokens() {
|
|
174
|
-
this.httpService.
|
|
176
|
+
this.httpService.endSession();
|
|
175
177
|
this._cachedUserId = undefined;
|
|
176
178
|
this._cachedAccessToken = null;
|
|
177
179
|
}
|
|
@@ -205,6 +205,9 @@ async function runSessionColdBoot(opts) {
|
|
|
205
205
|
// Network / 5xx: keep the secret; a later attempt can succeed.
|
|
206
206
|
logger_1.logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' });
|
|
207
207
|
return { kind: 'skip' };
|
|
208
|
+
case 'session-ended':
|
|
209
|
+
// Signed out while the boot was minting; the arm planted nothing.
|
|
210
|
+
return { kind: 'skip' };
|
|
208
211
|
case 'no-secret':
|
|
209
212
|
return { kind: 'skip' };
|
|
210
213
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The device backup of the self-custody identity: a copy that lives OUTSIDE this
|
|
4
|
+
* app's own keystore, so a keystore wipe cannot take it along with the keys.
|
|
5
|
+
*
|
|
6
|
+
* Why it exists (OxyHQ/oxy#1388): on Android every Oxy app shares the Linux UID
|
|
7
|
+
* `so.oxy.shared`, and the Android Keystore is per UID. Clearing the storage of
|
|
8
|
+
* ANY sibling app (`pm clear`, or Settings › Apps › Storage › Clear storage)
|
|
9
|
+
* wipes the Keystore of the whole UID. Every copy the SDK keeps in
|
|
10
|
+
* expo-secure-store (primary, backup slot, phrase slot) and in the androidx
|
|
11
|
+
* encrypted prefs (the cross-app shared slot) is wrapped by a key in that
|
|
12
|
+
* Keystore, so they all die together, and until now the only way back was the
|
|
13
|
+
* recovery phrase.
|
|
14
|
+
*
|
|
15
|
+
* `@oxy.so/core` never imports a native module, so the store is injected by the
|
|
16
|
+
* app that owns the identity ({@link KeyManager.setDeviceBackupStore}). Commons
|
|
17
|
+
* registers Android Block Store; every other app registers nothing, which keeps
|
|
18
|
+
* every code path below a no-op for them. The design and the threat model are in
|
|
19
|
+
* `docs/identity/device-backup.md`.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.DEVICE_BACKUP_VERSION = void 0;
|
|
23
|
+
exports.serializeDeviceBackup = serializeDeviceBackup;
|
|
24
|
+
exports.parseDeviceBackup = parseDeviceBackup;
|
|
25
|
+
/** The record format. Bump {@link DEVICE_BACKUP_VERSION} on a breaking change. */
|
|
26
|
+
exports.DEVICE_BACKUP_VERSION = 1;
|
|
27
|
+
function serializeDeviceBackup(record) {
|
|
28
|
+
const out = {
|
|
29
|
+
version: exports.DEVICE_BACKUP_VERSION,
|
|
30
|
+
privateKey: record.privateKey,
|
|
31
|
+
publicKey: record.publicKey,
|
|
32
|
+
updatedAt: record.updatedAt,
|
|
33
|
+
};
|
|
34
|
+
if (record.mnemonic) {
|
|
35
|
+
out.mnemonic = record.mnemonic;
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify(out);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parse a stored record, or `null` for anything that is not a well-formed
|
|
41
|
+
* version-1 record. Checks SHAPE only; the caller still proves the key pair is
|
|
42
|
+
* healthy (well-formed, public derives from private) before trusting it.
|
|
43
|
+
*/
|
|
44
|
+
function parseDeviceBackup(raw) {
|
|
45
|
+
if (!raw) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const candidate = parsed;
|
|
59
|
+
if (candidate.version !== exports.DEVICE_BACKUP_VERSION ||
|
|
60
|
+
typeof candidate.privateKey !== 'string' ||
|
|
61
|
+
typeof candidate.publicKey !== 'string' ||
|
|
62
|
+
typeof candidate.updatedAt !== 'string') {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const record = {
|
|
66
|
+
version: exports.DEVICE_BACKUP_VERSION,
|
|
67
|
+
privateKey: candidate.privateKey,
|
|
68
|
+
publicKey: candidate.publicKey,
|
|
69
|
+
updatedAt: candidate.updatedAt,
|
|
70
|
+
};
|
|
71
|
+
if (typeof candidate.mnemonic === 'string' && candidate.mnemonic.length > 0) {
|
|
72
|
+
record.mnemonic = candidate.mnemonic;
|
|
73
|
+
}
|
|
74
|
+
return record;
|
|
75
|
+
}
|