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