@oxyhq/core 12.11.1 → 13.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.
Files changed (56) hide show
  1. package/README.md +36 -2
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +4 -13
  4. package/dist/cjs/mixins/OxyServices.deviceBoot.js +0 -31
  5. package/dist/cjs/mixins/OxyServices.user.js +50 -44
  6. package/dist/cjs/server/index.js +9 -6
  7. package/dist/cjs/server/rateLimit.js +3 -0
  8. package/dist/cjs/server/securityHeaders.js +234 -0
  9. package/dist/cjs/session/accountDialogController.js +6 -8
  10. package/dist/cjs/utils/apiUtils.js +40 -10
  11. package/dist/cjs/utils/oauthPkce.js +1 -5
  12. package/dist/cjs/utils/officialOrigins.js +3 -73
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/index.js +3 -4
  15. package/dist/esm/mixins/OxyServices.deviceBoot.js +1 -32
  16. package/dist/esm/mixins/OxyServices.user.js +51 -45
  17. package/dist/esm/server/index.js +4 -1
  18. package/dist/esm/server/rateLimit.js +3 -0
  19. package/dist/esm/server/securityHeaders.js +224 -0
  20. package/dist/esm/session/accountDialogController.js +6 -8
  21. package/dist/esm/utils/apiUtils.js +39 -10
  22. package/dist/esm/utils/oauthPkce.js +0 -4
  23. package/dist/esm/utils/officialOrigins.js +3 -68
  24. package/dist/types/.tsbuildinfo +1 -1
  25. package/dist/types/index.d.ts +5 -7
  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 +27 -6
  29. package/dist/types/server/index.d.ts +3 -1
  30. package/dist/types/server/securityHeaders.d.ts +154 -0
  31. package/dist/types/session/accountDialogController.d.ts +9 -15
  32. package/dist/types/utils/apiUtils.d.ts +48 -6
  33. package/dist/types/utils/oauthPkce.d.ts +11 -7
  34. package/dist/types/utils/officialOrigins.d.ts +3 -13
  35. package/package.json +10 -5
  36. package/src/index.ts +9 -14
  37. package/src/mixins/OxyServices.auth.ts +6 -13
  38. package/src/mixins/OxyServices.deviceBoot.ts +0 -47
  39. package/src/mixins/OxyServices.user.ts +60 -49
  40. package/src/mixins/__tests__/commonsSignIn.test.ts +9 -2
  41. package/src/mixins/__tests__/followGraphPagination.test.ts +250 -0
  42. package/src/server/__tests__/securityHeaders.test.ts +244 -0
  43. package/src/server/index.ts +17 -8
  44. package/src/server/rateLimit.ts +3 -0
  45. package/src/server/securityHeaders.ts +304 -0
  46. package/src/session/__tests__/accountDialogController.test.ts +3 -5
  47. package/src/session/accountDialogController.ts +12 -18
  48. package/src/utils/__tests__/officialOrigins.test.ts +0 -57
  49. package/src/utils/apiUtils.ts +64 -15
  50. package/src/utils/oauthPkce.ts +11 -9
  51. package/src/utils/officialOrigins.ts +3 -70
  52. package/dist/cjs/session/hubSync.js +0 -55
  53. package/dist/esm/session/hubSync.js +0 -51
  54. package/dist/types/session/hubSync.d.ts +0 -20
  55. package/src/session/__tests__/hubSync.test.ts +0 -51
  56. package/src/session/hubSync.ts +0 -79
@@ -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
  });
@@ -3,20 +3,46 @@
3
3
  */
4
4
 
