@oxyhq/core 12.11.0 → 13.0.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 (54) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +66 -6
  3. package/dist/cjs/index.js +2 -12
  4. package/dist/cjs/mixins/OxyServices.deviceBoot.js +0 -31
  5. package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
  6. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  7. package/dist/cjs/server/index.js +1 -6
  8. package/dist/cjs/server/rateLimit.js +3 -0
  9. package/dist/cjs/session/accountDialogController.js +6 -8
  10. package/dist/cjs/utils/oauthPkce.js +1 -5
  11. package/dist/cjs/utils/officialOrigins.js +3 -73
  12. package/dist/esm/.tsbuildinfo +1 -1
  13. package/dist/esm/HttpService.js +66 -6
  14. package/dist/esm/index.js +2 -3
  15. package/dist/esm/mixins/OxyServices.deviceBoot.js +1 -32
  16. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  17. package/dist/esm/mixins/OxyServices.user.js +1 -0
  18. package/dist/esm/server/index.js +1 -1
  19. package/dist/esm/server/rateLimit.js +3 -0
  20. package/dist/esm/session/accountDialogController.js +6 -8
  21. package/dist/esm/utils/oauthPkce.js +0 -4
  22. package/dist/esm/utils/officialOrigins.js +3 -68
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/HttpService.d.ts +18 -1
  25. package/dist/types/index.d.ts +3 -5
  26. package/dist/types/mixins/OxyServices.auth.d.ts +1 -12
  27. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +1 -5
  28. package/dist/types/mixins/OxyServices.user.d.ts +2 -0
  29. package/dist/types/server/index.d.ts +1 -1
  30. package/dist/types/session/accountDialogController.d.ts +9 -15
  31. package/dist/types/utils/oauthPkce.d.ts +11 -7
  32. package/dist/types/utils/officialOrigins.d.ts +3 -13
  33. package/package.json +4 -4
  34. package/src/HttpService.ts +67 -6
  35. package/src/__tests__/inSessionRefresh.test.ts +67 -0
  36. package/src/index.ts +6 -14
  37. package/src/mixins/OxyServices.auth.ts +6 -13
  38. package/src/mixins/OxyServices.deviceBoot.ts +0 -47
  39. package/src/mixins/OxyServices.privacy.ts +6 -0
  40. package/src/mixins/OxyServices.user.ts +3 -0
  41. package/src/mixins/__tests__/commonsSignIn.test.ts +9 -2
  42. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
  43. package/src/server/index.ts +1 -8
  44. package/src/server/rateLimit.ts +3 -0
  45. package/src/session/__tests__/accountDialogController.test.ts +3 -5
  46. package/src/session/accountDialogController.ts +12 -18
  47. package/src/utils/__tests__/officialOrigins.test.ts +0 -57
  48. package/src/utils/oauthPkce.ts +11 -9
  49. package/src/utils/officialOrigins.ts +3 -70
  50. package/dist/cjs/session/hubSync.js +0 -55
  51. package/dist/esm/session/hubSync.js +0 -51
  52. package/dist/types/session/hubSync.d.ts +0 -20
  53. package/src/session/__tests__/hubSync.test.ts +0 -51
  54. package/src/session/hubSync.ts +0 -79
@@ -12,6 +12,8 @@
12
12
  * degrade-to-QR behaviour every ambiguous input must produce.
13
13
  */
14
14
 
15
+ import type { CommonsDenyReason } from '@oxyhq/contracts';
16
+ import { COMMONS_DENY_REASONS } from '@oxyhq/contracts';
15
17
  import type { SessionLoginResponse } from '../../models/session';
16
18
  import type { ChallengeResponse } from '../OxyServices.auth';
17
19
  import type { CommonsDeliveryPlatform } from '../../utils/commonsDelivery';
@@ -769,12 +771,17 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
769
771
  );
770
772
  });
771
773
 
