@oxyhq/core 7.1.1 → 8.1.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 (93) hide show
  1. package/README.md +48 -24
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +6 -6
  4. package/dist/cjs/boot/coldBootV2.js +97 -2
  5. package/dist/cjs/boot/deviceBootReturn.js +15 -0
  6. package/dist/cjs/i18n/locales/en-US.json +44 -1
  7. package/dist/cjs/i18n/locales/es-ES.json +44 -1
  8. package/dist/cjs/i18n/locales/locales/en-US.json +45 -2
  9. package/dist/cjs/i18n/locales/locales/es-ES.json +45 -2
  10. package/dist/cjs/index.js +19 -16
  11. package/dist/cjs/mixins/OxyServices.deviceBoot.js +28 -0
  12. package/dist/cjs/server/index.js +1 -7
  13. package/dist/cjs/session/accountDialogController.js +1 -1
  14. package/dist/cjs/session/accountProjection.js +1 -1
  15. package/dist/cjs/session/authStateStore.js +6 -0
  16. package/dist/cjs/session/projectSessionState.js +1 -1
  17. package/dist/cjs/session/refresh.js +9 -0
  18. package/dist/cjs/session/sessionClientHost.js +1 -2
  19. package/dist/cjs/utils/accountUtils.js +1 -1
  20. package/dist/cjs/utils/oauthPkce.js +142 -0
  21. package/dist/cjs/utils/platform.js +1 -1
  22. package/dist/esm/.tsbuildinfo +1 -1
  23. package/dist/esm/HttpService.js +6 -6
  24. package/dist/esm/boot/coldBootV2.js +97 -2
  25. package/dist/esm/boot/deviceBootReturn.js +15 -0
  26. package/dist/esm/i18n/locales/en-US.json +44 -1
  27. package/dist/esm/i18n/locales/es-ES.json +44 -1
  28. package/dist/esm/i18n/locales/locales/en-US.json +45 -2
  29. package/dist/esm/i18n/locales/locales/es-ES.json +45 -2
  30. package/dist/esm/index.js +11 -13
  31. package/dist/esm/mixins/OxyServices.deviceBoot.js +29 -1
  32. package/dist/esm/server/index.js +0 -5
  33. package/dist/esm/session/accountDialogController.js +1 -1
  34. package/dist/esm/session/accountProjection.js +1 -1
  35. package/dist/esm/session/authStateStore.js +6 -0
  36. package/dist/esm/session/projectSessionState.js +1 -1
  37. package/dist/esm/session/refresh.js +9 -0
  38. package/dist/esm/session/sessionClientHost.js +1 -2
  39. package/dist/esm/utils/accountUtils.js +1 -1
  40. package/dist/esm/utils/oauthPkce.js +135 -0
  41. package/dist/esm/utils/platform.js +1 -1
  42. package/dist/types/.tsbuildinfo +1 -1
  43. package/dist/types/HttpService.d.ts +1 -1
  44. package/dist/types/index.d.ts +3 -2
  45. package/dist/types/mixins/OxyServices.accounts.d.ts +13 -3
  46. package/dist/types/mixins/OxyServices.connectedApps.d.ts +4 -0
  47. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +17 -1
  48. package/dist/types/mixins/OxyServices.devices.d.ts +3 -2
  49. package/dist/types/models/interfaces.d.ts +4 -4
  50. package/dist/types/server/index.d.ts +0 -1
  51. package/dist/types/session/accountDialogController.d.ts +1 -1
  52. package/dist/types/session/accountProjection.d.ts +1 -1
  53. package/dist/types/session/authStateStore.d.ts +20 -0
  54. package/dist/types/session/projectSessionState.d.ts +1 -1
  55. package/dist/types/session/refresh.d.ts +4 -8
  56. package/dist/types/session/sessionClientHost.d.ts +1 -2
  57. package/dist/types/utils/accountUtils.d.ts +1 -1
  58. package/dist/types/utils/oauthPkce.d.ts +74 -0
  59. package/dist/types/utils/platform.d.ts +1 -1
  60. package/package.json +3 -3
  61. package/src/HttpService.ts +6 -6
  62. package/src/boot/__tests__/coldBootV2.test.ts +215 -1
  63. package/src/boot/__tests__/deviceBootReturn.test.ts +32 -0
  64. package/src/boot/coldBootV2.ts +117 -2
  65. package/src/boot/deviceBootReturn.ts +15 -0
  66. package/src/i18n/locales/en-US.json +45 -2
  67. package/src/i18n/locales/es-ES.json +45 -2
  68. package/src/index.ts +23 -16
  69. package/src/mixins/OxyServices.accounts.ts +12 -0
  70. package/src/mixins/OxyServices.connectedApps.ts +4 -0
  71. package/src/mixins/OxyServices.deviceBoot.ts +38 -0
  72. package/src/mixins/OxyServices.devices.ts +6 -5
  73. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +44 -1
  74. package/src/mixins/__tests__/accounts.test.ts +1 -1
  75. package/src/models/interfaces.ts +7 -5
  76. package/src/server/index.ts +0 -6
  77. package/src/session/__tests__/authStateStore.test.ts +27 -0
  78. package/src/session/__tests__/refresh.test.ts +14 -0
  79. package/src/session/accountDialogController.ts +1 -1
  80. package/src/session/accountProjection.ts +1 -1
  81. package/src/session/authStateStore.ts +26 -0
  82. package/src/session/projectSessionState.ts +1 -1
  83. package/src/session/refresh.ts +13 -8
  84. package/src/session/sessionClientHost.ts +1 -2
  85. package/src/utils/__tests__/coldBoot.test.ts +55 -65
  86. package/src/utils/__tests__/oauthPkce.test.ts +154 -0
  87. package/src/utils/accountUtils.ts +1 -1
  88. package/src/utils/oauthPkce.ts +189 -0
  89. package/src/utils/platform.ts +1 -1
  90. package/dist/cjs/utils/ssoBounce.js +0 -24
  91. package/dist/esm/utils/ssoBounce.js +0 -21
  92. package/dist/types/utils/ssoBounce.d.ts +0 -21
  93. package/src/utils/ssoBounce.ts +0 -22