5
5
  /**
6
- * Build URL search parameters from an object
7
- * @param params Object with parameter key-value pairs
8
- * @returns URLSearchParams instance
6
+ * Build a plain query-parameter record from an object, stringifying values and
7
+ * dropping `undefined`/`null` entries.
8
+ *
9
+ * This is the shape `OxyServices.makeRequest` expects for a GET's `params`:
10
+ * `HttpService` inspects it with `Object.keys(...)` (both to decide whether to
11
+ * append a query string and to build the request's cache key), and
12
+ * `Object.keys(new URLSearchParams({ limit: '20' }))` is `[]` — a
13
+ * `URLSearchParams` exposes its entries through iterator methods, never as own
14
+ * enumerable properties. Passing one to `makeRequest` therefore silently drops
15
+ * the whole query string. Always hand `makeRequest` a plain record.
16
+ *
17
+ * Generic over the input object rather than taking `Record<string, unknown>`,
18
+ * because a TypeScript `interface` (`PaginationParams`, `FollowGraphParams`, …)
19
+ * has no implicit index signature and so is not assignable to that type.
9
20
  */
10
- export function buildSearchParams(params: Record<string, any>): URLSearchParams {
11
- const searchParams = new URLSearchParams();
12
-
13
- for (const [key, value] of Object.entries(params)) {
21
+ export function buildQueryParams<T extends object>(params: T): Record<string, string> {
22
+ const query: Record<string, string> = {};
23
+
24
+ // Widening the value to `unknown` is always sound; the default overload of
25
+ // `Object.entries` would otherwise infer `any` here.
26
+ for (const [key, value] of Object.entries(params) as [string, unknown][]) {
14
27
  if (value !== undefined && value !== null) {
15
- searchParams.append(key, value.toString());
28
+ query[key] = String(value);
16
29
  }
17
30
  }
18
-
19
- return searchParams;
31
+
32
+ return query;
33
+ }
34
+
35
+ /**
36
+ * Build URL search parameters from an object.
37
+ *
38
+ * For building a URL string only — see {@link buildQueryParams} for the shape
39
+ * `makeRequest` needs.
40
+ *
41
+ * @param params Object with parameter key-value pairs
42
+ * @returns URLSearchParams instance
43
+ */
44
+ export function buildSearchParams<T extends object>(params: T): URLSearchParams {
45
+ return new URLSearchParams(buildQueryParams(params));
20
46
  }
21
47
 
22
48
  /**
@@ -25,7 +51,7 @@ export function buildSearchParams(params: Record<string, any>): URLSearchParams
25
51
  * @param params Object with parameter key-value pairs
26
52
  * @returns Complete URL with search parameters
27
53
  */
28
- export function buildUrl(baseUrl: string, params?: Record<string, any>): string {
54
+ export function buildUrl<T extends object>(baseUrl: string, params?: T): string {
29
55
  if (!params) return baseUrl;
30
56
 
31
57
  const searchParams = buildSearchParams(params);
@@ -43,12 +69,35 @@ export interface PaginationParams {
43
69
  }
44
70
 
45
71
  /**
46
- * Build pagination search parameters
72
+ * Ordering for the follow-graph list endpoints (`/users/:id/followers`,
73
+ * `/users/:id/following`, `/users/:id/mutuals`).
74
+ *
75
+ * - `recent` — newest follow edge first (the server default).
76
+ * - `oldest` — oldest follow edge first.
77
+ */
78
+ export type FollowGraphSort = 'recent' | 'oldest';
79
+
80
+ /**
81
+ * Pagination plus the follow-graph ordering.
82
+ *
83
+ * Kept separate from {@link PaginationParams}, which is shared by endpoints
84
+ * that have no `sort` at all.
85
+ */
86
+ export interface FollowGraphParams extends PaginationParams {
87
+ sort?: FollowGraphSort;
88
+ }
89
+
90
+ /**
91
+ * Build pagination query parameters.
92
+ *
93
+ * Returns a plain record — NOT a `URLSearchParams` — because that is the only
94
+ * shape `makeRequest`/`HttpService` can read. See {@link buildQueryParams}.
95
+ *
47
96
  * @param params Pagination parameters
48
- * @returns URLSearchParams with pagination
97
+ * @returns Query record with pagination
49
98
  */
50
- export function buildPaginationParams(params: PaginationParams): URLSearchParams {
51
- return buildSearchParams(params);
99
+ export function buildPaginationParams(params: PaginationParams): Record<string, string> {
100
+ return buildQueryParams(params);
52
101
  }
53
102
 
54
103
  /**
@@ -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
- }