772
- it.each([['declined'], ['not_me']] as const)(
774
+ // Iterating the CONTRACT set (rather than a local literal) is the point: the
775
+ // SDK, the API request schema and the persisted `AuthSession.deniedReason`
776
+ // enum all read this one declaration, so a value added on the server side
777
+ // without an SDK release — or the reverse — cannot go unnoticed here.
778
+ it.each([...COMMONS_DENY_REASONS])(
773
779
  'sends the closed-set reason %s in the body',
774
780
  async (reason) => {
775
781
  makeRequestSpy.mockResolvedValue({ success: true });
776
782
 
777
- await oxy.denyCommonsSignIn('code-1', reason);
783
+ const typedReason: CommonsDenyReason = reason;
784
+ await oxy.denyCommonsSignIn('code-1', typedReason);
778
785
 
779
786
  expect(makeRequestSpy).toHaveBeenCalledWith(
780
787
  'POST',
@@ -134,10 +134,12 @@ describe('privacy cache invalidation', () => {
134
134
  fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
135
135
  await oxy.blockUser('u1');
136
136
  expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/blocked');
137
+ expect(clearSpy).toHaveBeenCalledWith('GET:/users/me/graph');
137
138
 
138
139
  fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
139
140
  await oxy.restrictUser('u2');
140
141
  expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/restricted');
142
+ expect(clearSpy).toHaveBeenCalledWith('GET:/users/me/graph');
141
143
 
142
144
  fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
143
145
  await oxy.updatePrivacySettings({ isPrivateAccount: true }, 'me');
@@ -71,11 +71,4 @@ export { verifySecret } from './verifySecret';
71
71
  // Pure host handling (no browser deps), so it is safe on the server subpath and
72
72
  // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
73
73
  export { registrableApex } from '../utils/registrableApex';
74
- export {
75
- buildIdpHubOrigin,
76
- buildHubSyncUrl,
77
- isIdpHubOrigin,
78
- isOfficialWebOrigin,
79
- normalizeOfficialReturnOrigin,
80
- parseHubSyncReturnUrl,
81
- } from '../utils/officialOrigins';
74
+ export { isOfficialWebOrigin } from '../utils/officialOrigins';
@@ -278,6 +278,9 @@ export function createOxyRateLimit(
278
278
  standardHeaders: true,
279
279
  legacyHeaders: false,
280
280
  skip,
281
+ // hashAnonymousIp already buckets IPv6 to /56 before HMAC; disable the v8
282
+ // static source scan that false-positives on req.ip (ERR_ERL_KEY_GEN_IPV6).
283
+ validate: { keyGeneratorIpFallback: false },
281
284
  });
282
285
 
283
286
  return (req, res, next) => {
@@ -433,11 +433,9 @@ describe('AccountDialogController — switchTo (uniform switch)', () => {
433
433
  expect(commitSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
434
434
  });
435
435
 
436
- it('commits a graph switch via the IN-PLACE commitSwitchedSession — never the hub-syncing commitSession', async () => {
437
- // PROBLEM 2: an account switch must not run the cross-origin hub-sync
438
- // full-page redirect. When both funnels are wired, the mint-switch must use
439
- // commitSwitchedSession (in-place) and NEVER commitSession (which may
440
- // redirect on an official web origin).
436
+ it('commits a graph switch via the IN-PLACE commitSwitchedSession — never commitSession', async () => {
437
+ // An account switch must use commitSwitchedSession (in-place) and NEVER
438
+ // commitSession when both funnels are wired.
441
439
  const oxy = makeOxy();
442
440
  const sc = new TestSessionClient(host());
443
441
  sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
@@ -302,21 +302,17 @@ export interface AccountDialogControllerOptions {
302
302
  * `SessionClient.registerAndActivate` (registration + activation only — no
303
303
  * provider-side durable persist/hydration).
304
304
  *
305
- * This is the SIGN-IN commit: on an official web origin it may run the
306
- * cross-origin hub-sync (a full-page redirect to `auth.oxy.so/sync`) that
307
- * bootstraps silent OAuth restore on OTHER origins. A first sign-in on a web
308
- * origin legitimately needs that. An account SWITCH does NOT — see
309
- * {@link commitSwitchedSession}.
305
+ * This is the SIGN-IN commit: registers the session into the host's device
306
+ * set with durable persist + profile hydration. An account SWITCH uses
307
+ * {@link commitSwitchedSession} instead see below.
310
308
  */
311
309
  commitSession?: (session: SessionLoginResponse) => Promise<void>;
312
310
  /**
313
311
  * Commit a minted graph SWITCH session into the host's session set — same
314
312
  * device-first registration + durable persist + profile hydration as
315
- * {@link commitSession}, but IN-PLACE: it must NOT trigger the cross-origin
316
- * hub-sync redirect. Switching into an account you already operate reuses the
317
- * device credential that was already hub-synced at the original sign-in, so
318
- * re-syncing is redundant and a full-page redirect on switch is the exact
319
- * regression this separation prevents. Cross-tab/app propagation of the switch
313
+ * {@link commitSession}, but IN-PLACE: it must NOT re-run sign-in side effects
314
+ * that belong only to a fresh authorization (for example, a redundant full
315
+ * device-set reconcile on switch). Cross-tab/app propagation of the switch
320
316
  * still happens instantly via the server's device-scoped `session_state` /
321
317
  * `session_accounts_changed` socket broadcast — no navigation required.
322
318
  *
@@ -811,9 +807,9 @@ export class AccountDialogController {
811
807
  accessToken: result.accessToken,
812
808
  },
813
809
  result.user,
814
- // A switch is IN-PLACE: commit without the hub-sync redirect (the
815
- // device is already known/synced). Cross-tab/app propagation rides the
816
- // server's `session_state` socket broadcast, not a navigation.
810
+ // A switch is IN-PLACE: use the switch commit funnel (not sign-in).
811
+ // Cross-tab/app propagation rides the server's `session_state` socket
812
+ // broadcast, not a navigation.
817
813
  { fromSwitch: true },
818
814
  );
819
815
  }
@@ -1349,11 +1345,9 @@ export class AccountDialogController {
1349
1345
  * consumer's commit funnel (durable persist + hydration); falls back to
1350
1346
  * `SessionClient.registerAndActivate` (registration + activation only).
1351
1347
  *
1352
- * A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel
1353
- * so it never runs the cross-origin hub-sync redirect; a SIGN-IN uses
1354
- * `commitSession` (which may hub-sync on an official web origin). When the
1355
- * switch funnel is not wired it falls back to the sign-in funnel, then to
1356
- * `registerAndActivate`.
1348
+ * A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel;
1349
+ * a SIGN-IN uses `commitSession`. When the switch funnel is not wired it falls
1350
+ * back to the sign-in funnel, then to `registerAndActivate`.
1357
1351
  */
1358
1352
  private async commitAuthorizedSession(
1359
1353
  session: SessionLoginResponse,
@@ -1,19 +1,10 @@
1
1
  import {
2
- buildHubSyncUrl,
3
- buildIdpHubOrigin,
4
2
  isAllowedDeviceJoinOrigin,
5
- isIdpHubOrigin,
6
3
  isLoopbackOrigin,
7
4
  isOfficialWebOrigin,
8
- normalizeOfficialReturnOrigin,
9
- parseHubSyncReturnUrl,
10
5
  } from '../officialOrigins';
11
6
 
12
7
  describe('officialOrigins', () => {
13
- it('builds the IdP hub origin', () => {
14
- expect(buildIdpHubOrigin()).toBe('https://auth.oxy.so');
15
- });
16
-
17
8
  it('allows official first-party origins', () => {
18
9
  expect(isOfficialWebOrigin('https://inbox.oxy.so')).toBe(true);
19
10
  expect(isOfficialWebOrigin('https://mention.earth')).toBe(true);
@@ -37,52 +28,4 @@ describe('officialOrigins', () => {
37
28
  expect(isAllowedDeviceJoinOrigin('https://accounts.oxy.so')).toBe(true);
38
29
  expect(isAllowedDeviceJoinOrigin('https://evil.example')).toBe(false);
39
30
  });
40
-
41
- it('normalizes return origins to origin only', () => {
42
- expect(normalizeOfficialReturnOrigin('https://accounts.oxy.so/settings')).toBe(
43
- 'https://accounts.oxy.so',
44
- );
45
- expect(normalizeOfficialReturnOrigin('https://evil.example/')).toBeNull();
46
- });
47
-
48
- it('parses hub-sync return URLs', () => {
49
- expect(parseHubSyncReturnUrl('https://inbox.oxy.so/messages')).toBe(
50
- 'https://inbox.oxy.so/messages',
51
- );
52
- expect(parseHubSyncReturnUrl('https://evil.example/')).toBeNull();
53
- });
54
-
55
- it('builds hub sync URLs with ticket and optional return', () => {
56
- const url = new URL(buildHubSyncUrl('tk-abc', 'https://accounts.oxy.so/'));
57
- expect(url.pathname).toBe('/sync');
58
- expect(url.searchParams.get('ticket')).toBe('tk-abc');
59
- expect(url.searchParams.get('return')).toBe('https://accounts.oxy.so/');
60
- });
61
-
62
- describe('isIdpHubOrigin', () => {
63
- const originalLocation = globalThis.location;
64
-
65
- afterEach(() => {
66
- Object.defineProperty(globalThis, 'location', {
67
- configurable: true,
68
- value: originalLocation,
69
- });
70
- });
71
-
72
- it('returns true on auth.oxy.so', () => {
73
- Object.defineProperty(globalThis, 'location', {
74
- configurable: true,
75
- value: { href: 'https://auth.oxy.so/sync' },
76
- });
77
- expect(isIdpHubOrigin()).toBe(true);
78
- });
79
-
80
- it('returns false on satellite origins', () => {
81
- Object.defineProperty(globalThis, 'location', {
82
- configurable: true,
83
- value: { href: 'https://inbox.oxy.so/' },
84
- });
85
- expect(isIdpHubOrigin()).toBe(false);
86
- });
87
- });
88
31
  });
@@ -62,10 +62,18 @@ export interface BuildOAuthAuthorizeUrlParams {
62
62
  /** The PKCE `codeChallenge` from {@link generatePkcePair}. */
63
63
  codeChallenge: string;
64
64
  /**
65
- * OAuth `prompt` parameter. Use `none` for silent cross-origin session restore
66
- * (no UI when the IdP hub already has a session + grant).
65
+ * OAuth `prompt` parameter `login` forces a fresh authentication even when
66
+ * the IdP already has a session, `consent` forces the consent screen even for
67
+ * an already-granted scope. Both are ordinary OAuth/OIDC and are here for
68
+ * third-party relying parties building their own authorize link.
69
+ *
70
+ * `'none'` is deliberately NOT in this union. It is the silent-SSO value, and
71
+ * it was the only value this SDK ever sent — from the cold-boot cross-origin
72
+ * restore deleted in #691 phase 7b. Accepting it again would hand consumers a
73
+ * one-line rebuild of the automatic, gesture-less full-page bounce to the IdP
74
+ * that the popup transport exists to eliminate.
67
75
  */
68
- prompt?: 'none' | 'login' | 'consent';
76
+ prompt?: 'login' | 'consent';
69
77
  /**
70
78
  * How the IdP should deliver the authorization response. Omitted (the
71
79
  * default) means the ordinary top-level redirect back to `redirectUri`.
@@ -220,12 +228,6 @@ export const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = 'oxy_oauth_code_verifier';
220
228
  /** `sessionStorage` key — the exact `redirect_uri` sent on the authorize request. */
221
229
  export const OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = 'oxy.oauth_redirect_uri';
222
230
 
223
- /** `sessionStorage` key — at most one silent OAuth attempt per navigation. */
224
- export const OXY_SILENT_OAUTH_ATTEMPTED_KEY = 'oxy.silent_oauth_attempted';
225
-
226
- /** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
227
- export const OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = 'oxy.cross_origin_restore_attempted';
228
-
229
231
  /**
230
232
  * `sessionStorage` key for the in-app path to return to after an authorize
231
233
  * round trip. See {@link persistOAuthReturnPath}.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Official first-party web origin allowlist — shared by hub-ticket issuance,
3
- * OAuth redirect validation, and cross-origin session restore.
2
+ * Official first-party web origin allowlist — shared by OAuth redirect
3
+ * validation and the server-side trusted-origin checks.
4
4
  */
5
5
 
6
6
  import { CENTRAL_IDP_APEX } from './authWebUrl';
@@ -20,31 +20,9 @@ const OFFICIAL_APEXES = new Set([
20
20
  'mercaria.co',
21
21
  ]);
22
22
 
23
- export function buildIdpHubOrigin(): string {
24
- return `https://auth.${CENTRAL_IDP_APEX}`;
25
- }
26
-
27
- /** Whether the current web origin is the central IdP hub (`auth.oxy.so`). */
28
- export function isIdpHubOrigin(): boolean {
29
- if (typeof globalThis === 'undefined') {
30
- return false;
31
- }
32
- const location = (globalThis as { location?: Location }).location;
33
- if (!location) {
34
- return false;
35
- }
36
- try {
37
- const { hostname } = new URL(location.href);
38
- return hostname === `auth.${CENTRAL_IDP_APEX}`;
39
- } catch {
40
- return false;
41
- }
42
- }
43
-
44
23
  /**
45
24
  * Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
46
- * or `[::1]` on any port, http or https). Local dev must never be bounced to a
47
- * hosted IdP for cross-origin session restore.
25
+ * or `[::1]` on any port, http or https).
48
26
  */
49
27
  export function isLoopbackOrigin(origin: string): boolean {
50
28
  try {
@@ -80,50 +58,5 @@ export function isOfficialWebOrigin(origin: string): boolean {
80
58
  }
81
59
  }
82
60
 
83
- /** Normalize and validate a return URL against official origins. Returns origin only. */
84
- export function normalizeOfficialReturnOrigin(raw: string): string | null {
85
- try {
86
- const parsed = new URL(raw);
87
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
88
- return null;
89
- }
90
- if (!isOfficialWebOrigin(parsed.origin)) {
91
- return null;
92
- }
93
- return parsed.origin;
94
- } catch {
95
- return null;
96
- }
97
- }
98
-
99
- /** Validate a hub-sync return URL; returns the full normalized URL string. */
100
- export function parseHubSyncReturnUrl(raw: string | null): string | null {
101
- if (!raw) {
102
- return null;
103
- }
104
- try {
105
- const parsed = new URL(raw);
106
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
107
- return null;
108
- }
109
- if (!isOfficialWebOrigin(parsed.origin)) {
110
- return null;
111
- }
112
- return parsed.toString();
113
- } catch {
114
- return null;
115
- }
116
- }
117
-
118
- /** Build auth.oxy.so/sync redirect URL with a one-time hub ticket. */
119
- export function buildHubSyncUrl(ticket: string, returnUrl?: string): string {
120
- const url = new URL('/sync', buildIdpHubOrigin());
121
- url.searchParams.set('ticket', ticket);
122
- if (returnUrl) {
123
- url.searchParams.set('return', returnUrl);
124
- }
125
- return url.toString();
126
- }
127
-
128
61
  /** @deprecated Use {@link isOfficialWebOrigin}. */
129
62
  export const isAllowedDeviceJoinOrigin = isOfficialWebOrigin;
@@ -1,55 +0,0 @@
1
- "use strict";
2
- /**
3
- * Post-sign-in hub sync — plant device credentials on auth.oxy.so via a
4
- * one-time server ticket (no secrets in URL fragments).
5
- */
6
- Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.syncHubAfterSignIn = syncHubAfterSignIn;
8
- exports.redeemHubTicketOnHub = redeemHubTicketOnHub;
9
- const officialOrigins_1 = require("../utils/officialOrigins");
10
- /**
11
- * After a successful sign-in on an official web app, mint a hub ticket and
12
- * redirect to auth.oxy.so/sync so the IdP hub can redeem it and persist the
13
- * shared device credential for silent OAuth restore on other origins.
14
- *
15
- * No-op on native, non-official origins, and when already on the IdP hub.
16
- */
17
- async function syncHubAfterSignIn(oxy, opts) {
18
- if (opts?.enabled === false) {
19
- return false;
20
- }
21
- if (typeof globalThis === 'undefined') {
22
- return false;
23
- }
24
- const location = globalThis.location;
25
- if (!location) {
26
- return false;
27
- }
28
- if ((0, officialOrigins_1.isIdpHubOrigin)()) {
29
- return false;
30
- }
31
- if (!(0, officialOrigins_1.isOfficialWebOrigin)(location.origin)) {
32
- return false;
33
- }
34
- const hubOrigin = (0, officialOrigins_1.buildIdpHubOrigin)();
35
- const issued = await oxy.issueHubTicket(hubOrigin);
36
- const returnUrl = `${location.origin}${location.pathname}${location.search}${location.hash}`;
37
- const syncUrl = (0, officialOrigins_1.buildHubSyncUrl)(issued.ticket, returnUrl);
38
- window.location.assign(syncUrl);
39
- return true;
40
- }
41
- /** Redeem a hub ticket on auth.oxy.so and persist credentials locally. */
42
- async function redeemHubTicketOnHub(oxy, store, ticket) {
43
- const hubOrigin = (0, officialOrigins_1.buildIdpHubOrigin)();
44
- const creds = await oxy.redeemHubTicket(ticket, hubOrigin);
45
- const prior = await store.load();
46
- await store.save({
47
- sessionId: prior?.sessionId ?? '',
48
- userId: prior?.userId ?? '',
49
- deviceId: creds.deviceId,
50
- deviceSecret: creds.deviceSecret,
51
- ...(prior?.accessToken ? { accessToken: prior.accessToken } : {}),
52
- ...(prior?.expiresAt ? { expiresAt: prior.expiresAt } : {}),
53
- });
54
- return true;
55
- }
@@ -1,51 +0,0 @@
1
- /**
2
- * Post-sign-in hub sync — plant device credentials on auth.oxy.so via a
3
- * one-time server ticket (no secrets in URL fragments).
4
- */
5
- import { buildHubSyncUrl, buildIdpHubOrigin, isIdpHubOrigin, isOfficialWebOrigin, } from '../utils/officialOrigins.js';
6
- /**
7
- * After a successful sign-in on an official web app, mint a hub ticket and
8
- * redirect to auth.oxy.so/sync so the IdP hub can redeem it and persist the
9
- * shared device credential for silent OAuth restore on other origins.
10
- *
11
- * No-op on native, non-official origins, and when already on the IdP hub.
12
- */
13
- export async function syncHubAfterSignIn(oxy, opts) {
14
- if (opts?.enabled === false) {
15
- return false;
16
- }
17
- if (typeof globalThis === 'undefined') {
18
- return false;
19
- }
20
- const location = globalThis.location;
21
- if (!location) {
22
- return false;
23
- }
24
- if (isIdpHubOrigin()) {
25
- return false;
26
- }
27
- if (!isOfficialWebOrigin(location.origin)) {
28
- return false;
29
- }
30
- const hubOrigin = buildIdpHubOrigin();
31
- const issued = await oxy.issueHubTicket(hubOrigin);
32
- const returnUrl = `${location.origin}${location.pathname}${location.search}${location.hash}`;
33
- const syncUrl = buildHubSyncUrl(issued.ticket, returnUrl);
34
- window.location.assign(syncUrl);
35
- return true;
36
- }
37
- /** Redeem a hub ticket on auth.oxy.so and persist credentials locally. */
38
- export async function redeemHubTicketOnHub(oxy, store, ticket) {
39
- const hubOrigin = buildIdpHubOrigin();
40
- const creds = await oxy.redeemHubTicket(ticket, hubOrigin);
41
- const prior = await store.load();
42
- await store.save({
43
- sessionId: prior?.sessionId ?? '',
44
- userId: prior?.userId ?? '',
45
- deviceId: creds.deviceId,
46
- deviceSecret: creds.deviceSecret,
47
- ...(prior?.accessToken ? { accessToken: prior.accessToken } : {}),
48
- ...(prior?.expiresAt ? { expiresAt: prior.expiresAt } : {}),
49
- });
50
- return true;
51
- }
@@ -1,20 +0,0 @@
1
- /**
2
- * Post-sign-in hub sync — plant device credentials on auth.oxy.so via a
3
- * one-time server ticket (no secrets in URL fragments).
4
- */
5
- import type { OxyServices } from '../OxyServices';
6
- import type { AuthStateStore } from './authStateStore';
7
- export interface SyncHubAfterSignInOptions {
8
- /** Skip sync when false (OxyProvider hubSync prop). @default true */
9
- enabled?: boolean;
10
- }
11
- /**
12
- * After a successful sign-in on an official web app, mint a hub ticket and
13
- * redirect to auth.oxy.so/sync so the IdP hub can redeem it and persist the
14
- * shared device credential for silent OAuth restore on other origins.
15
- *
16
- * No-op on native, non-official origins, and when already on the IdP hub.
17
- */
18
- export declare function syncHubAfterSignIn(oxy: Pick<OxyServices, 'issueHubTicket'>, opts?: SyncHubAfterSignInOptions): Promise<boolean>;
19
- /** Redeem a hub ticket on auth.oxy.so and persist credentials locally. */
20
- export declare function redeemHubTicketOnHub(oxy: Pick<OxyServices, 'redeemHubTicket'>, store: AuthStateStore, ticket: string): Promise<boolean>;
@@ -1,51 +0,0 @@
1
- import { syncHubAfterSignIn } from '../hubSync';
2
-
3
- jest.mock('../../utils/officialOrigins', () => {
4
- const actual = jest.requireActual('../../utils/officialOrigins');
5
- return {
6
- ...actual,
7
- isIdpHubOrigin: jest.fn(() => false),
8
- isOfficialWebOrigin: jest.fn(() => true),
9
- buildIdpHubOrigin: jest.fn(() => 'https://auth.oxy.so'),
10
- };
11
- });
12
-
13
- describe('syncHubAfterSignIn', () => {
14
- const originalLocation = globalThis.location;
15
- const originalAssign = (globalThis as { location?: Location }).location?.assign;
16
-
17
- afterEach(() => {
18
- Object.defineProperty(globalThis, 'location', {
19
- configurable: true,
20
- value: originalLocation,
21
- });
22
- jest.restoreAllMocks();
23
- });
24
-
25
- it('includes the hash fragment in the hub-sync return URL', async () => {
26
- const assign = jest.fn();
27
- Object.defineProperty(globalThis, 'location', {
28
- configurable: true,
29
- value: {
30
- origin: 'https://oxy.so',
31
- pathname: '/pricing',
32
- search: '?plan=pro',
33
- hash: '#compare',
34
- assign,
35
- },
36
- });
37
- Object.defineProperty(globalThis, 'window', {
38
- configurable: true,
39
- value: { location: globalThis.location },
40
- });
41
-
42
- const issueHubTicket = jest.fn().mockResolvedValue({ ticket: 'tk-1' });
43
-
44
- const redirected = await syncHubAfterSignIn({ issueHubTicket });
45
-
46
- expect(redirected).toBe(true);
47
- expect(issueHubTicket).toHaveBeenCalledWith('https://auth.oxy.so');
48
- const syncUrl = new URL(String(assign.mock.calls[0]?.[0] ?? ''));
49
- expect(syncUrl.searchParams.get('return')).toBe('https://oxy.so/pricing?plan=pro#compare');
50
- });
51
- });
@@ -1,79 +0,0 @@
1
- /**
2
- * Post-sign-in hub sync — plant device credentials on auth.oxy.so via a
3
- * one-time server ticket (no secrets in URL fragments).
4
- */
5
-
6
- import type { OxyServices } from '../OxyServices';
7
- import type { AuthStateStore } from './authStateStore';
8
- import {
9
- buildHubSyncUrl,
10
- buildIdpHubOrigin,
11
- isIdpHubOrigin,
12
- isOfficialWebOrigin,
13
- } from '../utils/officialOrigins';
14
-
15
- export interface SyncHubAfterSignInOptions {
16
- /** Skip sync when false (OxyProvider hubSync prop). @default true */
17
- enabled?: boolean;
18
- }
19
-
20
- /**
21
- * After a successful sign-in on an official web app, mint a hub ticket and
22
- * redirect to auth.oxy.so/sync so the IdP hub can redeem it and persist the
23
- * shared device credential for silent OAuth restore on other origins.
24
- *
25
- * No-op on native, non-official origins, and when already on the IdP hub.
26
- */
27
- export async function syncHubAfterSignIn(
28
- oxy: Pick<OxyServices, 'issueHubTicket'>,
29
- opts?: SyncHubAfterSignInOptions,
30
- ): Promise<boolean> {
31
- if (opts?.enabled === false) {
32
- return false;
33
- }
34
-
35
- if (typeof globalThis === 'undefined') {
36
- return false;
37
- }
38
-
39
- const location = (globalThis as { location?: Location }).location;
40
- if (!location) {
41
- return false;
42
- }
43
-
44
- if (isIdpHubOrigin()) {
45
- return false;
46
- }
47
-
48
- if (!isOfficialWebOrigin(location.origin)) {
49
- return false;
50
- }
51
-
52
- const hubOrigin = buildIdpHubOrigin();
53
- const issued = await oxy.issueHubTicket(hubOrigin);
54
- const returnUrl = `${location.origin}${location.pathname}${location.search}${location.hash}`;
55
- const syncUrl = buildHubSyncUrl(issued.ticket, returnUrl);
56
-
57
- window.location.assign(syncUrl);
58
- return true;
59
- }
60
-
61
- /** Redeem a hub ticket on auth.oxy.so and persist credentials locally. */
62
- export async function redeemHubTicketOnHub(
63
- oxy: Pick<OxyServices, 'redeemHubTicket'>,
64
- store: AuthStateStore,
65
- ticket: string,
66
- ): Promise<boolean> {
67
- const hubOrigin = buildIdpHubOrigin();
68
- const creds = await oxy.redeemHubTicket(ticket, hubOrigin);
69
- const prior = await store.load();
70
- await store.save({
71
- sessionId: prior?.sessionId ?? '',
72
- userId: prior?.userId ?? '',
73
- deviceId: creds.deviceId,
74
- deviceSecret: creds.deviceSecret,
75
- ...(prior?.accessToken ? { accessToken: prior.accessToken } : {}),
76
- ...(prior?.expiresAt ? { expiresAt: prior.expiresAt } : {}),
77
- });
78
- return true;
79
- }