@hubspot/app-connect-sdk 1.0.0-alpha.13 → 1.0.0-alpha.14
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.
- package/.turbo/turbo-format$colon$check.log +1 -1
- package/.turbo/turbo-test.log +60 -55
- package/.turbo/turbo-tsdown.log +12 -12
- package/build/tsconfig.browser.tsbuildinfo +1 -1
- package/build/tsconfig.server.tsbuildinfo +1 -1
- package/dist/browser/{create-crdncXsh.js → create-CWpWpZ9y.js} +194 -60
- package/dist/browser/create-CWpWpZ9y.js.map +1 -0
- package/dist/browser/index.d.ts +2 -2
- package/dist/browser/index.js +1 -1
- package/dist/browser/react/lovable.d.ts +8 -0
- package/dist/browser/react/lovable.js +6 -3
- package/dist/browser/react/lovable.js.map +1 -1
- package/dist/browser/react.d.ts +1 -1
- package/dist/browser/{types-rTQw6A54.d.ts → types-DkAmHcZt.d.ts} +22 -7
- package/dist/server/shared/constants.js.map +1 -1
- package/package.json +3 -3
- package/src/browser/app-connect-controller/README.md +4 -2
- package/src/browser/app-connect-controller/connect-start.test.ts +145 -0
- package/src/browser/app-connect-controller/connect-start.ts +18 -3
- package/src/browser/app-connect-controller/init.test.ts +44 -2
- package/src/browser/app-connect-controller/init.ts +9 -4
- package/src/browser/app-connect-controller/oauth-popup.test.ts +166 -0
- package/src/browser/app-connect-controller/oauth-popup.ts +132 -0
- package/src/browser/app-connect-controller/utils/is-app-embedded-in-iframe.ts +12 -0
- package/src/browser/app-connect-controller/utils/resolve-oauth-connect-mode.test.ts +35 -0
- package/src/browser/app-connect-controller/utils/resolve-oauth-connect-mode.ts +21 -0
- package/src/browser/index.ts +1 -0
- package/src/browser/react/lovable/LovableHubSpotAppConnect.tsx +12 -2
- package/src/browser/types.ts +21 -5
- package/src/shared/constants.ts +6 -0
- package/dist/browser/create-crdncXsh.js.map +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { noopLogger } from '../../shared/logger.ts';
|
|
4
|
+
import { startHubSpotConnection } from './connect-start.ts';
|
|
5
|
+
import type {
|
|
6
|
+
AppConnectContext,
|
|
7
|
+
AppConnectInternalState,
|
|
8
|
+
SessionStorage,
|
|
9
|
+
} from './types.ts';
|
|
10
|
+
import { createStore } from './utils/store-utils.ts';
|
|
11
|
+
|
|
12
|
+
const HUBSPOT_CONNECT_BASE_URL =
|
|
13
|
+
'https://edge.example.com/functions/v1/hubspot-connect';
|
|
14
|
+
|
|
15
|
+
function createInMemorySessionStorage(): SessionStorage {
|
|
16
|
+
const map = new Map<string, string>();
|
|
17
|
+
return {
|
|
18
|
+
getItem: (key) => map.get(key) ?? null,
|
|
19
|
+
setItem: (key, value) => {
|
|
20
|
+
map.set(key, value);
|
|
21
|
+
},
|
|
22
|
+
removeItem: (key) => {
|
|
23
|
+
map.delete(key);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createTestContext(
|
|
29
|
+
oauthConnectMode?: AppConnectContext['config']['oauthConnectMode']
|
|
30
|
+
): AppConnectContext {
|
|
31
|
+
const initialState: AppConnectInternalState = {
|
|
32
|
+
isInitComplete: true,
|
|
33
|
+
isConnectInFlight: true,
|
|
34
|
+
isSessionConnected: false,
|
|
35
|
+
isDisconnectInFlight: false,
|
|
36
|
+
error: null,
|
|
37
|
+
expiresAt: null,
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
config: {
|
|
41
|
+
hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL,
|
|
42
|
+
...(oauthConnectMode !== undefined ? { oauthConnectMode } : {}),
|
|
43
|
+
},
|
|
44
|
+
logger: noopLogger,
|
|
45
|
+
sessionStorage: createInMemorySessionStorage(),
|
|
46
|
+
store: createStore<AppConnectInternalState>(initialState),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('startHubSpotConnection', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.useFakeTimers();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.unstubAllGlobals();
|
|
57
|
+
vi.restoreAllMocks();
|
|
58
|
+
vi.useRealTimers();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('redirects the browser when oauthConnectMode is redirect', async () => {
|
|
62
|
+
const top = {};
|
|
63
|
+
const location = {
|
|
64
|
+
origin: 'https://app.example.com',
|
|
65
|
+
pathname: '/dashboard',
|
|
66
|
+
search: '?tab=1',
|
|
67
|
+
href: '',
|
|
68
|
+
};
|
|
69
|
+
vi.stubGlobal('window', {
|
|
70
|
+
self: top,
|
|
71
|
+
top,
|
|
72
|
+
location,
|
|
73
|
+
open: vi.fn(),
|
|
74
|
+
addEventListener: vi.fn(),
|
|
75
|
+
removeEventListener: vi.fn(),
|
|
76
|
+
});
|
|
77
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
78
|
+
new Response(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
authorization_url: 'https://auth.example/authorize',
|
|
81
|
+
}),
|
|
82
|
+
{ status: 200 }
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const context = createTestContext('redirect');
|
|
87
|
+
const connectPromise = startHubSpotConnection(context);
|
|
88
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
89
|
+
await connectPromise;
|
|
90
|
+
|
|
91
|
+
expect(location.href).toBe('https://auth.example/authorize');
|
|
92
|
+
expect(window.open).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('opens a popup when oauthConnectMode is popup', async () => {
|
|
96
|
+
const top = {};
|
|
97
|
+
const popup = { closed: false, close: vi.fn() };
|
|
98
|
+
const messageListeners: Array<(event: MessageEvent) => void> = [];
|
|
99
|
+
|
|
100
|
+
vi.stubGlobal('window', {
|
|
101
|
+
self: top,
|
|
102
|
+
top,
|
|
103
|
+
location: {
|
|
104
|
+
origin: 'https://app.example.com',
|
|
105
|
+
pathname: '/dashboard',
|
|
106
|
+
search: '',
|
|
107
|
+
},
|
|
108
|
+
open: vi.fn(() => popup),
|
|
109
|
+
addEventListener: (
|
|
110
|
+
type: string,
|
|
111
|
+
listener: (event: MessageEvent) => void
|
|
112
|
+
) => {
|
|
113
|
+
if (type === 'message') messageListeners.push(listener);
|
|
114
|
+
},
|
|
115
|
+
removeEventListener: vi.fn(),
|
|
116
|
+
});
|
|
117
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
118
|
+
new Response(
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
authorization_url: 'https://auth.example/authorize',
|
|
121
|
+
}),
|
|
122
|
+
{ status: 200 }
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const expiresAt = Date.now() + 1800 * 1000;
|
|
127
|
+
const context = createTestContext('popup');
|
|
128
|
+
const connectPromise = startHubSpotConnection(context);
|
|
129
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
130
|
+
|
|
131
|
+
expect(window.open).toHaveBeenCalledWith(
|
|
132
|
+
'https://auth.example/authorize',
|
|
133
|
+
expect.any(String),
|
|
134
|
+
expect.stringContaining('popup=yes')
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
messageListeners[0]!({
|
|
138
|
+
origin: 'https://app.example.com',
|
|
139
|
+
data: { type: 'hubspot-app-connect:oauth-complete', expiresAt },
|
|
140
|
+
} as MessageEvent);
|
|
141
|
+
|
|
142
|
+
await connectPromise;
|
|
143
|
+
expect(context.store.getSnapshot().expiresAt).toBe(expiresAt);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { InitSessionResponse } from '../../shared/wire-types.ts';
|
|
2
|
+
import { waitForHubSpotOAuthPopup } from './oauth-popup.ts';
|
|
2
3
|
import type { AppConnectContext } from './types.ts';
|
|
4
|
+
import { resolveOAuthConnectMode } from './utils/resolve-oauth-connect-mode.ts';
|
|
3
5
|
import { delay } from './utils/timeout-utils.ts';
|
|
4
6
|
|
|
5
7
|
/** Extra wait before redirect so the connect progress UI is visible; set to `0` to disable. */
|
|
@@ -10,12 +12,14 @@ const ARTIFICIAL_CONNECT_REDIRECT_DELAY_MS = 500;
|
|
|
10
12
|
*
|
|
11
13
|
* 1. Calls the SDK's `auth/init-session` route to mint a fresh PKCE
|
|
12
14
|
* verifier + state and obtain HubSpot's `authorize` URL.
|
|
13
|
-
* 2. Navigates
|
|
15
|
+
* 2. Navigates to that URL via full-page redirect, or opens it in a
|
|
16
|
+
* popup when embedded in an iframe or when `oauthConnectMode` is
|
|
17
|
+
* `'popup'`.
|
|
14
18
|
*
|
|
15
19
|
* The `return_path` is the current path + query so the user lands
|
|
16
|
-
* back where they started after authorizing.
|
|
20
|
+
* back where they started after authorizing (redirect mode only).
|
|
17
21
|
*
|
|
18
|
-
* Throws when the init call fails. Does not return after
|
|
22
|
+
* Throws when the init call fails. Does not return after a redirect
|
|
19
23
|
* begins because the page is unloaded.
|
|
20
24
|
*/
|
|
21
25
|
export async function startHubSpotConnection(
|
|
@@ -41,5 +45,16 @@ export async function startHubSpotConnection(
|
|
|
41
45
|
|
|
42
46
|
await delay(ARTIFICIAL_CONNECT_REDIRECT_DELAY_MS);
|
|
43
47
|
|
|
48
|
+
const connectMode = resolveOAuthConnectMode(
|
|
49
|
+
config.oauthConnectMode !== undefined
|
|
50
|
+
? { oauthConnectMode: config.oauthConnectMode }
|
|
51
|
+
: {}
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (connectMode === 'popup') {
|
|
55
|
+
await waitForHubSpotOAuthPopup({ context, authorizationUrl });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
window.location.href = authorizationUrl;
|
|
45
60
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
OAUTH_CALLBACK_PATH,
|
|
5
|
+
OAUTH_POPUP_MESSAGE_TYPE,
|
|
6
|
+
} from '../../shared/constants.ts';
|
|
4
7
|
import { noopLogger } from '../../shared/logger.ts';
|
|
5
8
|
import { EXPIRES_AT_KEY } from './constants.ts';
|
|
6
9
|
import { initAppConnect } from './init.ts';
|
|
@@ -46,6 +49,7 @@ function createTestContext(): AppConnectContext {
|
|
|
46
49
|
|
|
47
50
|
interface FakeWindowOptions {
|
|
48
51
|
href: string;
|
|
52
|
+
opener?: { closed: boolean; postMessage: ReturnType<typeof vi.fn> };
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
interface FakeWindow {
|
|
@@ -53,17 +57,23 @@ interface FakeWindow {
|
|
|
53
57
|
windowProxy: {
|
|
54
58
|
location: Pick<Location, 'pathname' | 'search' | 'origin'>;
|
|
55
59
|
};
|
|
60
|
+
close: ReturnType<typeof vi.fn>;
|
|
61
|
+
openerPostMessage: ReturnType<typeof vi.fn> | undefined;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
function installFakeWindow(options: FakeWindowOptions): FakeWindow {
|
|
59
65
|
const url = new URL(options.href);
|
|
60
66
|
const calls: Array<[unknown, string, string]> = [];
|
|
67
|
+
const close = vi.fn();
|
|
68
|
+
const openerPostMessage = options.opener?.postMessage;
|
|
61
69
|
const fakeWindow = {
|
|
62
70
|
location: {
|
|
63
71
|
pathname: url.pathname,
|
|
64
72
|
search: url.search,
|
|
65
73
|
origin: url.origin,
|
|
66
74
|
},
|
|
75
|
+
opener: options.opener,
|
|
76
|
+
close,
|
|
67
77
|
};
|
|
68
78
|
const fakeHistory = {
|
|
69
79
|
replaceState: (state: unknown, title: string, newUrl: string) => {
|
|
@@ -75,7 +85,12 @@ function installFakeWindow(options: FakeWindowOptions): FakeWindow {
|
|
|
75
85
|
};
|
|
76
86
|
vi.stubGlobal('window', fakeWindow);
|
|
77
87
|
vi.stubGlobal('history', fakeHistory);
|
|
78
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
history: { calls },
|
|
90
|
+
windowProxy: fakeWindow,
|
|
91
|
+
close,
|
|
92
|
+
openerPostMessage,
|
|
93
|
+
};
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
describe('initAppConnect', () => {
|
|
@@ -153,6 +168,33 @@ describe('initAppConnect', () => {
|
|
|
153
168
|
expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
|
|
154
169
|
});
|
|
155
170
|
|
|
171
|
+
it('notifies the opener and skips replaceState when running in an OAuth popup', async () => {
|
|
172
|
+
const expiresAt = Date.now() + 1800 * 1000;
|
|
173
|
+
const openerPostMessage = vi.fn();
|
|
174
|
+
const fake = installFakeWindow({
|
|
175
|
+
href: `https://app.example.com${OAUTH_CALLBACK_PATH}?code=auth-code&state=auth-state`,
|
|
176
|
+
opener: { closed: false, postMessage: openerPostMessage },
|
|
177
|
+
});
|
|
178
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
179
|
+
new Response(
|
|
180
|
+
JSON.stringify({ expires_at: expiresAt, return_path: '/dashboard' }),
|
|
181
|
+
{ status: 200 }
|
|
182
|
+
)
|
|
183
|
+
);
|
|
184
|
+
const context = createTestContext();
|
|
185
|
+
|
|
186
|
+
await initAppConnect(context);
|
|
187
|
+
|
|
188
|
+
expect(openerPostMessage).toHaveBeenCalledWith(
|
|
189
|
+
{ type: OAUTH_POPUP_MESSAGE_TYPE, expiresAt },
|
|
190
|
+
'https://app.example.com'
|
|
191
|
+
);
|
|
192
|
+
expect(fake.close).toHaveBeenCalled();
|
|
193
|
+
expect(fake.history.calls).toHaveLength(0);
|
|
194
|
+
expect(context.store.getSnapshot().expiresAt).toBeNull();
|
|
195
|
+
expect(window.location.pathname).toBe(OAUTH_CALLBACK_PATH);
|
|
196
|
+
});
|
|
197
|
+
|
|
156
198
|
it('skips the callback step on the callback path when code or state are missing', async () => {
|
|
157
199
|
installFakeWindow({
|
|
158
200
|
href: `https://app.example.com${OAUTH_CALLBACK_PATH}`,
|
|
@@ -4,9 +4,9 @@ import {
|
|
|
4
4
|
OAUTH_CALLBACK_PATH,
|
|
5
5
|
} from '../../shared/constants.ts';
|
|
6
6
|
import type { AuthCompleteResponse } from '../../shared/wire-types.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { hasOAuthPopupOpener, notifyOAuthPopupOpener } from './oauth-popup.ts';
|
|
8
8
|
import type { AppConnectContext } from './types.ts';
|
|
9
|
-
import { clearSessionStorage } from './utils/session-utils.ts';
|
|
9
|
+
import { clearSessionStorage, storeExpiresAt } from './utils/session-utils.ts';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* On `controller.start()`:
|
|
@@ -74,8 +74,13 @@ async function consumeOAuthCallback(context: AppConnectContext): Promise<void> {
|
|
|
74
74
|
const body = (await response.json()) as AuthCompleteResponse;
|
|
75
75
|
|
|
76
76
|
const { expires_at: expiresAt, return_path: returnPath } = body;
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
|
|
78
|
+
if (hasOAuthPopupOpener()) {
|
|
79
|
+
notifyOAuthPopupOpener({ expiresAtMs: expiresAt });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
storeExpiresAt({ context, expiresAtMs: expiresAt });
|
|
79
84
|
|
|
80
85
|
const targetUrl = new URL(returnPath, window.location.origin);
|
|
81
86
|
history.replaceState(
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { OAUTH_POPUP_MESSAGE_TYPE } from '../../shared/constants.ts';
|
|
4
|
+
import { noopLogger } from '../../shared/logger.ts';
|
|
5
|
+
import { waitForHubSpotOAuthPopup } from './oauth-popup.ts';
|
|
6
|
+
import type {
|
|
7
|
+
AppConnectContext,
|
|
8
|
+
AppConnectInternalState,
|
|
9
|
+
SessionStorage,
|
|
10
|
+
} from './types.ts';
|
|
11
|
+
import { createStore } from './utils/store-utils.ts';
|
|
12
|
+
|
|
13
|
+
const HUBSPOT_CONNECT_BASE_URL =
|
|
14
|
+
'https://edge.example.com/functions/v1/hubspot-connect';
|
|
15
|
+
|
|
16
|
+
function createInMemorySessionStorage(): SessionStorage {
|
|
17
|
+
const map = new Map<string, string>();
|
|
18
|
+
return {
|
|
19
|
+
getItem: (key) => map.get(key) ?? null,
|
|
20
|
+
setItem: (key, value) => {
|
|
21
|
+
map.set(key, value);
|
|
22
|
+
},
|
|
23
|
+
removeItem: (key) => {
|
|
24
|
+
map.delete(key);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createTestContext(): AppConnectContext {
|
|
30
|
+
const initialState: AppConnectInternalState = {
|
|
31
|
+
isInitComplete: true,
|
|
32
|
+
isConnectInFlight: true,
|
|
33
|
+
isSessionConnected: false,
|
|
34
|
+
isDisconnectInFlight: false,
|
|
35
|
+
error: null,
|
|
36
|
+
expiresAt: null,
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
config: { hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL },
|
|
40
|
+
logger: noopLogger,
|
|
41
|
+
sessionStorage: createInMemorySessionStorage(),
|
|
42
|
+
store: createStore<AppConnectInternalState>(initialState),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('waitForHubSpotOAuthPopup', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.useFakeTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.unstubAllGlobals();
|
|
53
|
+
vi.restoreAllMocks();
|
|
54
|
+
vi.useRealTimers();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('resolves and persists expiresAt when a valid postMessage arrives', async () => {
|
|
58
|
+
const expiresAt = Date.now() + 1800 * 1000;
|
|
59
|
+
const popup = { closed: false, close: vi.fn() };
|
|
60
|
+
const messageListeners: Array<(event: MessageEvent) => void> = [];
|
|
61
|
+
|
|
62
|
+
vi.stubGlobal('window', {
|
|
63
|
+
location: { origin: 'https://app.example.com' },
|
|
64
|
+
addEventListener: (
|
|
65
|
+
type: string,
|
|
66
|
+
listener: (event: MessageEvent) => void
|
|
67
|
+
) => {
|
|
68
|
+
if (type === 'message') messageListeners.push(listener);
|
|
69
|
+
},
|
|
70
|
+
removeEventListener: vi.fn(),
|
|
71
|
+
open: vi.fn(() => popup),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const context = createTestContext();
|
|
75
|
+
const waitPromise = waitForHubSpotOAuthPopup({
|
|
76
|
+
context,
|
|
77
|
+
authorizationUrl: 'https://auth.example/authorize',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const listener = messageListeners[0]!;
|
|
81
|
+
listener({
|
|
82
|
+
origin: 'https://app.example.com',
|
|
83
|
+
data: { type: OAUTH_POPUP_MESSAGE_TYPE, expiresAt },
|
|
84
|
+
} as MessageEvent);
|
|
85
|
+
|
|
86
|
+
await expect(waitPromise).resolves.toBeUndefined();
|
|
87
|
+
expect(context.store.getSnapshot().expiresAt).toBe(expiresAt);
|
|
88
|
+
expect(context.store.getSnapshot().isSessionConnected).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects when the popup is closed without completing OAuth', async () => {
|
|
92
|
+
const popup = { closed: false, close: vi.fn() };
|
|
93
|
+
|
|
94
|
+
vi.stubGlobal('window', {
|
|
95
|
+
location: { origin: 'https://app.example.com' },
|
|
96
|
+
addEventListener: vi.fn(),
|
|
97
|
+
removeEventListener: vi.fn(),
|
|
98
|
+
open: vi.fn(() => popup),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const context = createTestContext();
|
|
102
|
+
const waitPromise = waitForHubSpotOAuthPopup({
|
|
103
|
+
context,
|
|
104
|
+
authorizationUrl: 'https://auth.example/authorize',
|
|
105
|
+
});
|
|
106
|
+
const rejection = expect(waitPromise).rejects.toThrow(/cancelled/i);
|
|
107
|
+
|
|
108
|
+
popup.closed = true;
|
|
109
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
110
|
+
|
|
111
|
+
await rejection;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects when window.open returns null', async () => {
|
|
115
|
+
vi.stubGlobal('window', {
|
|
116
|
+
location: { origin: 'https://app.example.com' },
|
|
117
|
+
addEventListener: vi.fn(),
|
|
118
|
+
removeEventListener: vi.fn(),
|
|
119
|
+
open: vi.fn(() => null),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const context = createTestContext();
|
|
123
|
+
await expect(
|
|
124
|
+
waitForHubSpotOAuthPopup({
|
|
125
|
+
context,
|
|
126
|
+
authorizationUrl: 'https://auth.example/authorize',
|
|
127
|
+
})
|
|
128
|
+
).rejects.toThrow(/Popup blocked/i);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('ignores postMessage from a foreign origin', async () => {
|
|
132
|
+
const expiresAt = Date.now() + 1800 * 1000;
|
|
133
|
+
const popup = { closed: false, close: vi.fn() };
|
|
134
|
+
const messageListeners: Array<(event: MessageEvent) => void> = [];
|
|
135
|
+
|
|
136
|
+
vi.stubGlobal('window', {
|
|
137
|
+
location: { origin: 'https://app.example.com' },
|
|
138
|
+
addEventListener: (
|
|
139
|
+
type: string,
|
|
140
|
+
listener: (event: MessageEvent) => void
|
|
141
|
+
) => {
|
|
142
|
+
if (type === 'message') messageListeners.push(listener);
|
|
143
|
+
},
|
|
144
|
+
removeEventListener: vi.fn(),
|
|
145
|
+
open: vi.fn(() => popup),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const context = createTestContext();
|
|
149
|
+
const waitPromise = waitForHubSpotOAuthPopup({
|
|
150
|
+
context,
|
|
151
|
+
authorizationUrl: 'https://auth.example/authorize',
|
|
152
|
+
});
|
|
153
|
+
const rejection = expect(waitPromise).rejects.toThrow(/cancelled/i);
|
|
154
|
+
|
|
155
|
+
messageListeners[0]!({
|
|
156
|
+
origin: 'https://evil.example.com',
|
|
157
|
+
data: { type: OAUTH_POPUP_MESSAGE_TYPE, expiresAt },
|
|
158
|
+
} as MessageEvent);
|
|
159
|
+
|
|
160
|
+
popup.closed = true;
|
|
161
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
162
|
+
|
|
163
|
+
await rejection;
|
|
164
|
+
expect(context.store.getSnapshot().expiresAt).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { OAUTH_POPUP_MESSAGE_TYPE } from '../../shared/constants.ts';
|
|
2
|
+
import type { AppConnectContext } from './types.ts';
|
|
3
|
+
import { storeExpiresAt } from './utils/session-utils.ts';
|
|
4
|
+
|
|
5
|
+
const OAUTH_POPUP_WINDOW_NAME = 'hubspot-app-connect-oauth';
|
|
6
|
+
const OAUTH_POPUP_POLL_INTERVAL_MS = 300;
|
|
7
|
+
|
|
8
|
+
interface OAuthPopupCompleteMessage {
|
|
9
|
+
type: typeof OAUTH_POPUP_MESSAGE_TYPE;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface WaitForHubSpotOAuthPopupOptions {
|
|
14
|
+
context: AppConnectContext;
|
|
15
|
+
authorizationUrl: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isOAuthPopupCompleteMessage(
|
|
19
|
+
data: unknown
|
|
20
|
+
): data is OAuthPopupCompleteMessage {
|
|
21
|
+
if (typeof data !== 'object' || data === null) return false;
|
|
22
|
+
const record = data as Record<string, unknown>;
|
|
23
|
+
return (
|
|
24
|
+
record.type === OAUTH_POPUP_MESSAGE_TYPE &&
|
|
25
|
+
typeof record.expiresAt === 'number' &&
|
|
26
|
+
Number.isFinite(record.expiresAt) &&
|
|
27
|
+
record.expiresAt > 0
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Opens HubSpot's authorize URL in a popup and waits for the OAuth
|
|
33
|
+
* callback page to post the session expiry back to this window.
|
|
34
|
+
*/
|
|
35
|
+
export async function waitForHubSpotOAuthPopup(
|
|
36
|
+
options: WaitForHubSpotOAuthPopupOptions
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const { context, authorizationUrl } = options;
|
|
39
|
+
const targetOrigin = window.location.origin;
|
|
40
|
+
|
|
41
|
+
return new Promise<void>((resolve, reject) => {
|
|
42
|
+
let popup: Window | null = null;
|
|
43
|
+
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
44
|
+
let isSettled = false;
|
|
45
|
+
|
|
46
|
+
const cleanup = () => {
|
|
47
|
+
window.removeEventListener('message', onMessage);
|
|
48
|
+
if (pollTimer !== undefined) clearInterval(pollTimer);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const settleSuccess = (expiresAtMs: number) => {
|
|
52
|
+
if (isSettled) return;
|
|
53
|
+
isSettled = true;
|
|
54
|
+
cleanup();
|
|
55
|
+
storeExpiresAt({ context, expiresAtMs });
|
|
56
|
+
context.store.setState({ isSessionConnected: true, error: null });
|
|
57
|
+
resolve();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const settleFailure = (message: string) => {
|
|
61
|
+
if (isSettled) return;
|
|
62
|
+
isSettled = true;
|
|
63
|
+
cleanup();
|
|
64
|
+
try {
|
|
65
|
+
popup?.close();
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore close errors
|
|
68
|
+
}
|
|
69
|
+
reject(new Error(message));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const onMessage = (event: MessageEvent) => {
|
|
73
|
+
if (event.origin !== targetOrigin) return;
|
|
74
|
+
if (!isOAuthPopupCompleteMessage(event.data)) return;
|
|
75
|
+
settleSuccess(event.data.expiresAt);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
window.addEventListener('message', onMessage);
|
|
79
|
+
|
|
80
|
+
popup = window.open(
|
|
81
|
+
authorizationUrl,
|
|
82
|
+
OAUTH_POPUP_WINDOW_NAME,
|
|
83
|
+
'popup=yes,width=600,height=700'
|
|
84
|
+
);
|
|
85
|
+
if (!popup) {
|
|
86
|
+
settleFailure('Popup blocked. Allow popups for this site and try again.');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pollTimer = setInterval(() => {
|
|
91
|
+
if (popup?.closed) {
|
|
92
|
+
settleFailure('Connect to HubSpot was cancelled.');
|
|
93
|
+
}
|
|
94
|
+
}, OAUTH_POPUP_POLL_INTERVAL_MS);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface NotifyOAuthPopupOpenerOptions {
|
|
99
|
+
expiresAtMs: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Called from the OAuth callback page running inside the popup after
|
|
104
|
+
* `auth/complete` succeeds. Notifies the opener and closes this window.
|
|
105
|
+
*/
|
|
106
|
+
export function notifyOAuthPopupOpener(
|
|
107
|
+
options: NotifyOAuthPopupOpenerOptions
|
|
108
|
+
): boolean {
|
|
109
|
+
const { expiresAtMs } = options;
|
|
110
|
+
const opener = window.opener;
|
|
111
|
+
if (!opener || opener.closed) return false;
|
|
112
|
+
|
|
113
|
+
const message: OAuthPopupCompleteMessage = {
|
|
114
|
+
type: OAUTH_POPUP_MESSAGE_TYPE,
|
|
115
|
+
expiresAt: expiresAtMs,
|
|
116
|
+
};
|
|
117
|
+
opener.postMessage(message, window.location.origin);
|
|
118
|
+
try {
|
|
119
|
+
window.close();
|
|
120
|
+
} catch {
|
|
121
|
+
// ignore close errors
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function hasOAuthPopupOpener(): boolean {
|
|
127
|
+
try {
|
|
128
|
+
return Boolean(window.opener && !window.opener.closed);
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns `true` when the app runs inside a parent frame (same-origin
|
|
3
|
+
* or cross-origin). Cross-origin parent access throws; treat that as
|
|
4
|
+
* embedded so OAuth uses a popup instead of a top-level redirect.
|
|
5
|
+
*/
|
|
6
|
+
export function isAppEmbeddedInIframe(): boolean {
|
|
7
|
+
try {
|
|
8
|
+
return window.self !== window.top;
|
|
9
|
+
} catch {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { resolveOAuthConnectMode } from './resolve-oauth-connect-mode.ts';
|
|
4
|
+
|
|
5
|
+
describe('resolveOAuthConnectMode', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.unstubAllGlobals();
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns redirect when mode is redirect', () => {
|
|
12
|
+
vi.stubGlobal('window', { self: {}, top: {} });
|
|
13
|
+
expect(resolveOAuthConnectMode({ oauthConnectMode: 'redirect' })).toBe(
|
|
14
|
+
'redirect'
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns popup when mode is popup', () => {
|
|
19
|
+
vi.stubGlobal('window', { self: {}, top: {} });
|
|
20
|
+
expect(resolveOAuthConnectMode({ oauthConnectMode: 'popup' })).toBe(
|
|
21
|
+
'popup'
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns popup in auto mode when embedded in an iframe', () => {
|
|
26
|
+
vi.stubGlobal('window', { self: {}, top: { other: true } });
|
|
27
|
+
expect(resolveOAuthConnectMode({ oauthConnectMode: 'auto' })).toBe('popup');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns redirect in auto mode when not embedded', () => {
|
|
31
|
+
const top = {};
|
|
32
|
+
vi.stubGlobal('window', { self: top, top });
|
|
33
|
+
expect(resolveOAuthConnectMode({})).toBe('redirect');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { OAuthConnectMode } from '../../types.ts';
|
|
2
|
+
import { isAppEmbeddedInIframe } from './is-app-embedded-in-iframe.ts';
|
|
3
|
+
|
|
4
|
+
export type ResolvedOAuthConnectMode = 'redirect' | 'popup';
|
|
5
|
+
|
|
6
|
+
interface ResolveOAuthConnectModeOptions {
|
|
7
|
+
oauthConnectMode?: OAuthConnectMode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Maps the configured {@link OAuthConnectMode} to the concrete connect
|
|
12
|
+
* behavior for the current browsing context.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveOAuthConnectMode(
|
|
15
|
+
options: ResolveOAuthConnectModeOptions
|
|
16
|
+
): ResolvedOAuthConnectMode {
|
|
17
|
+
const mode = options.oauthConnectMode ?? 'auto';
|
|
18
|
+
if (mode === 'popup') return 'popup';
|
|
19
|
+
if (mode === 'redirect') return 'redirect';
|
|
20
|
+
return isAppEmbeddedInIframe() ? 'popup' : 'redirect';
|
|
21
|
+
}
|