@oxyhq/core 10.1.4 → 10.2.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.
@@ -1,3 +1,5 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { isIPv4, isIPv6 } from 'node:net';
1
3
  import rateLimit from 'express-rate-limit';
2
4
  /**
3
5
  * Built-in exemptions. A media app's cover-art/avatar fan-out and HLS
@@ -15,9 +17,98 @@ function isBuiltInExempt(req) {
15
17
  path === '/health' ||
16
18
  path.endsWith('/health'));
17
19
  }
18
- /** IPv6-safe IP key generator (replaces colons to avoid Redis namespace issues). */
19
- function ipKeyGenerator(ip) {
20
- return ip.replace(/:/g, '_');
20
+ /**
21
+ * Anonymous rate-limit keys must be PRIVACY-PRESERVING: the raw client IP must
22
+ * never reach a store at rest (in-memory or Redis). We therefore HMAC-hash the
23
+ * IP into a short, transient-only bucket key. Two IPv6-specific concerns shape
24
+ * the pre-hash normalization:
25
+ *
26
+ * - IPv6 hosts are typically handed an entire /64 (often a /56), so a single
27
+ * host can rotate through an enormous address space and evade a per-address
28
+ * limit. We bucket IPv6 to its /56 prefix BEFORE hashing.
29
+ * - express-rate-limit only exposes an `ipKeyGenerator` /56 helper from v8
30
+ * onwards; `@oxyhq/core` pins v7 (peer `^7.0.0`), so the masking is
31
+ * implemented here rather than pulling a major-version bump of a
32
+ * security-critical dependency (and its rate-limit-redis compatibility) into
33
+ * an unrelated privacy change. This mirrors `packages/api/src/utils/ipKey.ts`.
34
+ */
35
+ const IPV6_SUBNET_BITS = 56;
36
+ /** Expand an IPv6 literal (handling `::` and embedded IPv4) to 8 numeric hextets, or null if unparseable. */
37
+ function ipv6Hextets(ip) {
38
+ let addr = ip;
39
+ const zone = addr.indexOf('%');
40
+ if (zone !== -1) {
41
+ addr = addr.slice(0, zone);
42
+ }
43
+ // Embedded IPv4 tail (e.g. `::ffff:203.0.113.7`) → fold the dotted quad into two hextets.
44
+ const lastColon = addr.lastIndexOf(':');
45
+ if (lastColon !== -1 && addr.slice(lastColon + 1).includes('.')) {
46
+ const v4 = addr.slice(lastColon + 1);
47
+ if (!isIPv4(v4)) {
48
+ return null;
49
+ }
50
+ const octets = v4.split('.').map((part) => Number.parseInt(part, 10));
51
+ const high = ((octets[0] << 8) | octets[1]).toString(16);
52
+ const low = ((octets[2] << 8) | octets[3]).toString(16);
53
+ addr = `${addr.slice(0, lastColon + 1)}${high}:${low}`;
54
+ }
55
+ const halves = addr.split('::');
56
+ if (halves.length > 2) {
57
+ return null;
58
+ }
59
+ const head = halves[0] ? halves[0].split(':') : [];
60
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
61
+ let groups;
62
+ if (halves.length === 1) {
63
+ groups = head;
64
+ }
65
+ else {
66
+ const missing = 8 - (head.length + tail.length);
67
+ if (missing < 0) {
68
+ return null;
69
+ }
70
+ groups = [...head, ...new Array(missing).fill('0'), ...tail];
71
+ }
72
+ if (groups.length !== 8) {
73
+ return null;
74
+ }
75
+ const hextets = groups.map((group) => Number.parseInt(group || '0', 16));
76
+ if (hextets.some((value) => Number.isNaN(value) || value < 0 || value > 0xffff)) {
77
+ return null;
78
+ }
79
+ return hextets;
80
+ }
81
+ /** Mask an IPv6 address to its /{bits} prefix, returned as a canonical hex string. */
82
+ function maskIPv6(ip, bits) {
83
+ const hextets = ipv6Hextets(ip);
84
+ if (!hextets) {
85
+ return ip;
86
+ }
87
+ const masked = hextets.map((hextet, index) => {
88
+ const groupStart = index * 16;
89
+ if (groupStart >= bits) {
90
+ return 0;
91
+ }
92
+ const keepBits = Math.min(16, bits - groupStart);
93
+ const mask = keepBits >= 16 ? 0xffff : (0xffff << (16 - keepBits)) & 0xffff;
94
+ return hextet & mask;
95
+ });
96
+ return `${masked.map((hextet) => hextet.toString(16)).join(':')}/${bits}`;
97
+ }
98
+ /**
99
+ * Hash a client IP into a privacy-preserving bucket key. IPv6 is bucketed to its
100
+ * /56 prefix first (so a single v6 host can't rotate through its allocation to
101
+ * mint fresh keys), then HMAC'd with the server-side salt. The salt is resolved
102
+ * at CALL time (`IP_HASH_SALT`, else `DEVICE_ID_SALT`, else empty) — an empty
103
+ * salt still hashes, which beats storing a raw IP; backends SHOULD set one of
104
+ * those envs. The `rl|` namespace ensures a rate-limit key can never collide
105
+ * with, or be correlated against, a deviceId derivation that reuses the same
106
+ * salt. The result is a short hex digest with no colons, so it is Redis-safe.
107
+ */
108
+ function hashAnonymousIp(ip) {
109
+ const normalized = isIPv6(ip) && !ip.startsWith('::ffff:') ? maskIPv6(ip, IPV6_SUBNET_BITS) : ip;
110
+ const salt = process.env.IP_HASH_SALT || process.env.DEVICE_ID_SALT || '';
111
+ return createHmac('sha256', salt).update(`rl|${normalized}`).digest('hex').slice(0, 24);
21
112
  }
22
113
  /**
23
114
  * Resolve the trusted authenticated rate-limit key.
@@ -43,14 +134,17 @@ function resolveTrustedAuthenticatedKey(req) {
43
134
  }
44
135
  return null;
45
136
  }
46
- /** Resolve the rate-limit key: per trusted authenticated identity, else per (IPv6-safe) IP. */
137
+ /** Resolve the rate-limit key: per trusted authenticated identity, else per hashed (IPv6-bucketed) IP. */
47
138
  function resolveKey(req) {
48
139
  const authenticatedKey = resolveTrustedAuthenticatedKey(req);
49
140
  if (authenticatedKey) {
50
141
  return authenticatedKey;
51
142
  }
52
- const ip = req.ip || req.socket.remoteAddress || 'unknown';
53
- return ipKeyGenerator(ip);
143
+ const ip = req.ip || req.socket.remoteAddress;
144
+ if (!ip) {
145
+ return 'unknown';
146
+ }
147
+ return hashAnonymousIp(ip);
54
148
  }
55
149
  /**
56
150
  * Build the composed Oxy rate-limit middleware. See module docs for rationale.
@@ -1,4 +1,4 @@
1
- import { deviceSessionStateSchema, deviceSessionSyncSchema, safeParseContract, } from '@oxyhq/contracts';
1
+ import { deviceSessionStateSchema, deviceSessionSyncSchema, safeParseContract, SESSION_ACCOUNTS_CHANGED_EVENT, sessionAccountsChangedEventSchema, } from '@oxyhq/contracts';
2
2
  import { logger } from '../utils/loggerUtils.js';
3
3
  import { getSocketIO } from './socketLoader.js';
4
4
  /**
@@ -308,6 +308,9 @@ export class SessionClient {
308
308
  });
309
309
  }
310
310
  });
311
+ socket.on(SESSION_ACCOUNTS_CHANGED_EVENT, (payload) => {
312
+ this.onSessionAccountsChanged(payload);
313
+ });
311
314
  this.socket = socket;
312
315
  // (Re)bind app-facing server-event subscriptions on the fresh socket.
313
316
  this.boundServerEvents.clear();
@@ -315,6 +318,38 @@ export class SessionClient {
315
318
  this.bindServerEvent(event);
316
319
  }
317
320
  }
321
+ /**
322
+ * Handle the token-free `session_accounts_changed` signal (room `user:<userId>`).
323
+ *
324
+ * Unlike `session_state` (device-scoped, carries the new state to APPLY), this
325
+ * reaches ALL of a user's connected sockets across their devices/origins and is
326
+ * a pure SIGNAL: it carries no token, no secret, and no account bodies. The only
327
+ * trustworthy bit is "something changed for this user", so — matching the
328
+ * `session_state` contract's guidance — we re-fetch our OWN authoritative device
329
+ * state (`bootstrap` → `GET /session/device/state`) and let the existing
330
+ * `applyState` revision guard reconcile it. We never trust any field on the event
331
+ * beyond routing it to the current user.
332
+ *
333
+ * The refetch is a private (bearer) call: the socket only joins `user:<userId>`
334
+ * when authenticated, so a signed-out client never receives this — but we guard
335
+ * the bearer anyway so a race at sign-out can't 401.
336
+ */
337
+ onSessionAccountsChanged(payload) {
338
+ const event = safeParseContract(sessionAccountsChangedEventSchema, payload);
339
+ if (!event) {
340
+ logger.warn('[SessionClient] discarded invalid session_accounts_changed', { component: 'SessionClient' });
341
+ return;
342
+ }
343
+ // The socket is in `user:<activeUserId>` for the planted bearer, so this should
344
+ // always be the current user; ignore a foreign id defensively (out-of-band relay).
345
+ if (event.userId !== this.host.getCurrentAccountId())
346
+ return;
347
+ if (!this.host.getAccessToken())
348
+ return;
349
+ void this.bootstrap().catch((error) => {
350
+ logger.warn('[SessionClient] session_accounts_changed refetch failed', { component: 'SessionClient' }, error);
351
+ });
352
+ }
318
353
  /**
319
354
  * Open the same-origin `BroadcastChannel` (web only). A sibling tab that
320
355
  * commits an account switch / sign-out posts a wake ping; on receipt an
@@ -30,7 +30,15 @@ import { extractErrorStatus } from '../utils/errorUtils.js';
30
30
  import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
31
31
  import { generateOAuthState, generatePkcePair, normalizeOAuthRedirectUri, persistOAuthHandshake, } from '../utils/oauthPkce.js';
32
32
  import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
33
- const DEFAULT_POLL_INTERVAL_MS = 3000;
33
+ /**
34
+ * Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
35
+ * the approval instantly via `auth_update`; this poll only covers the case where
36
+ * the socket can't connect, so it is deliberately slow (was 3000 when polling was
37
+ * the sole mechanism).
38
+ */
39
+ const DEFAULT_POLL_INTERVAL_MS = 12000;
40
+ /** Socket.IO namespace the API emits QR-flow approval (`auth_update`) events on. */
41
+ const AUTH_SESSION_NAMESPACE = '/auth-session';
34
42
  /**
35
43
  * Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
36
44
  * installed Commons on the same device; the `oxycommons://approve?...` deep link
@@ -62,6 +70,18 @@ export class AccountDialogController {
62
70
  /** The secret device-flow token of the active QR flow (never surfaced). */
63
71
  this.signInToken = null;
64
72
  this.pollTimer = null;
73
+ /**
74
+ * The `/auth-session` socket for the active QR flow, or null (poll-only). Its
75
+ * `auth_update` event wakes {@link pollOnce} instantly instead of waiting for the
76
+ * slow fallback timer.
77
+ */
78
+ this.authSessionSocket = null;
79
+ /**
80
+ * Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
81
+ * `auth_update` wake can fire together — without this both could claim the
82
+ * single-use token concurrently.
83
+ */
84
+ this.pollInFlight = false;
65
85
  // --- Store plumbing ---
66
86
  this.unsubscribeSession = null;
67
87
  this.unsubscribeTokens = null;
@@ -80,6 +100,7 @@ export class AccountDialogController {
80
100
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
81
101
  this.openUrl = options.openUrl;
82
102
  this.canOpenApp = options.canOpenApp;
103
+ this.socketFactory = options.socketFactory;
83
104
  this.snapshot = this.computeSnapshot();
84
105
  }
85
106
  // =========================================================================
@@ -147,6 +168,8 @@ export class AccountDialogController {
147
168
  this.unsubscribeTokens = null;
148
169
  }
149
170
  this.clearPollTimer();
171
+ this.closeAuthSessionSocket();
172
+ this.signInToken = null;
150
173
  this.listeners.clear();
151
174
  }