@@ -234,10 +234,9 @@ describe('runColdBoot', () => {
234
234
  });
235
235
 
236
236
  /**
237
- * Reproduces the production hang: a step whose `run()` promise NEVER
238
- * settles (the FedCM-silent `navigator.credentials.get` that ignored its
239
- * abort signal). WITHOUT a deadline the whole `runColdBoot` promise hangs
240
- * forever and the terminal step never runs.
237
+ * A step whose `run()` promise NEVER settles (e.g. an async call that
238
+ * ignores its abort signal). WITHOUT a deadline the whole `runColdBoot`
239
+ * promise hangs forever and the terminal step never runs.
241
240
  */
242
241
  it('hangs forever when a step never settles and no deadline is set', async () => {
243
242
  const terminalRan = jest.fn();
@@ -247,7 +246,7 @@ describe('runColdBoot', () => {
247
246
  steps: [
248
247
  {
249
248
  id: 'never-settles',
250
- // Never resolves or rejects — models the hung FedCM credential get.
249
+ // Never resolves or rejects — models a hung async call.
251
250
  run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
252
251
  },
253
252
  {
@@ -275,9 +274,8 @@ describe('runColdBoot', () => {
275
274
 
276
275
  /**
277
276
  * With `overallDeadlineMs` set, the non-settling step is abandoned at the
278
- * deadline, the runner CONTINUES to the terminal step (so the cross-domain
279
- * `/sso` bounce equivalent still fires), and the whole boot settles to
280
- * `unauthenticated` within the bounded budget.
277
+ * deadline, the runner CONTINUES to the terminal step, and the whole boot
278
+ * settles to `unauthenticated` within the bounded budget.
281
279
  */
282
280
  it('abandons a non-settling step at the deadline and still runs the terminal step', async () => {
283
281
  const terminalRan = jest.fn();
@@ -310,12 +308,12 @@ describe('runColdBoot', () => {
310
308
  });
311
309
 
312
310
  /**
313
- * The terminal step's synchronous side effect (the real `sso-bounce`
314
- * navigates BEFORE its first await) must still execute when the deadline
315
- * trips on an earlier step — the cross-domain fallback is preserved.
311
+ * A terminal step may run a synchronous side effect before its first
312
+ * `await`; that side effect must still execute when the deadline trips on
313
+ * an earlier step — the terminal fallback is preserved.
316
314
  */
317
315
  it('lets the terminal step fire its synchronous side effect after the deadline trips', async () => {
318
- const bounced = jest.fn();
316
+ const terminalSideEffect = jest.fn();
319
317
 
320
318
  const outcomePromise = runColdBoot<TestSession>({
321
319
  overallDeadlineMs: 3000,
@@ -325,10 +323,10 @@ describe('runColdBoot', () => {
325
323
  run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
326
324
  },
327
325
  {
328
- id: 'sso-bounce',
326
+ id: 'terminal',
329
327
  run: async (): Promise<ColdBootStepResult<TestSession>> => {
330
- // Synchronous navigation side effect, exactly like the real bounce.
331
- bounced();
328
+ // Synchronous side effect that fires before the first await.
329
+ terminalSideEffect();
332
330
  return { kind: 'skip' };
333
331
  },
334
332
  },
@@ -338,7 +336,7 @@ describe('runColdBoot', () => {
338
336
  await jest.advanceTimersByTimeAsync(3000);
339
337
  await outcomePromise;
340
338
 
341
- expect(bounced).toHaveBeenCalledTimes(1);
339
+ expect(terminalSideEffect).toHaveBeenCalledTimes(1);
342
340
  });
343
341
 
344
342
  /**
@@ -375,29 +373,22 @@ describe('runColdBoot', () => {
375
373
  });
376
374
 
377
375
  /**
378
- * End-to-end reproduction of the production FedCM-silent hang at the REAL
379
- * overall deadline the SDK ships.
380
- *
381
- * `OxyContext` runs the web cold-boot chain with `COLD_BOOT_OVERALL_DEADLINE`
382
- * (20000 ms): fedcm-silent /auth/silent iframe cookie restore →
383
- * stored-session /sso top-level bounce (terminal). The documented gotcha:
384
- * `navigator.credentials.get({mediation:'silent'})` can sit pending forever,
385
- * ignoring its AbortController. WITHOUT the overall deadline the whole chain
386
- * hangs and the terminal `/sso` bounce never fires.
387
- *
388
- * These tests model that exact chain shape against the real deadline value and
389
- * assert the runner (a) abandons the hung silent step at the deadline,
390
- * (b) still fires the terminal bounce's synchronous side effect, and
376
+ * End-to-end deadline semantics over a longer chain at the real overall
377
+ * deadline a consumer arms (the runner itself has no default). Models a chain
378
+ * whose FIRST step never settles — its `run()` promise ignores the abort
379
+ * signal and stays pending — followed by several no-op steps and a terminal
380
+ * step. Asserts the runner (a) abandons the hung step at the deadline,
381
+ * (b) still fires the terminal step's synchronous side effect, and
391
382
  * (c) always settles within the bounded budget — it never hangs.
392
383
  */
393
- describe('production cold-boot deadline semantics (FedCM-silent hang)', () => {
384
+ describe('long-chain overall-deadline semantics', () => {
394
385
  /**
395
- * Mirror of `COLD_BOOT_OVERALL_DEADLINE` in
396
- * `@oxyhq/services` `OxyContext` (the only consumer that arms the deadline).
397
- * Kept as a local literal because core does not — and must not — import the
398
- * services package; if the consumer's value changes, update this to match.
386
+ * Mirror of the overall-deadline value a consumer arms (e.g. `@oxyhq/services`
387
+ * cold boot the only consumer that arms the deadline). Kept as a local
388
+ * literal because core does not — and must not — import a consumer package;
389
+ * if the consumer's value changes, update this to match.
399
390
  */
400
- const COLD_BOOT_OVERALL_DEADLINE = 20000;
391
+ const OVERALL_DEADLINE = 20000;
401
392
 
402
393
  beforeEach(() => {
403
394
  jest.useFakeTimers();
@@ -407,55 +398,54 @@ describe('runColdBoot', () => {
407
398
  jest.useRealTimers();
408
399
  });
409
400
 
410
- /** A step whose `run()` never settles — the hung `credentials.get`. */
411
- const hungFedcmSilentStep = (): ColdBootStep<TestSession> => ({
412
- id: 'fedcm-silent',
401
+ /** A step whose `run()` never settles — models a hung async call. */
402
+ const neverSettlingStep = (): ColdBootStep<TestSession> => ({
403
+ id: 'never-settles',
413
404
  run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
414
405
  });
415
406
 
416
- it('abandons the hung fedcm-silent step at the 20s deadline and fires the terminal /sso bounce', async () => {
417
- const ssoBounced = jest.fn();
407
+ it('abandons the hung step at the deadline and fires the terminal step', async () => {
408
+ const terminalRan = jest.fn();
418
409
  const onStepDeadline = jest.fn();
419
410
 
420
411
  const outcomePromise = runColdBoot<TestSession>({
421
- overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
412
+ overallDeadlineMs: OVERALL_DEADLINE,
422
413
  onStepDeadline,
423
414
  steps: [
424
- hungFedcmSilentStep(),
415
+ neverSettlingStep(),
425
416
  // Every intermediate step has nothing to contribute on this load.
426
- skipStep('auth-silent-iframe'),
427
- skipStep('cookie-restore'),
428
- skipStep('stored-session'),
417
+ skipStep('intermediate-1'),
418
+ skipStep('intermediate-2'),
419
+ skipStep('intermediate-3'),
429
420
  {
430
- id: 'sso-bounce',
431
- // Terminal: navigates synchronously before its first await, exactly
432
- // like the real top-level `/sso` bounce.
421
+ id: 'terminal',
422
+ // Runs a synchronous side effect before its first await.
433
423
  run: async (): Promise<ColdBootStepResult<TestSession>> => {
434
- ssoBounced();
424
+ terminalRan();
435
425
  return { kind: 'skip' };
436
426
  },
437
427
  },
438
428
  ],
439
429
  });
440
430
 
441
- // Nothing settles before the deadline — the chain is "stuck" on silent.
442
- await jest.advanceTimersByTimeAsync(COLD_BOOT_OVERALL_DEADLINE - 1);
443
- expect(ssoBounced).not.toHaveBeenCalled();
431
+ // Nothing settles before the deadline — the chain is stuck on the first step.
432
+ await jest.advanceTimersByTimeAsync(OVERALL_DEADLINE - 1);
433
+ expect(terminalRan).not.toHaveBeenCalled();
444
434
 
445
- // At the deadline the silent step is abandoned and the chain proceeds.
435
+ // At the deadline the hung step is abandoned and the chain proceeds.
446
436
  await jest.advanceTimersByTimeAsync(1);
447
437
  const outcome = await outcomePromise;
448
438
 
449
- expect(onStepDeadline).toHaveBeenCalledWith('fedcm-silent');
450
- expect(ssoBounced).toHaveBeenCalledTimes(1);
439
+ expect(onStepDeadline).toHaveBeenCalledWith('never-settles');
440
+ expect(terminalRan).toHaveBeenCalledTimes(1);
451
441
  expect(outcome).toEqual({ kind: 'unauthenticated' });
452
442
  });
453
443
 
454
444
  it('settles within the bounded budget instead of hanging forever', async () => {
455
445
  let settled = false;
456
446
  const outcomePromise = runColdBoot<TestSession>({
457
- overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
458
- steps: [hungFedcmSilentStep(), skipStep('terminal')],
447
+ overallDeadlineMs: OVERALL_DEADLINE,
448
+ steps: [neverSettlingStep(), skipStep('terminal')],
459
449
  }).then((o) => {
460
450
  settled = true;
461
451
  return o;
@@ -463,7 +453,7 @@ describe('runColdBoot', () => {
463
453
 
464
454
  // Just before the deadline: still pending (proves the deadline, not an
465
455
  // accidental early settle, is what unblocks it).
466
- await jest.advanceTimersByTimeAsync(COLD_BOOT_OVERALL_DEADLINE - 1);
456
+ await jest.advanceTimersByTimeAsync(OVERALL_DEADLINE - 1);
467
457
  expect(settled).toBe(false);
468
458
 
469
459
  await jest.advanceTimersByTimeAsync(1);
@@ -471,19 +461,19 @@ describe('runColdBoot', () => {
471
461
  expect(settled).toBe(true);
472
462
  });
473
463
 
474
- it('does not penalize a fast silent success — it wins well before the deadline', async () => {
464
+ it('does not penalize a fast first-step success — it wins well before the deadline', async () => {
475
465
  const laterRan = jest.fn();
476
466
  const outcomePromise = runColdBoot<TestSession>({
477
- overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
467
+ overallDeadlineMs: OVERALL_DEADLINE,
478
468
  steps: [
479
469
  {
480
- id: 'fedcm-silent',
470
+ id: 'first',
481
471
  run: async (): Promise<ColdBootStepResult<TestSession>> => {
482
472
  await Promise.resolve();
483
- return { kind: 'session', session: { userId: 'u-silent' } };
473
+ return { kind: 'session', session: { userId: 'u-fast' } };
484
474
  },
485
475
  },
486
- sessionStep('sso-bounce', 'u-should-not-run', laterRan),
476
+ sessionStep('terminal', 'u-should-not-run', laterRan),
487
477
  ],
488
478
  });
489
479
 
@@ -492,8 +482,8 @@ describe('runColdBoot', () => {
492
482
 
493
483
  expect(outcome).toEqual({
494
484
  kind: 'session',
495
- via: 'fedcm-silent',
496
- session: { userId: 'u-silent' },
485
+ via: 'first',
486
+ session: { userId: 'u-fast' },
497
487
  });
498
488
  expect(laterRan).not.toHaveBeenCalled();
499
489
  });
@@ -0,0 +1,154 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ buildOAuthAuthorizeUrl,
4
+ computeCodeChallenge,
5
+ DEFAULT_OAUTH_SCOPE,
6
+ generateOAuthState,
7
+ generatePkcePair,
8
+ OXY_AUTHORIZE_URL,
9
+ } from '../oauthPkce';
10
+
11
+ /** RFC 7636 unreserved subset produced by base64url (no `+`, `/`, `=`). */
12
+ const BASE64URL_RE = /^[A-Za-z0-9\-_]+$/;
13
+
14
+ /** Independent base64url(SHA-256(input)) via Node crypto for cross-checking. */
15
+ function nodeCodeChallenge(verifier: string): string {
16
+ return createHash('sha256').update(verifier).digest('base64url');
17
+ }
18
+
19
+ describe('computeCodeChallenge', () => {
20
+ it('matches the RFC 7636 Appendix B known-answer vector', async () => {
21
+ // From RFC 7636 §Appendix B: verifier -> S256 challenge.
22
+ const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
23
+ const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
24
+ await expect(computeCodeChallenge(verifier)).resolves.toBe(expected);
25
+ });
26
+
27
+ it('produces unpadded base64url (no +, /, or = characters)', async () => {
28
+ const challenge = await computeCodeChallenge('some-arbitrary-code-verifier-value-123');
29
+ expect(challenge).toMatch(BASE64URL_RE);
30
+ expect(challenge).not.toContain('=');
31
+ });
32
+
33
+ it('agrees with an independent Node crypto implementation', async () => {
34
+ const verifier = 'another_verifier-value.with~allowed_chars-0123456789';
35
+ await expect(computeCodeChallenge(verifier)).resolves.toBe(nodeCodeChallenge(verifier));
36
+ });
37
+
38
+ it('encodes the full 32-byte SHA-256 digest as 43 base64url chars', async () => {
39
+ const challenge = await computeCodeChallenge('x');
40
+ expect(challenge).toHaveLength(43);
41
+ });
42
+ });
43
+
44
+ describe('generatePkcePair', () => {
45
+ it('returns a verifier, challenge, and S256 method', async () => {
46
+ const pair = await generatePkcePair();
47
+ expect(pair.method).toBe('S256');
48
+ expect(typeof pair.codeVerifier).toBe('string');
49
+ expect(typeof pair.codeChallenge).toBe('string');
50
+ });
51
+
52
+ it('produces a verifier in the RFC 7636 length range with only unreserved chars', async () => {
53
+ const { codeVerifier } = await generatePkcePair();
54
+ expect(codeVerifier).toMatch(BASE64URL_RE);
55
+ expect(codeVerifier.length).toBeGreaterThanOrEqual(43);
56
+ expect(codeVerifier.length).toBeLessThanOrEqual(128);
57
+ });
58
+
59
+ it('derives the challenge as base64url(SHA-256(verifier))', async () => {
60
+ const { codeVerifier, codeChallenge } = await generatePkcePair();
61
+ await expect(computeCodeChallenge(codeVerifier)).resolves.toBe(codeChallenge);
62
+ expect(codeChallenge).toBe(nodeCodeChallenge(codeVerifier));
63
+ });
64
+
65
+ it('generates a fresh, unpredictable verifier on each call', async () => {
66
+ const [a, b] = await Promise.all([generatePkcePair(), generatePkcePair()]);
67
+ expect(a.codeVerifier).not.toBe(b.codeVerifier);
68
+ expect(a.codeChallenge).not.toBe(b.codeChallenge);
69
+ });
70
+ });
71
+
72
+ describe('generateOAuthState', () => {
73
+ it('returns an unreserved base64url token of 32 random bytes (43 chars)', async () => {
74
+ const state = await generateOAuthState();
75
+ expect(state).toMatch(BASE64URL_RE);
76
+ expect(state).toHaveLength(43);
77
+ });
78
+
79
+ it('is unique across calls', async () => {
80
+ const [a, b] = await Promise.all([generateOAuthState(), generateOAuthState()]);
81
+ expect(a).not.toBe(b);
82
+ });
83
+ });
84
+
85
+ describe('buildOAuthAuthorizeUrl', () => {
86
+ const base = {
87
+ clientId: 'oxy_dk_example',
88
+ redirectUri: 'https://merchant.co/auth/callback',
89
+ state: 'csrf-state-token',
90
+ codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
91
+ };
92
+
93
+ it('defaults to the Oxy authorize endpoint', () => {
94
+ const url = buildOAuthAuthorizeUrl(base);
95
+ expect(url.startsWith(`${OXY_AUTHORIZE_URL}?`)).toBe(true);
96
+ expect(OXY_AUTHORIZE_URL).toBe('https://auth.oxy.so/authorize');
97
+ });
98
+
99
+ it('includes every required OAuth authorization-code + PKCE parameter', () => {
100
+ const params = new URL(buildOAuthAuthorizeUrl(base)).searchParams;
101
+ expect(params.get('client_id')).toBe(base.clientId);
102
+ expect(params.get('redirect_uri')).toBe(base.redirectUri);
103
+ expect(params.get('response_type')).toBe('code');
104
+ expect(params.get('state')).toBe(base.state);
105
+ expect(params.get('scope')).toBe(DEFAULT_OAUTH_SCOPE);
106
+ expect(params.get('code_challenge')).toBe(base.codeChallenge);
107
+ expect(params.get('code_challenge_method')).toBe('S256');
108
+ });
109
+
110
+ it('defaults the scope to "openid profile"', () => {
111
+ const params = new URL(buildOAuthAuthorizeUrl(base)).searchParams;
112
+ expect(params.get('scope')).toBe('openid profile');
113
+ });
114
+
115
+ it('honors a custom scope', () => {
116
+ const params = new URL(
117
+ buildOAuthAuthorizeUrl({ ...base, scope: 'openid profile email wallet' }),
118
+ ).searchParams;
119
+ expect(params.get('scope')).toBe('openid profile email wallet');
120
+ });
121
+
122
+ it('honors an authorizeBaseUrl override', () => {
123
+ const url = buildOAuthAuthorizeUrl({
124
+ ...base,
125
+ authorizeBaseUrl: 'https://auth.merchant.co/authorize',
126
+ });
127
+ expect(url.startsWith('https://auth.merchant.co/authorize?')).toBe(true);
128
+ expect(new URL(url).searchParams.get('client_id')).toBe(base.clientId);
129
+ });
130
+
131
+ it('preserves a query string already present on authorizeBaseUrl', () => {
132
+ const params = new URL(
133
+ buildOAuthAuthorizeUrl({
134
+ ...base,
135
+ authorizeBaseUrl: 'https://auth.merchant.co/authorize?foo=bar&tenant=acme',
136
+ }),
137
+ ).searchParams;
138
+ // Pre-existing params survive alongside the OAuth params.
139
+ expect(params.get('foo')).toBe('bar');
140
+ expect(params.get('tenant')).toBe('acme');
141
+ expect(params.get('client_id')).toBe(base.clientId);
142
+ expect(params.get('code_challenge_method')).toBe('S256');
143
+ });
144
+
145
+ it('URL-encodes a redirect_uri that carries a query string and special chars', () => {
146
+ const redirectUri = 'https://merchant.co/auth/callback?next=/dashboard&lang=es';
147
+ const url = buildOAuthAuthorizeUrl({ ...base, redirectUri });
148
+ // The raw string must be percent-encoded (its own & must not leak as a delimiter).
149
+ expect(url).toContain('redirect_uri=https%3A%2F%2Fmerchant.co%2Fauth%2Fcallback');
150
+ expect(url).not.toContain('redirect_uri=https://merchant.co');
151
+ // Round-trips back to the exact original when parsed.
152
+ expect(new URL(url).searchParams.get('redirect_uri')).toBe(redirectUri);
153
+ });
154
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared account types and pure helper functions.
3
- * Used by both @oxyhq/services (React Native) and @oxyhq/auth (Web) account stores.
3
+ * Used by the @oxyhq/services account stores (Expo/RN and RN-Web).
4
4
  */
5
5
 
6
6
  import { translate } from '../i18n';
@@ -0,0 +1,189 @@
1
+ /**
2
+ * OAuth 2.0 Authorization Code + PKCE helpers for "Sign in with Oxy" third-party
3
+ * sign-in.
4
+ *
5
+ * Third-party Relying Parties (SPAs, static sites, and native apps that are NOT
6
+ * Oxy first-party) authenticate through the standard OAuth flow against
7
+ * `auth.oxy.so/authorize` — never FedCM, SSO bounces, or Oxy session cookies.
8
+ * Public clients (no secret) prove possession of the authorization code with
9
+ * PKCE (RFC 7636, S256): the RP generates a random `code_verifier`, sends its
10
+ * `code_challenge = BASE64URL(SHA-256(code_verifier))` on the authorize
11
+ * redirect, and later replays the raw verifier on the token exchange.
12
+ *
13
+ * All cross-platform crypto (random bytes, SHA-256) is delegated to the shared
14
+ * `@oxyhq/protocol` platform loaders — the exact primitives the rest of core's
15
+ * crypto already uses — so these helpers run identically on web, Node, and
16
+ * React Native. No `require()`, so the ESM build stays bundler-clean.
17
+ */
18
+
19
+ import { isNodeJS, isReactNative, loadExpoCrypto, loadNodeCrypto, sha256 } from '@oxyhq/protocol';
20
+ import { logger } from './loggerUtils';
21
+
22
+ /** The central Oxy IdP authorization endpoint used by default. */
23
+ export const OXY_AUTHORIZE_URL = 'https://auth.oxy.so/authorize';
24
+
25
+ /** Default OAuth scope requested for a "Sign in with Oxy" third-party flow. */
26
+ export const DEFAULT_OAUTH_SCOPE = 'openid profile';
27
+
28
+ /**
29
+ * Number of random bytes behind a PKCE `code_verifier`. 64 bytes → 86 base64url
30
+ * characters, comfortably inside RFC 7636 §4.1's required 43–128 range.
31
+ */
32
+ const PKCE_VERIFIER_BYTES = 64;
33
+
34
+ /** Number of random bytes behind an OAuth `state` (CSRF) token. */
35
+ const OAUTH_STATE_BYTES = 32;
36
+
37
+ /** RFC 4648 §5 base64url alphabet (URL- and filename-safe, no padding). */
38
+ const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
39
+
40
+ /** A generated PKCE verifier/challenge pair. */
41
+ export interface PkcePair {
42
+ /** The high-entropy secret replayed on the token exchange (kept client-side). */
43
+ codeVerifier: string;
44
+ /** `BASE64URL(SHA-256(codeVerifier))` — sent on the authorize redirect. */
45
+ codeChallenge: string;
46
+ /** The PKCE transformation method. Always `S256`. */
47
+ method: 'S256';
48
+ }
49
+
50
+ /** Parameters for {@link buildOAuthAuthorizeUrl}. */
51
+ export interface BuildOAuthAuthorizeUrlParams {
52
+ /** Authorize endpoint; defaults to {@link OXY_AUTHORIZE_URL}. */
53
+ authorizeBaseUrl?: string;
54
+ /** The registered `ApplicationCredential` public key (`oxy_dk_…`). */
55
+ clientId: string;
56
+ /** Exact registered redirect URI to return the authorization code to. */
57
+ redirectUri: string;
58
+ /** Requested scope; defaults to {@link DEFAULT_OAUTH_SCOPE}. */
59
+ scope?: string;
60
+ /** Opaque CSRF token from {@link generateOAuthState}. */
61
+ state: string;
62
+ /** The PKCE `codeChallenge` from {@link generatePkcePair}. */
63
+ codeChallenge: string;
64
+ }
65
+
66
+ /**
67
+ * Cryptographically-secure random bytes, cross-platform.
68
+ *
69
+ * Mirrors the platform gating the rest of core's crypto already uses:
70
+ * `expo-crypto` on React Native, Node's built-in `crypto` on the server, and
71
+ * the Web Crypto API in the browser (also the fallback if Node's `crypto`
72
+ * fails to load in an unusual bundled-Node environment).
73
+ */
74
+ async function getSecureRandomBytes(byteLength: number): Promise<Uint8Array> {
75
+ if (isReactNative()) {
76
+ const crypto = await loadExpoCrypto();
77
+ return Uint8Array.from(await crypto.getRandomBytesAsync(byteLength));
78
+ }
79
+
80
+ if (isNodeJS()) {
81
+ try {
82
+ const nodeCrypto = await loadNodeCrypto();
83
+ return Uint8Array.from(nodeCrypto.randomBytes(byteLength));
84
+ } catch (error) {
85
+ logger.warn(
86
+ '[oxy.oauth] Node crypto unavailable for PKCE random bytes, falling back to Web Crypto',
87
+ { component: 'oauthPkce' },
88
+ error,
89
+ );
90
+ }
91
+ }
92
+
93
+ const bytes = new Uint8Array(byteLength);
94
+ globalThis.crypto.getRandomValues(bytes);
95
+ return bytes;
96
+ }
97
+
98
+ /** Encode raw bytes as unpadded base64url (RFC 4648 §5). */
99
+ function bytesToBase64Url(bytes: Uint8Array): string {
100
+ let output = '';
101
+ for (let i = 0; i < bytes.length; i += 3) {
102
+ const byte0 = bytes[i];
103
+ const hasByte1 = i + 1 < bytes.length;
104
+ const hasByte2 = i + 2 < bytes.length;
105
+ const byte1 = hasByte1 ? bytes[i + 1] : 0;
106
+ const byte2 = hasByte2 ? bytes[i + 2] : 0;
107
+
108
+ output += BASE64URL_ALPHABET[byte0 >> 2];
109
+ output += BASE64URL_ALPHABET[((byte0 & 0x03) << 4) | (byte1 >> 4)];
110
+ if (hasByte1) {
111
+ output += BASE64URL_ALPHABET[((byte1 & 0x0f) << 2) | (byte2 >> 6)];
112
+ }
113
+ if (hasByte2) {
114
+ output += BASE64URL_ALPHABET[byte2 & 0x3f];
115
+ }
116
+ }
117
+ return output;
118
+ }
119
+
120
+ /** Decode a lowercase-hex string into its raw bytes. */
121
+ function hexToBytes(hex: string): Uint8Array {
122
+ const bytes = new Uint8Array(hex.length / 2);
123
+ for (let i = 0; i < bytes.length; i += 1) {
124
+ bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
125
+ }
126
+ return bytes;
127
+ }
128
+
129
+ /**
130
+ * Compute the PKCE S256 `code_challenge` for a given verifier:
131
+ * `BASE64URL(SHA-256(ASCII(codeVerifier)))` (RFC 7636 §4.2). The verifier is
132
+ * base64url (ASCII), so its UTF-8 and ASCII byte encodings are identical.
133
+ *
134
+ * Reuses `@oxyhq/protocol`'s cross-platform {@link sha256} (which returns
135
+ * lowercase hex); the digest bytes are recovered and re-encoded as base64url.
136
+ */
137
+ export async function computeCodeChallenge(codeVerifier: string): Promise<string> {
138
+ const digestHex = await sha256(codeVerifier);
139
+ return bytesToBase64Url(hexToBytes(digestHex));
140
+ }
141
+
142
+ /**
143
+ * Generate a fresh PKCE verifier/challenge pair for an OAuth authorization-code
144
+ * flow. The verifier is 64 random bytes as base64url (86 chars, within RFC 7636
145
+ * §4.1's 43–128 range and drawn only from the unreserved set); the challenge is
146
+ * its S256 transform.
147
+ */
148
+ export async function generatePkcePair(): Promise<PkcePair> {
149
+ const codeVerifier = bytesToBase64Url(await getSecureRandomBytes(PKCE_VERIFIER_BYTES));
150
+ const codeChallenge = await computeCodeChallenge(codeVerifier);
151
+ return { codeVerifier, codeChallenge, method: 'S256' };
152
+ }
153
+
154
+ /**
155
+ * Generate an opaque, single-use OAuth `state` token (32 random bytes as
156
+ * base64url) for CSRF protection across the authorize redirect.
157
+ */
158
+ export async function generateOAuthState(): Promise<string> {
159
+ return bytesToBase64Url(await getSecureRandomBytes(OAUTH_STATE_BYTES));
160
+ }
161
+
162
+ /**
163
+ * Build the `auth.oxy.so/authorize` redirect URL for an OAuth authorization-code
164
+ * + PKCE (S256) flow. Built via the WHATWG `URL` API so a custom
165
+ * `authorizeBaseUrl` that already carries a query string keeps its existing
166
+ * params (the OAuth params are merged in, not clobbered by a naive `?` concat).
167
+ * All values are percent-encoded by `URL.searchParams`.
168
+ */
169
+ export function buildOAuthAuthorizeUrl(params: BuildOAuthAuthorizeUrlParams): string {
170
+ const {
171
+ authorizeBaseUrl = OXY_AUTHORIZE_URL,
172
+ clientId,
173
+ redirectUri,
174
+ scope = DEFAULT_OAUTH_SCOPE,
175
+ state,
176
+ codeChallenge,
177
+ } = params;
178
+
179
+ const url = new URL(authorizeBaseUrl);
180
+ url.searchParams.set('client_id', clientId);
181
+ url.searchParams.set('redirect_uri', redirectUri);
182
+ url.searchParams.set('response_type', 'code');
183
+ url.searchParams.set('state', state);
184
+ url.searchParams.set('scope', scope);
185
+ url.searchParams.set('code_challenge', codeChallenge);
186
+ url.searchParams.set('code_challenge_method', 'S256');
187
+
188
+ return url.toString();
189
+ }
@@ -104,7 +104,7 @@ export function setPlatformOS(os: PlatformOS): void {
104
104
  *
105
105
  * Native defines a global `window` but no `document`, so the DOM probe — not a
106
106
  * bare `window` check — is the reliable discriminator. This is the single
107
- * source of truth consumed by `@oxyhq/services` and `@oxyhq/auth` (both dropped
107
+ * source of truth consumed by `@oxyhq/services` (which dropped
108
108
  * their local copies), so every consumer shares the exact same predicate.
109
109
  *
110
110
  * NOTE: this is a live runtime probe (not the cached `getPlatformOS()` verdict)
@@ -1,24 +0,0 @@
1
- "use strict";
2
- /**
3
- * SSO callback path constant.
4
- *
5
- * The client SSO-bounce machinery (per-origin sessionStorage keys, the bounce
6
- * URL builder, the `guardActive` / `allowSsoBounce` / `getSsoCallbackBootstrapScript`
7
- * predicates, …) was removed in the device-first cutover — RP apps no longer
8
- * bounce through `auth.oxy.so`. The one survivor is this path constant, still
9
- * referenced by the api SSO controller and the IdP (both lista B, gated on the
10
- * ecosystem bump). `@oxyhq/core/server` re-exports it for the api.
11
- *
12
- * LEGACY(old-sdk): `SSO_CALLBACK_PATH` survives ONLY for the lista-B api/IdP SSO
13
- * surface. Deletable once Homiio/Allo/Alia/Syra are bumped off the old SDK AND
14
- * CloudWatch `/oxy/ecs` shows the `/sso*` + `/fedcm/*` routes quiet — the
15
- * F-final sweep should remove this file then.
16
- */
17
- Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.SSO_CALLBACK_PATH = void 0;
19
- /**
20
- * The RP callback path the central IdP redirects back to after a legacy SSO
21
- * bounce. Kept as the single source of truth so the api/IdP never hardcode the
22
- * literal.
23
- */
24
- exports.SSO_CALLBACK_PATH = '/__oxy/sso-callback';
@@ -1,21 +0,0 @@
1
- /**
2
- * SSO callback path constant.
3
- *
4
- * The client SSO-bounce machinery (per-origin sessionStorage keys, the bounce
5
- * URL builder, the `guardActive` / `allowSsoBounce` / `getSsoCallbackBootstrapScript`
6
- * predicates, …) was removed in the device-first cutover — RP apps no longer
7
- * bounce through `auth.oxy.so`. The one survivor is this path constant, still
8
- * referenced by the api SSO controller and the IdP (both lista B, gated on the
9
- * ecosystem bump). `@oxyhq/core/server` re-exports it for the api.
10
- *
11
- * LEGACY(old-sdk): `SSO_CALLBACK_PATH` survives ONLY for the lista-B api/IdP SSO
12
- * surface. Deletable once Homiio/Allo/Alia/Syra are bumped off the old SDK AND
13
- * CloudWatch `/oxy/ecs` shows the `/sso*` + `/fedcm/*` routes quiet — the
14
- * F-final sweep should remove this file then.
15
- */
16
- /**
17
- * The RP callback path the central IdP redirects back to after a legacy SSO
18
- * bounce. Kept as the single source of truth so the api/IdP never hardcode the
19
- * literal.
20
- */
21
- export const SSO_CALLBACK_PATH = '/__oxy/sso-callback';
@@ -1,21 +0,0 @@
1
- /**
2
- * SSO callback path constant.
3
- *
4
- * The client SSO-bounce machinery (per-origin sessionStorage keys, the bounce
5
- * URL builder, the `guardActive` / `allowSsoBounce` / `getSsoCallbackBootstrapScript`
6
- * predicates, …) was removed in the device-first cutover — RP apps no longer
7
- * bounce through `auth.oxy.so`. The one survivor is this path constant, still
8
- * referenced by the api SSO controller and the IdP (both lista B, gated on the
9
- * ecosystem bump). `@oxyhq/core/server` re-exports it for the api.
10
- *
11
- * LEGACY(old-sdk): `SSO_CALLBACK_PATH` survives ONLY for the lista-B api/IdP SSO
12
- * surface. Deletable once Homiio/Allo/Alia/Syra are bumped off the old SDK AND
13
- * CloudWatch `/oxy/ecs` shows the `/sso*` + `/fedcm/*` routes quiet — the
14
- * F-final sweep should remove this file then.
15
- */
16
- /**
17
- * The RP callback path the central IdP redirects back to after a legacy SSO
18
- * bounce. Kept as the single source of truth so the api/IdP never hardcode the
19
- * literal.
20
- */
21
- export declare const SSO_CALLBACK_PATH = "/__oxy/sso-callback";