@oxyhq/core 3.10.1 → 3.12.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.
Files changed (92) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +9 -2
  3. package/dist/cjs/HttpService.js +27 -9
  4. package/dist/cjs/OxyServices.base.js +3 -2
  5. package/dist/cjs/crypto/canonicalJson.js +107 -0
  6. package/dist/cjs/crypto/keyManager.js +67 -8
  7. package/dist/cjs/crypto/signatureService.js +189 -0
  8. package/dist/cjs/index.js +30 -4
  9. package/dist/cjs/mixins/OxyServices.assets.js +16 -1
  10. package/dist/cjs/mixins/OxyServices.auth.js +190 -1
  11. package/dist/cjs/mixins/OxyServices.civic.js +611 -0
  12. package/dist/cjs/mixins/OxyServices.identity.js +291 -0
  13. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  14. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  15. package/dist/cjs/mixins/index.js +6 -0
  16. package/dist/cjs/server/cors.js +20 -21
  17. package/dist/cjs/server/rateLimit.js +32 -8
  18. package/dist/cjs/utils/profileLinks.js +52 -0
  19. package/dist/cjs/utils/ssoReturn.js +1 -1
  20. package/dist/esm/.tsbuildinfo +1 -1
  21. package/dist/esm/AuthManager.js +9 -2
  22. package/dist/esm/HttpService.js +27 -9
  23. package/dist/esm/OxyServices.base.js +3 -2
  24. package/dist/esm/crypto/canonicalJson.js +104 -0
  25. package/dist/esm/crypto/keyManager.js +67 -8
  26. package/dist/esm/crypto/signatureService.js +187 -0
  27. package/dist/esm/index.js +19 -1
  28. package/dist/esm/mixins/OxyServices.assets.js +16 -1
  29. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  30. package/dist/esm/mixins/OxyServices.civic.js +605 -0
  31. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  32. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  33. package/dist/esm/mixins/OxyServices.user.js +1 -0
  34. package/dist/esm/mixins/index.js +6 -0
  35. package/dist/esm/server/cors.js +20 -21
  36. package/dist/esm/server/rateLimit.js +32 -8
  37. package/dist/esm/utils/profileLinks.js +49 -0
  38. package/dist/esm/utils/ssoReturn.js +1 -1
  39. package/dist/types/.tsbuildinfo +1 -1
  40. package/dist/types/HttpService.d.ts +3 -0
  41. package/dist/types/OxyServices.d.ts +2 -2
  42. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  43. package/dist/types/crypto/keyManager.d.ts +7 -0
  44. package/dist/types/crypto/signatureService.d.ts +112 -0
  45. package/dist/types/index.d.ts +10 -2
  46. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  47. package/dist/types/mixins/OxyServices.civic.d.ts +512 -0
  48. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  49. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  50. package/dist/types/mixins/index.d.ts +3 -1
  51. package/dist/types/models/interfaces.d.ts +3 -0
  52. package/dist/types/server/cors.d.ts +5 -5
  53. package/dist/types/utils/profileLinks.d.ts +36 -0
  54. package/dist/types/utils/ssoReturn.d.ts +1 -1
  55. package/package.json +2 -2
  56. package/src/AuthManager.ts +8 -2
  57. package/src/HttpService.ts +36 -8
  58. package/src/OxyServices.base.ts +3 -2
  59. package/src/OxyServices.ts +1 -1
  60. package/src/__tests__/authManager.security.test.ts +31 -0
  61. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  62. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  63. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  64. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  65. package/src/crypto/__tests__/signedRecord.test.ts +345 -0
  66. package/src/crypto/canonicalJson.ts +120 -0
  67. package/src/crypto/keyManager.ts +62 -12
  68. package/src/crypto/signatureService.ts +225 -0
  69. package/src/index.ts +55 -2
  70. package/src/mixins/OxyServices.assets.ts +16 -1
  71. package/src/mixins/OxyServices.auth.ts +309 -1
  72. package/src/mixins/OxyServices.civic.ts +956 -0
  73. package/src/mixins/OxyServices.identity.ts +445 -0
  74. package/src/mixins/OxyServices.sso.ts +30 -1
  75. package/src/mixins/OxyServices.user.ts +1 -0
  76. package/src/mixins/__tests__/OxyServices.civic.test.ts +1097 -0
  77. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  78. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  79. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  80. package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
  81. package/src/mixins/__tests__/sso.test.ts +31 -0
  82. package/src/mixins/index.ts +8 -0
  83. package/src/models/interfaces.ts +3 -0
  84. package/src/server/__tests__/cors.test.ts +5 -1
  85. package/src/server/__tests__/rateLimit.test.ts +116 -0
  86. package/src/server/cors.ts +25 -20
  87. package/src/server/rateLimit.ts +39 -8
  88. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  89. package/src/utils/__tests__/profileLinks.test.ts +126 -0
  90. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  91. package/src/utils/profileLinks.ts +74 -0
  92. package/src/utils/ssoReturn.ts +2 -2
@@ -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).