@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OxyServicesAssetsMixin = OxyServicesAssetsMixin;
|
|
4
|
+
const platform_1 = require("../utils/platform");
|
|
4
5
|
function OxyServicesAssetsMixin(Base) {
|
|
5
6
|
return class extends Base {
|
|
6
7
|
constructor(...args) {
|
|
@@ -43,8 +44,8 @@ function OxyServicesAssetsMixin(Base) {
|
|
|
43
44
|
*
|
|
44
45
|
* For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
|
|
45
46
|
*/
|
|
46
|
-
getFileDownloadUrl(fileId, variant, expiresIn) {
|
|
47
|
-
const token = this.getClient().getAccessToken();
|
|
47
|
+
getFileDownloadUrl(fileId, variant, expiresIn, options = {}) {
|
|
48
|
+
const token = options.omitToken ? undefined : this.getClient().getAccessToken();
|
|
48
49
|
// Public case: no auth token and no expiry requested → clean CDN URL.
|
|
49
50
|
// CloudFront serves the public media origin under `${cloudURL}/<id>`.
|
|
50
51
|
if (!token && !expiresIn) {
|
|
@@ -192,10 +193,32 @@ function OxyServicesAssetsMixin(Base) {
|
|
|
192
193
|
formData.append('file', file, fileName);
|
|
193
194
|
}
|
|
194
195
|
else if ('uri' in file && typeof file.uri === 'string') {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
196
|
+
const descriptor = file;
|
|
197
|
+
if ((0, platform_1.isReactNative)()) {
|
|
198
|
+
// React Native file descriptor — RN's FormData handles {uri, type, name} natively.
|
|
199
|
+
// It reads the file from disk during the multipart request — no in-JS Blob
|
|
200
|
+
// conversion (which would fail on Hermes for ArrayBuffer-backed Blobs).
|
|
201
|
+
formData.append('file', descriptor, fileName);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
// Web (browser/Node): the browser's FormData cannot read bytes from a plain
|
|
205
|
+
// { uri } object — it would serialize "[object Object]" and the server would
|
|
206
|
+
// store a 0-byte asset. Materialize the uri into a real Blob first. `fetch`
|
|
207
|
+
// resolves blob:, data:, and http(s): uris on web, so all picker outputs work.
|
|
208
|
+
const res = await fetch(descriptor.uri);
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
throw new Error(`Failed to read file from uri (status ${res.status})`);
|
|
211
|
+
}
|
|
212
|
+
const fetched = await res.blob();
|
|
213
|
+
// Preserve the descriptor's declared MIME type when the fetched blob has none.
|
|
214
|
+
const blob = fetched.type === '' && descriptor.type
|
|
215
|
+
? new Blob([fetched], { type: descriptor.type })
|
|
216
|
+
: fetched;
|
|
217
|
+
if (blob.size === 0) {
|
|
218
|
+
throw new Error('Cannot upload an empty file');
|
|
219
|
+
}
|
|
220
|
+
formData.append('file', blob, fileName);
|
|
221
|
+
}
|
|
199
222
|
}
|
|
200
223
|
else {
|
|
201
224
|
throw new Error('Unsupported file input: expected File, Blob, or { uri, type?, name?, size? } descriptor');
|
|
@@ -418,7 +441,9 @@ function OxyServicesAssetsMixin(Base) {
|
|
|
418
441
|
return urlRes?.url || null;
|
|
419
442
|
}
|
|
420
443
|
async fetchAssetContent(url, type) {
|
|
421
|
-
const response = await fetch(url, {
|
|
444
|
+
const response = await fetch(url, {
|
|
445
|
+
credentials: shouldSendAssetCredentials(url, this.getBaseURL()) ? 'include' : 'omit',
|
|
446
|
+
});
|
|
422
447
|
if (!response?.ok) {
|
|
423
448
|
throw new Error(`Failed to fetch asset content (status ${response?.status})`);
|
|
424
449
|
}
|
|
@@ -426,3 +451,16 @@ function OxyServicesAssetsMixin(Base) {
|
|
|
426
451
|
}
|
|
427
452
|
};
|
|
428
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Only send ambient credentials (cookies) when the asset URL is same-origin with
|
|
456
|
+
* the configured API base. Caller-supplied cross-origin asset URLs must not leak
|
|
457
|
+
* the user's cookies to arbitrary hosts.
|
|
458
|
+
*/
|
|
459
|
+
function shouldSendAssetCredentials(url, baseURL) {
|
|
460
|
+
try {
|
|
461
|
+
return new URL(url).origin === new URL(baseURL).origin;
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
@@ -3,9 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ServiceCredentialMismatchError = void 0;
|
|
4
4
|
exports.OxyServicesAuthMixin = OxyServicesAuthMixin;
|
|
5
5
|
const OxyServices_errors_1 = require("../OxyServices.errors");
|
|
6
|
+
const keyManager_1 = require("../crypto/keyManager");
|
|
7
|
+
const signatureService_1 = require("../crypto/signatureService");
|
|
6
8
|
const platformCrypto_1 = require("../utils/platformCrypto");
|
|
7
9
|
const loggerUtils_1 = require("../utils/loggerUtils");
|
|
8
10
|
const userIdentity_1 = require("../utils/userIdentity");
|
|
11
|
+
/**
|
|
12
|
+
* Default lifetime of a "Sign in with Oxy" device-flow session / authorize code.
|
|
13
|
+
* Matches the authorize-code TTL the server enforces (5 minutes). The server's
|
|
14
|
+
* returned `expiresAt` is authoritative; this is only the client-proposed value.
|
|
15
|
+
*/
|
|
16
|
+
const COMMONS_SIGN_IN_EXPIRY_MS = 5 * 60 * 1000;
|
|
9
17
|
/**
|
|
10
18
|
* Sentinel error raised when getServiceToken() is called with a known apiKey
|
|
11
19
|
* but a non-matching secret. Indicates either credential drift in the caller
|
|
@@ -148,11 +156,25 @@ function OxyServicesAuthMixin(Base) {
|
|
|
148
156
|
try {
|
|
149
157
|
return await pending;
|
|
150
158
|
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
// Do not retain unauthenticated cache entries. If the initial
|
|
161
|
+
// /auth/service-token request fails (for example, wrong apiSecret),
|
|
162
|
+
// leaving the pre-seeded empty entry would cause later calls with the
|
|
163
|
+
// real secret for the same apiKey to fail locally as a credential
|
|
164
|
+
// mismatch without ever contacting the server. Keep previously-issued
|
|
165
|
+
// stale tokens on refresh failures, but remove never-authenticated
|
|
166
|
+
// entries.
|
|
167
|
+
const failed = this._serviceTokenCache.get(cacheKey);
|
|
168
|
+
if (failed?.pending === pending && !failed.token) {
|
|
169
|
+
this._serviceTokenCache.delete(cacheKey);
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
151
173
|
finally {
|
|
152
174
|
// Clear the in-flight slot; the entry itself (with fresh token / expiry)
|
|
153
175
|
// is updated inside _doFetchServiceToken before we land here.
|
|
154
176
|
const settled = this._serviceTokenCache.get(cacheKey);
|
|
155
|
-
if (settled) {
|
|
177
|
+
if (settled?.pending === pending) {
|
|
156
178
|
settled.pending = null;
|
|
157
179
|
}
|
|
158
180
|
}
|
|
@@ -418,6 +440,173 @@ function OxyServicesAuthMixin(Base) {
|
|
|
418
440
|
throw this.handleError(error);
|
|
419
441
|
}
|
|
420
442
|
}
|
|
443
|
+
// =======================================================================
|
|
444
|
+
// "Sign in with Oxy" — handoff (Workstream C)
|
|
445
|
+
//
|
|
446
|
+
// Two mechanisms share the same challenge/verify + device-flow primitives:
|
|
447
|
+
// A. Same-device shared-keychain SSO (`signInWithSharedIdentity`): a
|
|
448
|
+
// sibling native app silently mints its own session from the shared
|
|
449
|
+
// identity key. No user interaction.
|
|
450
|
+
// B. QR / app-to-app handoff: a relying party (`startCommonsSignIn` +
|
|
451
|
+
// `pollCommonsSignIn` + the existing `claimSessionByToken`) and the
|
|
452
|
+
// approver / Commons (`getCommonsApprovalInfo` + `approveCommonsSignIn`
|
|
453
|
+
// / `denyCommonsSignIn`). The approver signs with its PRIMARY local
|
|
454
|
+
// key; the RP never sees the private key.
|
|
455
|
+
// =======================================================================
|
|
456
|
+
/**
|
|
457
|
+
* MECHANISM A — same-device shared-keychain SSO.
|
|
458
|
+
*
|
|
459
|
+
* Native-only. If this device holds a shared identity (the cross-app
|
|
460
|
+
* `group.so.oxy.shared` keychain key), prove control of it and mint a
|
|
461
|
+
* session: `requestChallenge(sharedPublicKey)` → `signChallengeWithSharedKey`
|
|
462
|
+
* → `verifyChallenge` (which plants the tokens). Returns `null` on web or
|
|
463
|
+
* when no shared identity is present — never throws for the absent-identity
|
|
464
|
+
* case, so a cold-boot caller can fall through to the next step.
|
|
465
|
+
*
|
|
466
|
+
* The cold-boot wiring that CALLS this lives in `OxyContext`
|
|
467
|
+
* (`@oxyhq/services`); this method just performs the exchange.
|
|
468
|
+
*/
|
|
469
|
+
async signInWithSharedIdentity(opts = {}) {
|
|
470
|
+
try {
|
|
471
|
+
// `hasSharedIdentity()` already returns false on web (the shared
|
|
472
|
+
// keychain is native-only), so this short-circuits the web case without
|
|
473
|
+
// a wasted challenge round-trip.
|
|
474
|
+
if (!(await keyManager_1.KeyManager.hasSharedIdentity())) {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
const sharedPublicKey = await keyManager_1.KeyManager.getSharedPublicKey();
|
|
478
|
+
if (!sharedPublicKey) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
const { challenge } = await this.requestChallenge(sharedPublicKey);
|
|
482
|
+
const signed = await signatureService_1.SignatureService.signChallengeWithSharedKey(challenge);
|
|
483
|
+
// `signed.challenge` carries the SIGNATURE (mirrors `signChallenge`).
|
|
484
|
+
return await this.verifyChallenge(signed.publicKey, challenge, signed.challenge, signed.timestamp, opts.deviceName, opts.deviceFingerprint);
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
throw this.handleError(error);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* MECHANISM B (relying party) — begin a "Sign in with Oxy" handoff.
|
|
492
|
+
*
|
|
493
|
+
* Generates a secret device-flow `sessionToken` client-side (it never
|
|
494
|
+
* appears in the QR), registers it with `POST /auth/session/create`, and
|
|
495
|
+
* returns the server-issued public `authorizeCode` + ready-to-render
|
|
496
|
+
* `qrPayload`. Render the QR (web) / open the deep-link (same-device); the
|
|
497
|
+
* approver resolves the code and authorizes. Then poll with
|
|
498
|
+
* {@link pollCommonsSignIn} and, on `authorized`, exchange the
|
|
499
|
+
* `sessionToken` via the existing `claimSessionByToken`.
|
|
500
|
+
*
|
|
501
|
+
* @param params.clientId - The RP's registered OAuth client id
|
|
502
|
+
* (ApplicationCredential publicKey); required so the server can resolve the
|
|
503
|
+
* requesting application's identity.
|
|
504
|
+
*/
|
|
505
|
+
async startCommonsSignIn(params) {
|
|
506
|
+
try {
|
|
507
|
+
// High-entropy opaque secret token (256-bit hex). Generated client-side
|
|
508
|
+
// and held only here; the server stores it but never returns it in the
|
|
509
|
+
// QR. Reuses the platform-safe random generator.
|
|
510
|
+
const sessionToken = await signatureService_1.SignatureService.generateChallenge();
|
|
511
|
+
const expiresAt = Date.now() + COMMONS_SIGN_IN_EXPIRY_MS;
|
|
512
|
+
const res = await this.makeRequest('POST', '/auth/session/create', { sessionToken, expiresAt, clientId: params.clientId }, { cache: false });
|
|
513
|
+
return {
|
|
514
|
+
sessionToken,
|
|
515
|
+
authorizeCode: res.authorizeCode,
|
|
516
|
+
qrPayload: res.qrPayload,
|
|
517
|
+
expiresAt: res.expiresAt ?? expiresAt,
|
|
518
|
+
status: res.status,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
throw this.handleError(error);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* MECHANISM B (relying party) — poll a device-flow session for approval.
|
|
527
|
+
*
|
|
528
|
+
* Backstop for the auth socket. On `authorized` (with a `sessionId`), the
|
|
529
|
+
* caller exchanges the secret `sessionToken` via the existing
|
|
530
|
+
* `claimSessionByToken` to mint the first access token.
|
|
531
|
+
*
|
|
532
|
+
* @param sessionToken - The secret token from {@link startCommonsSignIn}.
|
|
533
|
+
*/
|
|
534
|
+
async pollCommonsSignIn(sessionToken) {
|
|
535
|
+
try {
|
|
536
|
+
return await this.makeRequest('GET', `/auth/session/status/${encodeURIComponent(sessionToken)}`, undefined, { cache: false, retry: false });
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
throw this.handleError(error);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* MECHANISM B (approver / Commons) — resolve the TRUSTED identity of a
|
|
544
|
+
* sign-in request from its public `authorizeCode`.
|
|
545
|
+
*
|
|
546
|
+
* The returned `application` is resolved server-side and is the only safe
|
|
547
|
+
* thing to display in the approval UI — NEVER trust the app/name/origin
|
|
548
|
+
* strings carried in the QR payload. Public (no auth required).
|
|
549
|
+
*
|
|
550
|
+
* @param authorizeCode - The public code scanned from the QR / deep-link.
|
|
551
|
+
*/
|
|
552
|
+
async getCommonsApprovalInfo(authorizeCode) {
|
|
553
|
+
try {
|
|
554
|
+
return await this.makeRequest('GET', `/auth/session/approve-info/${encodeURIComponent(authorizeCode)}`, undefined, { cache: false });
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
throw this.handleError(error);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* MECHANISM B (approver / Commons) — approve a sign-in request by signing a
|
|
562
|
+
* fresh challenge with the PRIMARY local identity key.
|
|
563
|
+
*
|
|
564
|
+
* Commons holds the user's identity as its primary key (not the shared
|
|
565
|
+
* key), so this uses `signChallenge`. The signed-but-cookieless authorize
|
|
566
|
+
* endpoint resolves the user from the verified signer — the RP that started
|
|
567
|
+
* the flow then claims its session. Native-only (requires a local identity).
|
|
568
|
+
*
|
|
569
|
+
* @param params.authorizeCode - The public code being approved.
|
|
570
|
+
* @param params.deviceName - Optional human-readable device label.
|
|
571
|
+
* @param params.deviceFingerprint - Optional device fingerprint.
|
|
572
|
+
*/
|
|
573
|
+
async approveCommonsSignIn(params) {
|
|
574
|
+
try {
|
|
575
|
+
const publicKey = await keyManager_1.KeyManager.getPublicKey();
|
|
576
|
+
if (!publicKey) {
|
|
577
|
+
throw new Error('No identity found on this device. Create or import an identity first.');
|
|
578
|
+
}
|
|
579
|
+
const { challenge } = await this.requestChallenge(publicKey);
|
|
580
|
+
const signed = await signatureService_1.SignatureService.signChallenge(challenge);
|
|
581
|
+
return await this.makeRequest('POST', `/auth/session/authorize-signed/${encodeURIComponent(params.authorizeCode)}`, {
|
|
582
|
+
// `signed.challenge` carries the SIGNATURE; `challenge` is the
|
|
583
|
+
// original server-issued challenge string.
|
|
584
|
+
publicKey: signed.publicKey,
|
|
585
|
+
challenge,
|
|
586
|
+
signature: signed.challenge,
|
|
587
|
+
timestamp: signed.timestamp,
|
|
588
|
+
...(params.deviceName ? { deviceName: params.deviceName } : {}),
|
|
589
|
+
...(params.deviceFingerprint ? { deviceFingerprint: params.deviceFingerprint } : {}),
|
|
590
|
+
}, { cache: false });
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
throw this.handleError(error);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* MECHANISM B (approver / Commons) — deny a sign-in request, cancelling the
|
|
598
|
+
* device-flow session so the RP stops waiting.
|
|
599
|
+
*
|
|
600
|
+
* @param authorizeCode - The public code being denied.
|
|
601
|
+
*/
|
|
602
|
+
async denyCommonsSignIn(authorizeCode) {
|
|
603
|
+
try {
|
|
604
|
+
return await this.makeRequest('POST', `/auth/session/deny/${encodeURIComponent(authorizeCode)}`, undefined, { cache: false });
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
throw this.handleError(error);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
421
610
|
/**
|
|
422
611
|
* Refresh every device-local refresh-cookie slot in a single round trip
|
|
423
612
|
* (Google-style multi-account rebuild).
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildUserDid = buildUserDid;
|
|
4
|
+
exports.OxyServicesIdentityMixin = OxyServicesIdentityMixin;
|
|
5
|
+
const keyManager_1 = require("../crypto/keyManager");
|
|
6
|
+
const signatureService_1 = require("../crypto/signatureService");
|
|
7
|
+
const mixinHelpers_1 = require("./mixinHelpers");
|
|
8
|
+
/**
|
|
9
|
+
* Registrable apex the Oxy DID method is anchored on. A user's DID is
|
|
10
|
+
* `did:web:<OXY_IDENTITY_APEX>:u:<userId>`, anchored on the stable account id
|
|
11
|
+
* (NOT the keypair).
|
|
12
|
+
*/
|
|
13
|
+
const OXY_IDENTITY_APEX = 'oxy.so';
|
|
14
|
+
/**
|
|
15
|
+
* Derive a user's Oxy DID from their stable account id.
|
|
16
|
+
* `did:web:oxy.so:u:<userId>`.
|
|
17
|
+
*/
|
|
18
|
+
function buildUserDid(userId) {
|
|
19
|
+
return `did:web:${OXY_IDENTITY_APEX}:u:${userId}`;
|
|
20
|
+
}
|
|
21
|
+
function OxyServicesIdentityMixin(Base) {
|
|
22
|
+
return class extends Base {
|
|
23
|
+
constructor(...args) {
|
|
24
|
+
super(...args);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the W3C DID document for any user. The API derives it on demand
|
|
28
|
+
* from the account's `authMethods` + `publicKey` — there is no stored
|
|
29
|
+
* document. Public (no auth required); short-TTL cached.
|
|
30
|
+
*
|
|
31
|
+
* @param userId - The account's Mongo `_id`. URL-encoded into the path.
|
|
32
|
+
*/
|
|
33
|
+
async resolveDid(userId) {
|
|
34
|
+
try {
|
|
35
|
+
return await this.makeRequest('GET', `/u/${encodeURIComponent(userId)}/did.json`, undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw this.handleError(error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The current user's DID (`did:web:oxy.so:u:<userId>`), derived locally from
|
|
43
|
+
* the access token's user id. Throws if no user is authenticated.
|
|
44
|
+
*/
|
|
45
|
+
getMyDid() {
|
|
46
|
+
const userId = this.getCurrentUserId();
|
|
47
|
+
if (!userId) {
|
|
48
|
+
throw new Error('No authenticated user — cannot derive DID.');
|
|
49
|
+
}
|
|
50
|
+
return buildUserDid(userId);
|
|
51
|
+
}
|
|
52
|
+
/** Resolve the current user's DID document. Requires an authenticated session. */
|
|
53
|
+
async getMyDidDocument() {
|
|
54
|
+
const userId = this.getCurrentUserId();
|
|
55
|
+
if (!userId) {
|
|
56
|
+
throw new Error('No authenticated user — cannot resolve DID document.');
|
|
57
|
+
}
|
|
58
|
+
return this.resolveDid(userId);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* List the current user's linked authentication methods plus their DID.
|
|
62
|
+
* Each `identity` method carries a `verificationMethodId` linking it to its
|
|
63
|
+
* DID verification-method fragment.
|
|
64
|
+
*/
|
|
65
|
+
async listAuthMethods() {
|
|
66
|
+
try {
|
|
67
|
+
return await this.makeRequest('GET', '/auth/methods', undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw this.handleError(error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Link the on-device cryptographic identity to the current account,
|
|
75
|
+
* upgrading it from custodial to self-sovereign. Signs a proof of private
|
|
76
|
+
* key ownership and posts it to `POST /auth/link`.
|
|
77
|
+
*
|
|
78
|
+
* NATIVE-ONLY: requires a stored identity (throws if `KeyManager` has no key
|
|
79
|
+
* or no user is authenticated). The signed payload is
|
|
80
|
+
* `JSON.stringify({ action: 'link_identity', userId, timestamp })` — the
|
|
81
|
+
* exact bytes the server reconstructs and verifies.
|
|
82
|
+
*/
|
|
83
|
+
async linkIdentityKey() {
|
|
84
|
+
try {
|
|
85
|
+
const userId = this.getCurrentUserId();
|
|
86
|
+
if (!userId) {
|
|
87
|
+
throw new Error('No authenticated user — sign in before linking an identity key.');
|
|
88
|
+
}
|
|
89
|
+
const publicKey = await keyManager_1.KeyManager.getPublicKey();
|
|
90
|
+
if (!publicKey) {
|
|
91
|
+
throw new Error('No identity found on this device. Create or import an identity first.');
|
|
92
|
+
}
|
|
93
|
+
const timestamp = Date.now();
|
|
94
|
+
// The signed message MUST match the server's reconstruction byte-for-byte:
|
|
95
|
+
// JSON.stringify with this exact key order (action, userId, timestamp).
|
|
96
|
+
const message = JSON.stringify({ action: 'link_identity', userId, timestamp });
|
|
97
|
+
const signature = await signatureService_1.SignatureService.sign(message);
|
|
98
|
+
const result = await this.makeRequest('POST', '/auth/link', { type: 'identity', publicKey, signature, timestamp }, { cache: false });
|
|
99
|
+
this._invalidateIdentityCaches(userId);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
throw this.handleError(error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Link password authentication to the current account. Adds a `password`
|
|
108
|
+
* auth method (does not remove existing methods).
|
|
109
|
+
*
|
|
110
|
+
* @param email - The email to associate with password auth.
|
|
111
|
+
* @param password - The new password (server enforces strength rules).
|
|
112
|
+
*/
|
|
113
|
+
async linkPassword(email, password) {
|
|
114
|
+
try {
|
|
115
|
+
const result = await this.makeRequest('POST', '/auth/link', { type: 'password', email, password }, { cache: false });
|
|
116
|
+
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
throw this.handleError(error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Unlink an authentication method from the current account. The server
|
|
125
|
+
* refuses to remove the last remaining method (the account would become
|
|
126
|
+
* inaccessible). Unlinking `identity` downgrades the account to custodial.
|
|
127
|
+
*
|
|
128
|
+
* @param type - The auth-method type to remove.
|
|
129
|
+
*/
|
|
130
|
+
async unlinkAuthMethod(type) {
|
|
131
|
+
try {
|
|
132
|
+
const result = await this.makeRequest('DELETE', `/auth/link/${encodeURIComponent(type)}`, undefined, { cache: false });
|
|
133
|
+
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
throw this.handleError(error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Sign a record with the on-device identity key, WITHOUT publishing it.
|
|
142
|
+
* The subject is the current user's DID. NATIVE-ONLY (requires a stored
|
|
143
|
+
* key). Use {@link publishRecord} to sign and store in one step.
|
|
144
|
+
*
|
|
145
|
+
* @param type - The record category.
|
|
146
|
+
* @param record - The arbitrary record payload to attest to.
|
|
147
|
+
*/
|
|
148
|
+
async signRecord(type, record) {
|
|
149
|
+
const subject = this.getMyDid();
|
|
150
|
+
return signatureService_1.SignatureService.signRecord(type, subject, record);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Sign a record and publish it to the append-only record store
|
|
154
|
+
* (`POST /identity/records`). NATIVE-ONLY (requires a stored key).
|
|
155
|
+
*
|
|
156
|
+
* @param type - The record category.
|
|
157
|
+
* @param record - The arbitrary record payload to attest to.
|
|
158
|
+
*/
|
|
159
|
+
async publishRecord(type, record) {
|
|
160
|
+
try {
|
|
161
|
+
const envelope = await this.signRecord(type, record);
|
|
162
|
+
return await this.makeRequest('POST', '/identity/records', envelope, { cache: false });
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
throw this.handleError(error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Fetch a user's most recent signed record of a given type. Public (no auth
|
|
170
|
+
* required); short-TTL cached.
|
|
171
|
+
*
|
|
172
|
+
* @param userId - The subject account's Mongo `_id`.
|
|
173
|
+
* @param type - The record category to fetch.
|
|
174
|
+
*/
|
|
175
|
+
async getRecord(userId, type) {
|
|
176
|
+
try {
|
|
177
|
+
const res = await this.makeRequest('GET', `/identity/records/${encodeURIComponent(userId)}/${encodeURIComponent(type)}`, undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
|
|
178
|
+
return res.record;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
throw this.handleError(error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Ask the server to verify a user's stored record: it recomputes the
|
|
186
|
+
* canonical signing input, checks the signature, and asserts the signing key
|
|
187
|
+
* is a current verification method on the subject's DID.
|
|
188
|
+
*
|
|
189
|
+
* @param userId - The subject account's Mongo `_id`.
|
|
190
|
+
* @param type - The record category to verify.
|
|
191
|
+
*/
|
|
192
|
+
async verifyRecord(userId, type) {
|
|
193
|
+
try {
|
|
194
|
+
return await this.makeRequest('GET', `/identity/records/${encodeURIComponent(userId)}/${encodeURIComponent(type)}/verify`, undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
throw this.handleError(error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Download the current user's signed, open-format data-export bundle
|
|
202
|
+
* (`GET /users/me/export`) — the "credible exit" snapshot. Always carries an
|
|
203
|
+
* Oxy provenance `attestation`; carries an optional client `proof` when the
|
|
204
|
+
* account holds its own key.
|
|
205
|
+
*/
|
|
206
|
+
async exportMyData() {
|
|
207
|
+
try {
|
|
208
|
+
return await this.makeRequest('GET', '/users/me/export', undefined, { cache: false });
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
throw this.handleError(error);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Start verifying ownership of a domain. Returns the instructions: publish
|
|
216
|
+
* EITHER the DNS-TXT record OR the `/.well-known/oxy-domain` file, then call
|
|
217
|
+
* {@link verifyDomain}.
|
|
218
|
+
*
|
|
219
|
+
* @param domain - The domain to claim (e.g. `nate.com`).
|
|
220
|
+
*/
|
|
221
|
+
async requestDomainVerification(domain) {
|
|
222
|
+
try {
|
|
223
|
+
return await this.makeRequest('POST', '/identity/domains', { domain }, { cache: false });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
throw this.handleError(error);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Complete domain verification: the server checks the DNS-TXT record or
|
|
231
|
+
* well-known file and, on success, attaches the domain to the account
|
|
232
|
+
* (surfaced in the DID's `alsoKnownAs` and the user's `verifiedDomains`).
|
|
233
|
+
*
|
|
234
|
+
* @param domain - The domain previously requested via
|
|
235
|
+
* {@link requestDomainVerification}.
|
|
236
|
+
*/
|
|
237
|
+
async verifyDomain(domain) {
|
|
238
|
+
try {
|
|
239
|
+
const result = await this.makeRequest('POST', `/identity/domains/${encodeURIComponent(domain)}/verify`, undefined, { cache: false });
|
|
240
|
+
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
throw this.handleError(error);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** List the current user's verified domains. */
|
|
248
|
+
async listDomains() {
|
|
249
|
+
try {
|
|
250
|
+
const res = await this.makeRequest('GET', '/identity/domains', undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
|
|
251
|
+
return res.domains ?? [];
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
throw this.handleError(error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Remove a verified domain from the current account.
|
|
259
|
+
* @param domain - The verified domain to remove.
|
|
260
|
+
*/
|
|
261
|
+
async removeDomain(domain) {
|
|
262
|
+
try {
|
|
263
|
+
const result = await this.makeRequest('DELETE', `/identity/domains/${encodeURIComponent(domain)}`, undefined, { cache: false });
|
|
264
|
+
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
throw this.handleError(error);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Bust the cached reads that an identity mutation invalidates: the current
|
|
273
|
+
* user (`/users/me*`), the linked auth-methods list, the verified-domains
|
|
274
|
+
* list, and the user's derived DID document (which embeds auth methods +
|
|
275
|
+
* verified domains, so it goes stale on link/unlink/domain changes).
|
|
276
|
+
*
|
|
277
|
+
* Internal helper (leading underscore); not part of the supported public
|
|
278
|
+
* surface. Public rather than `private` because mixins compose into an
|
|
279
|
+
* exported anonymous class, where TypeScript cannot represent a private
|
|
280
|
+
* member in the emitted declaration file (TS4094).
|
|
281
|
+
*/
|
|
282
|
+
_invalidateIdentityCaches(userId) {
|
|
283
|
+
this.clearCacheByPrefix('GET:/users/me');
|
|
284
|
+
this.clearCacheEntry('GET:/auth/methods');
|
|
285
|
+
this.clearCacheEntry('GET:/identity/domains');
|
|
286
|
+
if (userId) {
|
|
287
|
+
this.clearCacheEntry(`GET:/u/${encodeURIComponent(userId)}/did.json`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
@@ -27,6 +27,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
27
27
|
exports.generateSsoState = generateSsoState;
|
|
28
28
|
exports.OxyServicesSsoMixin = OxyServicesSsoMixin;
|
|
29
29
|
const debugUtils_1 = require("../shared/utils/debugUtils");
|
|
30
|
+
const ssoBounce_1 = require("../utils/ssoBounce");
|
|
30
31
|
const debug = (0, debugUtils_1.createDebugLogger)('SSO');
|
|
31
32
|
/**
|
|
32
33
|
* Generate a cryptographically secure state value for the SSO bounce.
|
|
@@ -46,6 +47,25 @@ function generateSsoState() {
|
|
|
46
47
|
}
|
|
47
48
|
throw new Error('No secure random source available for SSO state generation');
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Read the SSO bounce state stored for the current browser origin, if any.
|
|
52
|
+
*
|
|
53
|
+
* Returns `null` outside a browser (no `window`/`sessionStorage`) or when no
|
|
54
|
+
* state is stored — in which case the caller cannot (and must not) enforce a
|
|
55
|
+
* state match, e.g. native flows or pre-hydration callbacks that already
|
|
56
|
+
* validated the state before this exchange.
|
|
57
|
+
*/
|
|
58
|
+
function getStoredSsoStateForCurrentOrigin() {
|
|
59
|
+
if (typeof window === 'undefined' || !window.location || !window.sessionStorage) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return window.sessionStorage.getItem((0, ssoBounce_1.ssoStateKey)(window.location.origin));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
49
69
|
function OxyServicesSsoMixin(Base) {
|
|
50
70
|
return class extends Base {
|
|
51
71
|
constructor(...args) {
|
|
@@ -72,12 +92,19 @@ function OxyServicesSsoMixin(Base) {
|
|
|
72
92
|
* @param code - The opaque single-use code delivered in the SSO return
|
|
73
93
|
* fragment (see {@link parseSsoReturnFragment}). The central store burns
|
|
74
94
|
* it atomically on exchange.
|
|
95
|
+
* @param state - The state value returned alongside the code. In browsers,
|
|
96
|
+
* when an SSO bounce state is still stored for the current origin, this
|
|
97
|
+
* must match before any token-committing exchange is attempted.
|
|
75
98
|
* @returns The resolved {@link SessionLoginResponse}.
|
|
76
99
|
*/
|
|
77
|
-
async exchangeSsoCode(code) {
|
|
100
|
+
async exchangeSsoCode(code, state) {
|
|
78
101
|
if (typeof code !== 'string' || code.length === 0) {
|
|
79
102
|
throw this.handleError(new Error('exchangeSsoCode requires a non-empty code'));
|
|
80
103
|
}
|
|
104
|
+
const expectedState = getStoredSsoStateForCurrentOrigin();
|
|
105
|
+
if (expectedState !== null && (typeof state !== 'string' || state.length === 0 || state !== expectedState)) {
|
|
106
|
+
throw this.handleError(new Error('SSO exchange state mismatch'));
|
|
107
|
+
}
|
|
81
108
|
const url = `${this.getSessionBaseUrl().replace(/\/$/, '')}/sso/exchange`;
|
|
82
109
|
debug.log('Exchanging SSO code for session...');
|
|
83
110
|
let response;
|