@oxyhq/core 3.10.0 → 3.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +9 -2
- package/dist/cjs/HttpService.js +27 -9
- package/dist/cjs/OxyServices.base.js +3 -2
- package/dist/cjs/crypto/canonicalJson.js +107 -0
- package/dist/cjs/crypto/keyManager.js +67 -8
- package/dist/cjs/crypto/signatureService.js +103 -0
- package/dist/cjs/i18n/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/es-ES.json +9 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
- package/dist/cjs/index.js +15 -5
- package/dist/cjs/mixins/OxyServices.assets.js +45 -7
- package/dist/cjs/mixins/OxyServices.auth.js +190 -1
- package/dist/cjs/mixins/OxyServices.identity.js +291 -0
- package/dist/cjs/mixins/OxyServices.sso.js +28 -1
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/mixins/OxyServices.utility.js +52 -23
- package/dist/cjs/mixins/index.js +3 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- package/dist/cjs/utils/fapiAutoDetect.js +12 -42
- package/dist/cjs/utils/ssoReturn.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +9 -2
- package/dist/esm/HttpService.js +27 -9
- package/dist/esm/OxyServices.base.js +3 -2
- package/dist/esm/crypto/canonicalJson.js +104 -0
- package/dist/esm/crypto/keyManager.js +67 -8
- package/dist/esm/crypto/signatureService.js +102 -0
- package/dist/esm/i18n/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/es-ES.json +9 -0
- package/dist/esm/i18n/locales/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
- package/dist/esm/index.js +10 -2
- package/dist/esm/mixins/OxyServices.assets.js +45 -7
- package/dist/esm/mixins/OxyServices.auth.js +190 -1
- package/dist/esm/mixins/OxyServices.identity.js +287 -0
- package/dist/esm/mixins/OxyServices.sso.js +28 -1
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/mixins/OxyServices.utility.js +52 -23
- package/dist/esm/mixins/index.js +3 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- package/dist/esm/utils/fapiAutoDetect.js +12 -41
- package/dist/esm/utils/ssoReturn.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -0
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/crypto/canonicalJson.d.ts +44 -0
- package/dist/types/crypto/keyManager.d.ts +7 -0
- package/dist/types/crypto/signatureService.d.ts +61 -0
- package/dist/types/index.d.ts +7 -3
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +3 -0
- package/dist/types/server/cors.d.ts +5 -5
- package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
- package/dist/types/utils/ssoReturn.d.ts +1 -1
- package/package.json +3 -2
- package/src/AuthManager.ts +8 -2
- package/src/HttpService.ts +36 -8
- package/src/OxyServices.base.ts +3 -2
- package/src/OxyServices.ts +1 -1
- package/src/__tests__/authManager.security.test.ts +31 -0
- package/src/__tests__/authSocket.test.ts +96 -0
- package/src/__tests__/httpServiceCsrf.test.ts +75 -0
- package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
- package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
- package/src/crypto/__tests__/signedRecord.test.ts +125 -0
- package/src/crypto/canonicalJson.ts +120 -0
- package/src/crypto/keyManager.ts +62 -12
- package/src/crypto/signatureService.ts +126 -0
- package/src/i18n/locales/en-US.json +9 -0
- package/src/i18n/locales/es-ES.json +9 -0
- package/src/index.ts +28 -3
- package/src/mixins/OxyServices.assets.ts +56 -7
- package/src/mixins/OxyServices.auth.ts +309 -1
- package/src/mixins/OxyServices.identity.ts +445 -0
- package/src/mixins/OxyServices.sso.ts +30 -1
- package/src/mixins/OxyServices.user.ts +1 -0
- package/src/mixins/OxyServices.utility.ts +57 -23
- package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/assetUpload.test.ts +191 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +49 -2
- package/src/mixins/__tests__/sso.test.ts +31 -0
- package/src/mixins/index.ts +4 -0
- package/src/models/interfaces.ts +3 -0
- package/src/server/__tests__/cors.test.ts +5 -1
- package/src/server/__tests__/rateLimit.test.ts +116 -0
- package/src/server/cors.ts +25 -20
- package/src/server/rateLimit.ts +39 -8
- package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
- package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
- package/src/utils/__tests__/ssoReturn.test.ts +1 -1
- package/src/utils/fapiAutoDetect.ts +12 -39
- package/src/utils/ssoReturn.ts +2 -2
package/dist/esm/AuthManager.js
CHANGED
|
@@ -336,8 +336,15 @@ export class AuthManager {
|
|
|
336
336
|
* Get default storage based on environment.
|
|
337
337
|
*/
|
|
338
338
|
getDefaultStorage() {
|
|
339
|
-
|
|
340
|
-
|
|
339
|
+
try {
|
|
340
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
341
|
+
return new LocalStorageAdapter();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// Accessing window.localStorage can throw in opaque-origin/sandboxed
|
|
346
|
+
// browser contexts or when storage is disabled. Fall back to memory so
|
|
347
|
+
// AuthManager construction remains safe during provider render.
|
|
341
348
|
}
|
|
342
349
|
return new MemoryStorage();
|
|
343
350
|
}
|
package/dist/esm/HttpService.js
CHANGED
|
@@ -348,13 +348,13 @@ export class HttpService {
|
|
|
348
348
|
// use fetch on every platform.
|
|
349
349
|
const useXhrForUpload = isFormData && isReactNative() && typeof XMLHttpRequest !== 'undefined';
|
|
350
350
|
const response = useXhrForUpload
|
|
351
|
-
? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout)
|
|
351
|
+
? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout, this.shouldSendCredentials(fullUrl))
|
|
352
352
|
: await fetch(fullUrl, {
|
|
353
353
|
method,
|
|
354
354
|
headers,
|
|
355
355
|
body: bodyValue,
|
|
356
356
|
signal: controller.signal,
|
|
357
|
-
credentials:
|
|
357
|
+
credentials: this.getCredentialsMode(fullUrl),
|
|
358
358
|
});
|
|
359
359
|
if (timeoutId)
|
|
360
360
|
clearTimeout(timeoutId);
|
|
@@ -380,7 +380,7 @@ export class HttpService {
|
|
|
380
380
|
const errBody = await clonedResponse.json();
|
|
381
381
|
if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
|
|
382
382
|
this.tokenStore.clearCsrfToken();
|
|
383
|
-
return this.request({ ...config, _isCsrfRetry: true, retry: false });
|
|
383
|
+
return this.request({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
|
|
384
384
|
}
|
|
385
385
|
}
|
|
386
386
|
catch {
|
|
@@ -414,7 +414,10 @@ export class HttpService {
|
|
|
414
414
|
// Handle different response types (optimized - read response once)
|
|
415
415
|
const contentType = response.headers.get('content-type');
|
|
416
416
|
let responseData;
|
|
417
|
-
if (
|
|
417
|
+
if (config.responseType === 'blob') {
|
|
418
|
+
responseData = await response.blob();
|
|
419
|
+
}
|
|
420
|
+
else if (contentType && contentType.includes('application/json')) {
|
|
418
421
|
// Use response.json() directly for better performance
|
|
419
422
|
try {
|
|
420
423
|
responseData = await response.json();
|
|
@@ -525,13 +528,14 @@ export class HttpService {
|
|
|
525
528
|
* (status checks, 401/403 retries, JSON/blob/text parsing) is identical
|
|
526
529
|
* to the fetch path.
|
|
527
530
|
*/
|
|
528
|
-
uploadViaXHR(url, method, headers, body, abortSignal, timeout) {
|
|
531
|
+
uploadViaXHR(url, method, headers, body, abortSignal, timeout, withCredentials) {
|
|
529
532
|
return new Promise((resolve, reject) => {
|
|
530
533
|
const xhr = new XMLHttpRequest();
|
|
531
534
|
xhr.open(method, url, true);
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
+
// Only send ambient cookies to the configured API origin. Absolute
|
|
536
|
+
// caller-supplied URLs can target arbitrary origins, so they must not
|
|
537
|
+
// receive credential-bearing requests by default.
|
|
538
|
+
xhr.withCredentials = withCredentials;
|
|
535
539
|
// Forward headers but skip Content-Type — XHR sets the multipart
|
|
536
540
|
// boundary automatically and overriding it breaks the upload.
|
|
537
541
|
for (const [key, value] of Object.entries(headers)) {
|
|
@@ -684,6 +688,17 @@ export class HttpService {
|
|
|
684
688
|
const queryString = searchParams.toString();
|
|
685
689
|
return queryString ? `${base}${base.includes('?') ? '&' : '?'}${queryString}` : base;
|
|
686
690
|
}
|
|
691
|
+
getCredentialsMode(url) {
|
|
692
|
+
return this.shouldSendCredentials(url) ? 'include' : 'omit';
|
|
693
|
+
}
|
|
694
|
+
shouldSendCredentials(url) {
|
|
695
|
+
try {
|
|
696
|
+
return new URL(url).origin === new URL(this.baseURL).origin;
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
687
702
|
/**
|
|
688
703
|
* Fetch CSRF token from server (with deduplication)
|
|
689
704
|
* Required for state-changing requests (POST, PUT, PATCH, DELETE)
|
|
@@ -718,8 +733,11 @@ export class HttpService {
|
|
|
718
733
|
this.logger.debug('CSRF fetch response:', response.status, response.ok);
|
|
719
734
|
if (response.ok) {
|
|
720
735
|
const data = await response.json();
|
|
721
|
-
this.logger.debug('CSRF response data:', data);
|
|
722
736
|
const token = data.csrfToken || null;
|
|
737
|
+
this.logger.debug('CSRF response data:', {
|
|
738
|
+
hasCsrfToken: typeof token === 'string' && token.length > 0,
|
|
739
|
+
csrfTokenLength: token?.length,
|
|
740
|
+
});
|
|
723
741
|
this.tokenStore.setCsrfToken(token);
|
|
724
742
|
this.logger.debug('CSRF token fetched');
|
|
725
743
|
return token;
|
|
@@ -229,8 +229,9 @@ export class OxyServicesBase {
|
|
|
229
229
|
}
|
|
230
230
|
try {
|
|
231
231
|
const decoded = jwtDecode(accessToken);
|
|
232
|
-
|
|
233
|
-
|
|
232
|
+
const userId = decoded.userId || decoded.id || null;
|
|
233
|
+
this._cachedUserId = userId;
|
|
234
|
+
return userId;
|
|
234
235
|
}
|
|
235
236
|
catch {
|
|
236
237
|
this._cachedUserId = null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON (RFC 8785 / JCS-style) serialization.
|
|
3
|
+
*
|
|
4
|
+
* `canonicalize(value)` produces a deterministic string for any JSON-compatible
|
|
5
|
+
* value so that a client which SIGNS a record and a server which VERIFIES it
|
|
6
|
+
* agree byte-for-byte on the signing input — regardless of the order in which
|
|
7
|
+
* object keys happen to be written, how the value was deserialized, or which
|
|
8
|
+
* runtime built it.
|
|
9
|
+
*
|
|
10
|
+
* This is the load-bearing primitive for the self-sovereign identity layer's
|
|
11
|
+
* signed records (`SignatureService.signRecord` + the API's record-verify path):
|
|
12
|
+
* both sides import THIS function from `@oxyhq/core`, so cross-implementation
|
|
13
|
+
* number/string formatting differences cannot cause a verify mismatch.
|
|
14
|
+
*
|
|
15
|
+
* Rules (the JSON Canonicalization Scheme subset we need):
|
|
16
|
+
* - Objects: keys are sorted (ascending, by UTF-16 code unit — the default
|
|
17
|
+
* `Array.prototype.sort` order) and serialized recursively. Properties whose
|
|
18
|
+
* value is `undefined`, a function, or a symbol are OMITTED (matching
|
|
19
|
+
* `JSON.stringify` object semantics).
|
|
20
|
+
* - Arrays: element order is PRESERVED; `undefined`/function/symbol elements
|
|
21
|
+
* serialize to `null` (matching `JSON.stringify` array semantics).
|
|
22
|
+
* - `null`, booleans, strings, and finite numbers serialize via the standard
|
|
23
|
+
* JSON representation.
|
|
24
|
+
* - Values exposing a `toJSON()` method (e.g. `Date`) are replaced by its
|
|
25
|
+
* result first, then serialized — so a `Date` and its ISO-string equivalent
|
|
26
|
+
* canonicalize identically (the wire always carries the string form).
|
|
27
|
+
* - Non-finite numbers (`NaN`, `Infinity`) and `bigint` are not part of the
|
|
28
|
+
* JSON data model and throw, rather than silently producing `null`.
|
|
29
|
+
*
|
|
30
|
+
* Platform-agnostic — zero dependencies, no `require()`, no react/react-native/
|
|
31
|
+
* expo. Safe in the dual CJS + ESM build.
|
|
32
|
+
*/
|
|
33
|
+
function hasToJSON(value) {
|
|
34
|
+
return typeof value.toJSON === 'function';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Serialize a single value into its canonical JSON fragment. Recursive; called
|
|
38
|
+
* on each nested member. Object keys are sorted at every level.
|
|
39
|
+
*/
|
|
40
|
+
function serialize(value) {
|
|
41
|
+
if (value === null) {
|
|
42
|
+
return 'null';
|
|
43
|
+
}
|
|
44
|
+
const valueType = typeof value;
|
|
45
|
+
if (valueType === 'number') {
|
|
46
|
+
if (!Number.isFinite(value)) {
|
|
47
|
+
throw new Error('canonicalize: non-finite numbers cannot be serialized');
|
|
48
|
+
}
|
|
49
|
+
return JSON.stringify(value);
|
|
50
|
+
}
|
|
51
|
+
if (valueType === 'string' || valueType === 'boolean') {
|
|
52
|
+
return JSON.stringify(value);
|
|
53
|
+
}
|
|
54
|
+
if (valueType === 'bigint') {
|
|
55
|
+
throw new Error('canonicalize: bigint values cannot be serialized');
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
const items = value.map((item) => {
|
|
59
|
+
const itemType = typeof item;
|
|
60
|
+
// JSON array semantics: undefined / function / symbol become null so the
|
|
61
|
+
// element positions (and therefore the array length) are preserved.
|
|
62
|
+
if (item === undefined || itemType === 'function' || itemType === 'symbol') {
|
|
63
|
+
return 'null';
|
|
64
|
+
}
|
|
65
|
+
return serialize(item);
|
|
66
|
+
});
|
|
67
|
+
return `[${items.join(',')}]`;
|
|
68
|
+
}
|
|
69
|
+
if (valueType === 'object') {
|
|
70
|
+
const obj = value;
|
|
71
|
+
if (hasToJSON(obj)) {
|
|
72
|
+
return serialize(obj.toJSON());
|
|
73
|
+
}
|
|
74
|
+
const record = obj;
|
|
75
|
+
const parts = [];
|
|
76
|
+
for (const key of Object.keys(record).sort()) {
|
|
77
|
+
const member = record[key];
|
|
78
|
+
const memberType = typeof member;
|
|
79
|
+
// JSON object semantics: properties with undefined / function / symbol
|
|
80
|
+
// values are omitted entirely.
|
|
81
|
+
if (member === undefined || memberType === 'function' || memberType === 'symbol') {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
parts.push(`${JSON.stringify(key)}:${serialize(member)}`);
|
|
85
|
+
}
|
|
86
|
+
return `{${parts.join(',')}}`;
|
|
87
|
+
}
|
|
88
|
+
// undefined / function / symbol at the top level have no JSON representation.
|
|
89
|
+
throw new Error(`canonicalize: cannot serialize a value of type ${valueType}`);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Produce the canonical JSON string for `value`.
|
|
93
|
+
*
|
|
94
|
+
* Deterministic: two structurally-equal values yield identical strings even if
|
|
95
|
+
* their object keys were written in different orders. Use this — never an
|
|
96
|
+
* ad-hoc `JSON.stringify` of a hand-sorted object — as the signing input for
|
|
97
|
+
* signed records, so client signing and server verification cannot drift.
|
|
98
|
+
*
|
|
99
|
+
* @throws if `value` (or any nested member used as the top-level/primitive)
|
|
100
|
+
* contains a non-finite number or a `bigint`, which have no JSON form.
|
|
101
|
+
*/
|
|
102
|
+
export function canonicalize(value) {
|
|
103
|
+
return serialize(value);
|
|
104
|
+
}
|
|
@@ -57,9 +57,9 @@ const STORAGE_KEYS = {
|
|
|
57
57
|
/**
|
|
58
58
|
* iOS Keychain Access Group for sharing identities across Oxy apps
|
|
59
59
|
* All Oxy apps must have this access group enabled in their entitlements
|
|
60
|
-
* Format: [Team ID].
|
|
60
|
+
* Format: [Team ID].so.oxy.shared or group.so.oxy.shared
|
|
61
61
|
*/
|
|
62
|
-
const IOS_KEYCHAIN_GROUP = 'group.
|
|
62
|
+
const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
|
|
63
63
|
/**
|
|
64
64
|
* Android Account Manager type for shared authentication
|
|
65
65
|
* Used with sharedUserId to share sessions across apps
|
|
@@ -691,11 +691,25 @@ export class KeyManager {
|
|
|
691
691
|
throw new IdentityPersistError('Stored identity failed crypto self-test', error);
|
|
692
692
|
}
|
|
693
693
|
// Step 3: The new primary is durable and functional. NOW it is safe to
|
|
694
|
-
// refresh the backup to the new key.
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
694
|
+
// refresh the backup to the new key. This is part of the successful write
|
|
695
|
+
// contract: returning success while the backup still belongs to the
|
|
696
|
+
// previous identity would allow a later restore with an absent primary to
|
|
697
|
+
// silently switch the device back to the previous account. Snapshot the
|
|
698
|
+
// backup first so a partial backup refresh can be rolled back along with
|
|
699
|
+
// the primary before surfacing the failure.
|
|
700
|
+
let priorBackupPrivate;
|
|
701
|
+
let priorBackupPublic;
|
|
702
|
+
let priorBackupTimestamp;
|
|
703
|
+
try {
|
|
704
|
+
priorBackupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
705
|
+
priorBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
706
|
+
priorBackupTimestamp = await store.getItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
logger.error('Failed to snapshot identity backup before refresh', error, { component: 'KeyManager' });
|
|
710
|
+
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
711
|
+
throw new IdentityPersistError('Failed to snapshot identity backup before refresh', error);
|
|
712
|
+
}
|
|
699
713
|
try {
|
|
700
714
|
await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
|
|
701
715
|
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
@@ -704,12 +718,57 @@ export class KeyManager {
|
|
|
704
718
|
await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
|
|
705
719
|
}
|
|
706
720
|
catch (error) {
|
|
707
|
-
logger.
|
|
721
|
+
logger.error('Failed to refresh identity backup after primary write', error, { component: 'KeyManager' });
|
|
722
|
+
await KeyManager._rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
|
|
723
|
+
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
724
|
+
throw new IdentityPersistError('Failed to refresh identity backup after primary write', error);
|
|
708
725
|
}
|
|
709
726
|
// Update cache only after we are certain the identity is durable.
|
|
710
727
|
KeyManager.cachedPublicKey = canonicalPublic;
|
|
711
728
|
KeyManager.cachedHasIdentity = true;
|
|
712
729
|
}
|
|
730
|
+
/**
|
|
731
|
+
* Restore the backup slot to a previously-snapshotted state. Best-effort so
|
|
732
|
+
* the original persistence error remains the one surfaced to the caller.
|
|
733
|
+
*
|
|
734
|
+
* @internal
|
|
735
|
+
*/
|
|
736
|
+
static async _rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp) {
|
|
737
|
+
try {
|
|
738
|
+
if (priorBackupPrivate) {
|
|
739
|
+
await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, priorBackupPrivate, {
|
|
740
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
try {
|
|
745
|
+
await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
746
|
+
}
|
|
747
|
+
catch { /* best effort */ }
|
|
748
|
+
}
|
|
749
|
+
if (priorBackupPublic) {
|
|
750
|
+
await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorBackupPublic);
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
try {
|
|
754
|
+
await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
755
|
+
}
|
|
756
|
+
catch { /* best effort */ }
|
|
757
|
+
}
|
|
758
|
+
if (priorBackupTimestamp) {
|
|
759
|
+
await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, priorBackupTimestamp);
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
try {
|
|
763
|
+
await store.deleteItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
|
|
764
|
+
}
|
|
765
|
+
catch { /* best effort */ }
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
catch (rollbackError) {
|
|
769
|
+
logger.error('Failed to roll back identity backup after a failed refresh', rollbackError, { component: 'KeyManager' });
|
|
770
|
+
}
|
|
771
|
+
}
|
|
713
772
|
/**
|
|
714
773
|
* Restore the primary slot to a previously-snapshotted (privA, pubA) pair,
|
|
715
774
|
* or delete it entirely if there was no prior identity. Best-effort: every
|
|
@@ -7,11 +7,24 @@
|
|
|
7
7
|
import _cjs_elliptic from 'elliptic';
|
|
8
8
|
const { ec: EC } = _cjs_elliptic;
|
|
9
9
|
import { KeyManager } from './keyManager.js';
|
|
10
|
+
import { canonicalize } from './canonicalJson.js';
|
|
10
11
|
import { isReactNative, isNodeJS } from '../utils/platform.js';
|
|
11
12
|
import { loadExpoCrypto, loadNodeCrypto } from '../utils/platformCrypto.js';
|
|
12
13
|
import { logger } from '../utils/loggerUtils.js';
|
|
13
14
|
import { isDev } from '../shared/utils/debugUtils.js';
|
|
14
15
|
const ec = new EC('secp256k1');
|
|
16
|
+
/**
|
|
17
|
+
* Compute the canonical signing input for a signed-record envelope.
|
|
18
|
+
*
|
|
19
|
+
* This is the single definition of "what the signature covers": the canonical
|
|
20
|
+
* JSON of `{version, type, subject, issuer, record, issuedAt}`. `@oxyhq/core`
|
|
21
|
+
* (client signing) and `@oxyhq/api` (server verification) both call this, so a
|
|
22
|
+
* record signed by a client and verified by the server cannot drift.
|
|
23
|
+
*/
|
|
24
|
+
export function signedRecordSigningInput(fields) {
|
|
25
|
+
const { version, type, subject, issuer, record, issuedAt } = fields;
|
|
26
|
+
return canonicalize({ version, type, subject, issuer, record, issuedAt });
|
|
27
|
+
}
|
|
15
28
|
/**
|
|
16
29
|
* Compute SHA-256 hash of a string
|
|
17
30
|
*/
|
|
@@ -199,6 +212,36 @@ export class SignatureService {
|
|
|
199
212
|
timestamp,
|
|
200
213
|
};
|
|
201
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Create a signed authentication challenge response using the SHARED identity
|
|
217
|
+
* key (the cross-app `group.so.oxy.shared` keychain key), not the primary
|
|
218
|
+
* device key.
|
|
219
|
+
*
|
|
220
|
+
* Mirrors {@link signChallenge} exactly — same message format
|
|
221
|
+
* (`auth:${publicKey}:${challenge}:${timestamp}`) so the server verification
|
|
222
|
+
* path is unchanged — but sources the shared public/private key from
|
|
223
|
+
* `KeyManager` and signs with `signWithKey`. Used by "Sign in with Oxy"
|
|
224
|
+
* same-device shared-keychain SSO (Mechanism A): a sibling native app proves
|
|
225
|
+
* control of the shared identity to mint its own session.
|
|
226
|
+
*
|
|
227
|
+
* Throws if no shared identity exists (native-only; the shared keychain is
|
|
228
|
+
* unavailable on web).
|
|
229
|
+
*/
|
|
230
|
+
static async signChallengeWithSharedKey(challenge) {
|
|
231
|
+
const publicKey = await KeyManager.getSharedPublicKey();
|
|
232
|
+
const privateKey = await KeyManager.getSharedPrivateKey();
|
|
233
|
+
if (!publicKey || !privateKey) {
|
|
234
|
+
throw new Error('No shared identity found. Cannot sign with the shared key.');
|
|
235
|
+
}
|
|
236
|
+
const timestamp = Date.now();
|
|
237
|
+
const message = `auth:${publicKey}:${challenge}:${timestamp}`;
|
|
238
|
+
const signature = await SignatureService.signWithKey(message, privateKey);
|
|
239
|
+
return {
|
|
240
|
+
challenge: signature,
|
|
241
|
+
publicKey,
|
|
242
|
+
timestamp,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
202
245
|
/**
|
|
203
246
|
* Verify a challenge response
|
|
204
247
|
*/
|
|
@@ -253,5 +296,64 @@ export class SignatureService {
|
|
|
253
296
|
timestamp,
|
|
254
297
|
};
|
|
255
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Build a signed-record envelope for a self-issued identity/profile record.
|
|
301
|
+
*
|
|
302
|
+
* The envelope is self-issued: `issuer` equals `subject` (the signer's DID).
|
|
303
|
+
* The signature covers the canonical JSON of every field EXCEPT `publicKey`
|
|
304
|
+
* and `signature` (see {@link signedRecordSigningInput}); `alg` is
|
|
305
|
+
* `ES256K-DER-SHA256` (secp256k1 over the SHA-256 of the canonical bytes,
|
|
306
|
+
* DER-encoded), the same scheme this service uses everywhere else.
|
|
307
|
+
*
|
|
308
|
+
* Requires a stored identity (native secure storage); throws if none exists.
|
|
309
|
+
*
|
|
310
|
+
* @param type - The record category (`'identity'` or `'profile'`).
|
|
311
|
+
* @param subject - The subject DID the record is about (also the issuer).
|
|
312
|
+
* @param record - The arbitrary record payload to attest to.
|
|
313
|
+
*/
|
|
314
|
+
static async signRecord(type, subject, record) {
|
|
315
|
+
const publicKey = await KeyManager.getPublicKey();
|
|
316
|
+
if (!publicKey) {
|
|
317
|
+
throw new Error('No identity found. Please create or import an identity first.');
|
|
318
|
+
}
|
|
319
|
+
const version = 1;
|
|
320
|
+
const issuer = subject;
|
|
321
|
+
const issuedAt = Date.now();
|
|
322
|
+
const signingInput = signedRecordSigningInput({
|
|
323
|
+
version,
|
|
324
|
+
type,
|
|
325
|
+
subject,
|
|
326
|
+
issuer,
|
|
327
|
+
record,
|
|
328
|
+
issuedAt,
|
|
329
|
+
});
|
|
330
|
+
const signature = await SignatureService.sign(signingInput);
|
|
331
|
+
return {
|
|
332
|
+
version,
|
|
333
|
+
type,
|
|
334
|
+
subject,
|
|
335
|
+
issuer,
|
|
336
|
+
record,
|
|
337
|
+
issuedAt,
|
|
338
|
+
publicKey,
|
|
339
|
+
alg: 'ES256K-DER-SHA256',
|
|
340
|
+
signature,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Verify a signed-record envelope: recompute the canonical signing input from
|
|
345
|
+
* the envelope's own fields and check the signature against the envelope's
|
|
346
|
+
* `publicKey`.
|
|
347
|
+
*
|
|
348
|
+
* Note: this confirms the signature is internally consistent with the
|
|
349
|
+
* embedded `publicKey`. It does NOT establish that `publicKey` is an
|
|
350
|
+
* authorized verification method for `subject` — that authorization check is
|
|
351
|
+
* the server's responsibility (it asserts the key is a current verification
|
|
352
|
+
* method on the subject's DID).
|
|
353
|
+
*/
|
|
354
|
+
static async verifyRecord(envelope) {
|
|
355
|
+
const signingInput = signedRecordSigningInput(envelope);
|
|
356
|
+
return SignatureService.verify(signingInput, envelope.signature, envelope.publicKey);
|
|
357
|
+
}
|
|
256
358
|
}
|
|
257
359
|
export default SignatureService;
|
|
@@ -129,6 +129,15 @@
|
|
|
129
129
|
"title": "Reputation = Trust & Growth",
|
|
130
130
|
"body": "Oxy Trust is a reputation system that reacts to what you do. Helpful, respectful, constructive actions earn it. Harmful or low‑effort stuff chips it away. More reputation can unlock benefits; low reputation can limit features. It keeps things fair and rewards real contribution."
|
|
131
131
|
},
|
|
132
|
+
"name": {
|
|
133
|
+
"title": "What's your name?",
|
|
134
|
+
"body": "Add your name so people know who you are.",
|
|
135
|
+
"firstLabel": "First name",
|
|
136
|
+
"firstPlaceholder": "Your first name",
|
|
137
|
+
"lastLabel": "Last name",
|
|
138
|
+
"lastPlaceholder": "Your last name",
|
|
139
|
+
"saveFailed": "Could not save your name"
|
|
140
|
+
},
|
|
132
141
|
"avatar": {
|
|
133
142
|
"title": "Make It Yours",
|
|
134
143
|
"body": "Add an avatar so people recognize you. It will show anywhere you show up here. Skip if you want — you can add it later.",
|
|
@@ -849,6 +849,15 @@
|
|
|
849
849
|
"title": "Reputación = Confianza y crecimiento",
|
|
850
850
|
"body": "Oxy Trust es un sistema de reputación que reacciona a lo que haces. Las acciones útiles, respetuosas y constructivas la aumentan. Las acciones dañinas o de poco esfuerzo la reducen. Más reputación puede desbloquear beneficios; poca reputación puede limitar funciones. Mantiene la justicia y recompensa la contribución real."
|
|
851
851
|
},
|
|
852
|
+
"name": {
|
|
853
|
+
"title": "¿Cuál es tu nombre?",
|
|
854
|
+
"body": "Añade tu nombre para que la gente sepa quién eres.",
|
|
855
|
+
"firstLabel": "Nombre",
|
|
856
|
+
"firstPlaceholder": "Tu nombre",
|
|
857
|
+
"lastLabel": "Apellidos",
|
|
858
|
+
"lastPlaceholder": "Tus apellidos",
|
|
859
|
+
"saveFailed": "No se pudo guardar tu nombre"
|
|
860
|
+
},
|
|
852
861
|
"avatar": {
|
|
853
862
|
"title": "Hazlo tuyo",
|
|
854
863
|
"body": "Añade un avatar para que te reconozcan. Se mostrará donde aparezcas aquí. Puedes omitirlo — puedes añadirlo más tarde.",
|
|
@@ -129,6 +129,15 @@
|
|
|
129
129
|
"title": "Reputation = Trust & Growth",
|
|
130
130
|
"body": "Oxy Trust is a reputation system that reacts to what you do. Helpful, respectful, constructive actions earn it. Harmful or low‑effort stuff chips it away. More reputation can unlock benefits; low reputation can limit features. It keeps things fair and rewards real contribution."
|
|
131
131
|
},
|
|
132
|
+
"name": {
|
|
133
|
+
"title": "What's your name?",
|
|
134
|
+
"body": "Add your name so people know who you are.",
|
|
135
|
+
"firstLabel": "First name",
|
|
136
|
+
"firstPlaceholder": "Your first name",
|
|
137
|
+
"lastLabel": "Last name",
|
|
138
|
+
"lastPlaceholder": "Your last name",
|
|
139
|
+
"saveFailed": "Could not save your name"
|
|
140
|
+
},
|
|
132
141
|
"avatar": {
|
|
133
142
|
"title": "Make It Yours",
|
|
134
143
|
"body": "Add an avatar so people recognize you. It will show anywhere you show up here. Skip if you want — you can add it later.",
|
|
@@ -849,6 +849,15 @@
|
|
|
849
849
|
"title": "Reputación = Confianza y crecimiento",
|
|
850
850
|
"body": "Oxy Trust es un sistema de reputación que reacciona a lo que haces. Las acciones útiles, respetuosas y constructivas la aumentan. Las acciones dañinas o de poco esfuerzo la reducen. Más reputación puede desbloquear beneficios; poca reputación puede limitar funciones. Mantiene la justicia y recompensa la contribución real."
|
|
851
851
|
},
|
|
852
|
+
"name": {
|
|
853
|
+
"title": "¿Cuál es tu nombre?",
|
|
854
|
+
"body": "Añade tu nombre para que la gente sepa quién eres.",
|
|
855
|
+
"firstLabel": "Nombre",
|
|
856
|
+
"firstPlaceholder": "Tu nombre",
|
|
857
|
+
"lastLabel": "Apellidos",
|
|
858
|
+
"lastPlaceholder": "Tus apellidos",
|
|
859
|
+
"saveFailed": "No se pudo guardar tu nombre"
|
|
860
|
+
},
|
|
852
861
|
"avatar": {
|
|
853
862
|
"title": "Hazlo tuyo",
|
|
854
863
|
"body": "Añade un avatar para que te reconozcan. Se mostrará donde aparezcas aquí. Puedes omitirlo — puedes añadirlo más tarde.",
|
package/dist/esm/index.js
CHANGED
|
@@ -36,6 +36,13 @@ export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData.js';
|
|
|
36
36
|
export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity.js';
|
|
37
37
|
export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle.js';
|
|
38
38
|
// ---------------------------------------------------------------------------
|
|
39
|
+
// Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
|
|
40
|
+
// verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
|
|
41
|
+
// AuthMethodsResponse, VerifiedDomain, DomainVerificationInstructions,
|
|
42
|
+
// ExportBundle) live in `@oxyhq/contracts` — import them directly from there.
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
export { buildUserDid } from './mixins/OxyServices.identity.js';
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
39
46
|
// Auth helpers (token refresh, error normalisation, retry policies)
|
|
40
47
|
// ---------------------------------------------------------------------------
|
|
41
48
|
export { SessionSyncRequiredError, AuthenticationFailedError, ensureValidToken, isAuthenticationError, withAuthErrorHandling, authenticatedApiCall, } from './utils/authHelpers.js';
|
|
@@ -47,7 +54,8 @@ export { mergeSessions, normalizeAndSortSessions, sessionsArraysEqual, } from '.
|
|
|
47
54
|
// Crypto / identity
|
|
48
55
|
// ---------------------------------------------------------------------------
|
|
49
56
|
export { KeyManager, IdentityAlreadyExistsError, IdentityPersistError, } from './crypto/keyManager.js';
|
|
50
|
-
export { SignatureService } from './crypto/signatureService.js';
|
|
57
|
+
export { SignatureService, signedRecordSigningInput } from './crypto/signatureService.js';
|
|
58
|
+
export { canonicalize } from './crypto/canonicalJson.js';
|
|
51
59
|
export { RecoveryPhraseService } from './crypto/recoveryPhrase.js';
|
|
52
60
|
// ---------------------------------------------------------------------------
|
|
53
61
|
// Devices
|
|
@@ -104,7 +112,7 @@ export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccou
|
|
|
104
112
|
// ---------------------------------------------------------------------------
|
|
105
113
|
// Cross-domain SSO infrastructure
|
|
106
114
|
// ---------------------------------------------------------------------------
|
|
107
|
-
export { autoDetectAuthWebUrl, registrableApex
|
|
115
|
+
export { autoDetectAuthWebUrl, registrableApex } from './utils/fapiAutoDetect.js';
|
|
108
116
|
// Central cross-domain SSO (opaque single-use code bounce via auth.oxy.so)
|
|
109
117
|
export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl.js';
|
|
110
118
|
export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isReactNative } from '../utils/platform.js';
|
|
1
2
|
export function OxyServicesAssetsMixin(Base) {
|
|
2
3
|
return class extends Base {
|
|
3
4
|
constructor(...args) {
|
|
@@ -40,8 +41,8 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
40
41
|
*
|
|
41
42
|
* For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
|
|
42
43
|
*/
|
|
43
|
-
getFileDownloadUrl(fileId, variant, expiresIn) {
|
|
44
|
-
const token = this.getClient().getAccessToken();
|
|
44
|
+
getFileDownloadUrl(fileId, variant, expiresIn, options = {}) {
|
|
45
|
+
const token = options.omitToken ? undefined : this.getClient().getAccessToken();
|
|
45
46
|
// Public case: no auth token and no expiry requested → clean CDN URL.
|
|
46
47
|
// CloudFront serves the public media origin under `${cloudURL}/<id>`.
|
|
47
48
|
if (!token && !expiresIn) {
|
|
@@ -189,10 +190,32 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
189
190
|
formData.append('file', file, fileName);
|
|
190
191
|
}
|
|
191
192
|
else if ('uri' in file && typeof file.uri === 'string') {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
const descriptor = file;
|
|
194
|
+
if (isReactNative()) {
|
|
195
|
+
// React Native file descriptor — RN's FormData handles {uri, type, name} natively.
|
|
196
|
+
// It reads the file from disk during the multipart request — no in-JS Blob
|
|
197
|
+
// conversion (which would fail on Hermes for ArrayBuffer-backed Blobs).
|
|
198
|
+
formData.append('file', descriptor, fileName);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Web (browser/Node): the browser's FormData cannot read bytes from a plain
|
|
202
|
+
// { uri } object — it would serialize "[object Object]" and the server would
|
|
203
|
+
// store a 0-byte asset. Materialize the uri into a real Blob first. `fetch`
|
|
204
|
+
// resolves blob:, data:, and http(s): uris on web, so all picker outputs work.
|
|
205
|
+
const res = await fetch(descriptor.uri);
|
|
206
|
+
if (!res.ok) {
|
|
207
|
+
throw new Error(`Failed to read file from uri (status ${res.status})`);
|
|
208
|
+
}
|
|
209
|
+
const fetched = await res.blob();
|
|
210
|
+
// Preserve the descriptor's declared MIME type when the fetched blob has none.
|
|
211
|
+
const blob = fetched.type === '' && descriptor.type
|
|
212
|
+
? new Blob([fetched], { type: descriptor.type })
|
|
213
|
+
: fetched;
|
|
214
|
+
if (blob.size === 0) {
|
|
215
|
+
throw new Error('Cannot upload an empty file');
|
|
216
|
+
}
|
|
217
|
+
formData.append('file', blob, fileName);
|
|
218
|
+
}
|
|
196
219
|
}
|
|
197
220
|
else {
|
|
198
221
|
throw new Error('Unsupported file input: expected File, Blob, or { uri, type?, name?, size? } descriptor');
|
|
@@ -415,7 +438,9 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
415
438
|
return urlRes?.url || null;
|
|
416
439
|
}
|
|
417
440
|
async fetchAssetContent(url, type) {
|
|
418
|
-
const response = await fetch(url, {
|
|
441
|
+
const response = await fetch(url, {
|
|
442
|
+
credentials: shouldSendAssetCredentials(url, this.getBaseURL()) ? 'include' : 'omit',
|
|
443
|
+
});
|
|
419
444
|
if (!response?.ok) {
|
|
420
445
|
throw new Error(`Failed to fetch asset content (status ${response?.status})`);
|
|
421
446
|
}
|
|
@@ -423,3 +448,16 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
423
448
|
}
|
|
424
449
|
};
|
|
425
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Only send ambient credentials (cookies) when the asset URL is same-origin with
|
|
453
|
+
* the configured API base. Caller-supplied cross-origin asset URLs must not leak
|
|
454
|
+
* the user's cookies to arbitrary hosts.
|
|
455
|
+
*/
|
|
456
|
+
function shouldSendAssetCredentials(url, baseURL) {
|
|
457
|
+
try {
|
|
458
|
+
return new URL(url).origin === new URL(baseURL).origin;
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|