152
175
  // =========================================================================
@@ -420,6 +443,10 @@ export class AccountDialogController {
420
443
  expiresAt: handle.expiresAt,
421
444
  error: null,
422
445
  });
446
+ // Primary path: an instant `auth_update` wake over the `/auth-session`
447
+ // socket. The poll below is only the fallback for when the socket can't
448
+ // connect, so it now runs at the slow fallback cadence.
449
+ this.openAuthSessionSocket(handle.sessionToken);
423
450
  this.scheduleNextPoll(handle.sessionToken);
424
451
  // Same-device convenience: if Commons is installed (native only — `canOpenApp`
425
452
  // is undefined/false on web), deep-link straight into its approve screen with
@@ -449,9 +476,10 @@ export class AccountDialogController {
449
476
  logger.debug('[AccountDialogController] Commons deep-link probe failed (QR fallback active)', { component: 'AccountDialogController' }, error);
450
477
  }
451
478
  }
452
- /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
479
+ /** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
453
480
  cancelSignIn() {
454
481
  this.clearPollTimer();
482
+ this.closeAuthSessionSocket();
455
483
  this.signInToken = null;
456
484
  if (this.signIn !== IDLE_SIGN_IN) {
457
485
  this.setSignIn(IDLE_SIGN_IN);
@@ -506,39 +534,52 @@ export class AccountDialogController {
506
534
  void this.pollOnce(sessionToken);
507
535
  }, this.pollIntervalMs);
508
536
  }
537
+ /**
538
+ * Run one status check + (on approval) claim. Triggered by the fallback timer
539
+ * AND by the `/auth-session` socket's `auth_update` wake, so it is guarded
540
+ * against concurrent entry: whichever fires first claims the single-use token;
541
+ * the other no-ops. The `auth_update` payload is never trusted — this always
542
+ * re-checks the authoritative status via `pollCommonsSignIn`.
543
+ */
509
544
  async pollOnce(sessionToken) {
510
- // A superseded / cancelled flow must not act.
511
- if (this.signInToken !== sessionToken)
512
- return;
513
- const expiresAt = this.signIn.expiresAt;
514
- if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
515
- this.failSignIn('Session expired. Please try again.');
545
+ // A superseded / cancelled flow must not act; a poll already running owns the claim.
546
+ if (this.signInToken !== sessionToken || this.pollInFlight)
516
547
  return;
517
- }
548
+ this.pollInFlight = true;
518
549
  try {
519
- const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
520
- if (this.signInToken !== sessionToken)
521
- return; // cancelled mid-request
522
- if (status.authorized && status.sessionId) {
523
- this.clearPollTimer();
524
- await this.claimAndComplete(status.sessionId, sessionToken);
550
+ const expiresAt = this.signIn.expiresAt;
551
+ if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
552
+ this.failSignIn('Session expired. Please try again.');
525
553
  return;
526
554
  }
527
- if (status.status === 'cancelled') {
528
- this.failSignIn('Authorization was denied.');
529
- return;
555
+ try {
556
+ const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
557
+ if (this.signInToken !== sessionToken)
558
+ return; // cancelled mid-request
559
+ if (status.authorized && status.sessionId) {
560
+ this.clearPollTimer();
561
+ await this.claimAndComplete(status.sessionId, sessionToken);
562
+ return;
563
+ }
564
+ if (status.status === 'cancelled') {
565
+ this.failSignIn('Authorization was denied.');
566
+ return;
567
+ }
568
+ if (status.status === 'expired') {
569
+ this.failSignIn('Session expired. Please try again.');
570
+ return;
571
+ }
530
572
  }
531
- if (status.status === 'expired') {
532
- this.failSignIn('Session expired. Please try again.');
533
- return;
573
+ catch (error) {
574
+ // Transient poll error — the next tick retries. Logged, never thrown.
575
+ logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
576
+ }
577
+ if (this.signInToken === sessionToken) {
578
+ this.scheduleNextPoll(sessionToken);
534
579
  }
535
580
  }
536
- catch (error) {
537
- // Transient poll error — the next tick retries. Logged, never thrown.
538
- logger.debug('[AccountDialogController] poll error (will retry)', { component: 'AccountDialogController' }, error);
539
- }
540
- if (this.signInToken === sessionToken) {
541
- this.scheduleNextPoll(sessionToken);
581
+ finally {
582
+ this.pollInFlight = false;
542
583
  }
543
584
  }
544
585
  async claimAndComplete(sessionId, sessionToken) {
@@ -586,6 +627,7 @@ export class AccountDialogController {
586
627
  await this.commitAuthorizedSession(session, user);
587
628
  this.signInToken = null;
588
629
  this.clearPollTimer();
630
+ this.closeAuthSessionSocket();
589
631
  this.signIn = IDLE_SIGN_IN;
590
632
  this.view = 'accounts';
591
633
  this.emit();
@@ -607,6 +649,7 @@ export class AccountDialogController {
607
649
  }
608
650
  failSignIn(message) {
609
651
  this.clearPollTimer();
652
+ this.closeAuthSessionSocket();
610
653
  this.signInToken = null;
611
654
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: message });
612
655
  }
@@ -617,6 +660,77 @@ export class AccountDialogController {
617
660
  }
618
661
  }
619
662
  // =========================================================================
663
+ // /auth-session socket (instant QR approval wake — replaces 3s polling)
664
+ // =========================================================================
665
+ /**
666
+ * Subscribe the active QR flow to the `/auth-session` namespace so the API's
667
+ * `auth_update` event wakes {@link pollOnce} the instant the approval lands.
668
+ *
669
+ * The join is keyed by the secret `sessionToken` (the server's `auth:<token>`
670
+ * room, joined by emitting `join`) and re-issued on every (re)connect so it
671
+ * survives socket drops. `auth_update` is treated as a pure SIGNAL — the payload
672
+ * is never trusted; `pollOnce` re-checks the authoritative status and claims.
673
+ *
674
+ * No-op (poll-only) when no `socketFactory` was injected (web without a bundled
675
+ * `io`, headless/core usage, tests). The namespace needs no auth.
676
+ */
677
+ openAuthSessionSocket(sessionToken) {
678
+ this.closeAuthSessionSocket();
679
+ if (!this.socketFactory)
680
+ return;
681
+ let socket;
682
+ try {
683
+ socket = this.socketFactory(`${this.oxyServices.getBaseURL()}${AUTH_SESSION_NAMESPACE}`, {
684
+ transports: ['websocket'],
685
+ autoConnect: true,
686
+ reconnection: true,
687
+ reconnectionAttempts: Number.POSITIVE_INFINITY,
688
+ reconnectionDelay: 1000,
689
+ reconnectionDelayMax: 10000,
690
+ });
691
+ }
692
+ catch (error) {
693
+ // Socket unavailable — the fallback poll still completes the flow.
694
+ logger.debug('[AccountDialogController] auth-session socket create failed (poll fallback)', { component: 'AccountDialogController' }, error);
695
+ return;
696
+ }
697
+ const join = () => {
698
+ if (this.signInToken !== sessionToken)
699
+ return;
700
+ try {
701
+ socket.emit('join', sessionToken);
702
+ }
703
+ catch (error) {
704
+ logger.debug('[AccountDialogController] auth-session join failed', { component: 'AccountDialogController' }, error);
705
+ }
706
+ };
707
+ socket.on('connect', join);
708
+ if (socket.connected)
709
+ join();
710
+ socket.on('auth_update', () => {
711
+ if (this.signInToken !== sessionToken)
712
+ return;
713
+ // Pure wake signal — re-check the authoritative status + claim the poll would have.
714
+ void this.pollOnce(sessionToken);
715
+ });
716
+ this.authSessionSocket = socket;
717
+ }
718
+ /** Tear down the `/auth-session` socket, if any. Idempotent. */
719
+ closeAuthSessionSocket() {
720
+ const socket = this.authSessionSocket;
721
+ if (!socket)
722
+ return;
723
+ this.authSessionSocket = null;
724
+ try {
725
+ socket.off('auth_update');
726
+ socket.off('connect');
727
+ socket.disconnect();
728
+ }
729
+ catch (error) {
730
+ logger.debug('[AccountDialogController] auth-session socket close failed', { component: 'AccountDialogController' }, error);
731
+ }
732
+ }
733
+ // =========================================================================
620
734
  // Snapshot plumbing
621
735
  // =========================================================================
622
736
  setSignIn(next) {