@oxyhq/core 3.10.1 → 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/index.js +15 -4
- package/dist/cjs/mixins/OxyServices.assets.js +16 -1
- 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/index.js +3 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- 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/index.js +9 -1
- package/dist/esm/mixins/OxyServices.assets.js +16 -1
- 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/index.js +3 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- 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 +6 -2
- 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/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/ssoReturn.d.ts +1 -1
- package/package.json +2 -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__/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/index.ts +27 -2
- package/src/mixins/OxyServices.assets.ts +16 -1
- 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/__tests__/OxyServices.identity.test.ts +364 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
- 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__/ssoReturn.test.ts +1 -1
- 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;
|
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
|
|
@@ -438,7 +438,9 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
438
438
|
return urlRes?.url || null;
|
|
439
439
|
}
|
|
440
440
|
async fetchAssetContent(url, type) {
|
|
441
|
-
const response = await fetch(url, {
|
|
441
|
+
const response = await fetch(url, {
|
|
442
|
+
credentials: shouldSendAssetCredentials(url, this.getBaseURL()) ? 'include' : 'omit',
|
|
443
|
+
});
|
|
442
444
|
if (!response?.ok) {
|
|
443
445
|
throw new Error(`Failed to fetch asset content (status ${response?.status})`);
|
|
444
446
|
}
|
|
@@ -446,3 +448,16 @@ export function OxyServicesAssetsMixin(Base) {
|
|
|
446
448
|
}
|
|
447
449
|
};
|
|
448
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
|
+
}
|