@hubspot/app-connect-sdk 1.0.0-alpha.13 → 1.0.0-alpha.15
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 +63 -58
- 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-D-oTtxX-.js} +252 -92
- package/dist/browser/create-D-oTtxX-.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 +5 -2
- package/src/browser/app-connect-controller/connect-start.test.ts +156 -0
- package/src/browser/app-connect-controller/connect-start.ts +18 -3
- package/src/browser/app-connect-controller/init.test.ts +43 -2
- package/src/browser/app-connect-controller/init.ts +23 -48
- package/src/browser/app-connect-controller/oauth-complete.test.ts +106 -0
- package/src/browser/app-connect-controller/oauth-complete.ts +53 -0
- package/src/browser/app-connect-controller/oauth-popup.test.ts +192 -0
- package/src/browser/app-connect-controller/oauth-popup.ts +152 -0
- package/src/browser/app-connect-controller/utils/iframe-utils.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 +9 -0
- package/dist/browser/create-crdncXsh.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.js","names":[],"sources":["../../../src/shared/constants.ts"],"sourcesContent":["/**\n * Constants whose values are part of the contract between the browser\n * controller and the server-side hubspot-connect routes. Both halves\n * import from this module so the wire format stays in sync.\n */\n\n/**\n * Query parameter on the OAuth return URL that carries the new access\n * token's expiry (Unix epoch milliseconds). The browser controller\n * sets this in the URL after a successful `auth/complete` call and\n * then strips it during `initAppConnect` via `history.replaceState`.\n */\nexport const EXPIRES_AT_URL_PARAM = '__hs_expires_at';\n\n/**\n * Path the browser visits after HubSpot's authorize endpoint\n * redirects back to the app. Mounted on the **frontend** origin (not\n * the SDK's edge function host) so all OAuth-related cookies live in\n * the `(frontend, edge)` CHIPS partition.\n *\n * The SDK's `auth/init-session` builds the OAuth `redirect_uri` as\n * `${requestOrigin}${HUBSPOT_FRONTEND_CALLBACK_PATH}`. The browser\n * controller, on `start()`, recognizes this path on `window.location`\n * and forwards `?code` + `?state` to the SDK's `auth/complete`\n * endpoint via a credentialed cross-site fetch. The host app must\n * register `${app_origin}${HUBSPOT_FRONTEND_CALLBACK_PATH}` as a\n * redirect URI in its HubSpot app settings.\n */\nexport const OAUTH_CALLBACK_PATH = '/__hubspot_oauth_callback';\n\n/**\n * Query parameter on the `auth/complete` POST request carrying the\n * authorization `code` HubSpot returned to the frontend callback.\n */\nexport const AUTH_COMPLETE_CODE_PARAM = 'code';\n\n/**\n * Query parameter on the `auth/complete` POST request carrying the\n * OAuth `state` HubSpot echoed back to the frontend callback.\n */\nexport const AUTH_COMPLETE_STATE_PARAM = 'state';\n"],"mappings":";;;;;;;;;;;;;;;AA4BA,MAAa,sBAAsB;;;;;AAMnC,MAAa,2BAA2B;;;;;AAMxC,MAAa,4BAA4B"}
|
|
1
|
+
{"version":3,"file":"constants.js","names":[],"sources":["../../../src/shared/constants.ts"],"sourcesContent":["/**\n * Constants whose values are part of the contract between the browser\n * controller and the server-side hubspot-connect routes. Both halves\n * import from this module so the wire format stays in sync.\n */\n\n/**\n * Query parameter on the OAuth return URL that carries the new access\n * token's expiry (Unix epoch milliseconds). The browser controller\n * sets this in the URL after a successful `auth/complete` call and\n * then strips it during `initAppConnect` via `history.replaceState`.\n */\nexport const EXPIRES_AT_URL_PARAM = '__hs_expires_at';\n\n/**\n * Path the browser visits after HubSpot's authorize endpoint\n * redirects back to the app. Mounted on the **frontend** origin (not\n * the SDK's edge function host) so all OAuth-related cookies live in\n * the `(frontend, edge)` CHIPS partition.\n *\n * The SDK's `auth/init-session` builds the OAuth `redirect_uri` as\n * `${requestOrigin}${HUBSPOT_FRONTEND_CALLBACK_PATH}`. The browser\n * controller, on `start()`, recognizes this path on `window.location`\n * and forwards `?code` + `?state` to the SDK's `auth/complete`\n * endpoint via a credentialed cross-site fetch. The host app must\n * register `${app_origin}${HUBSPOT_FRONTEND_CALLBACK_PATH}` as a\n * redirect URI in its HubSpot app settings.\n */\nexport const OAUTH_CALLBACK_PATH = '/__hubspot_oauth_callback';\n\n/**\n * Query parameter on the `auth/complete` POST request carrying the\n * authorization `code` HubSpot returned to the frontend callback.\n */\nexport const AUTH_COMPLETE_CODE_PARAM = 'code';\n\n/**\n * Query parameter on the `auth/complete` POST request carrying the\n * OAuth `state` HubSpot echoed back to the frontend callback.\n */\nexport const AUTH_COMPLETE_STATE_PARAM = 'state';\n\n/**\n * `postMessage` `data.type` value the OAuth popup sends to its opener\n * with the authorization `code` and `state` from the callback URL. The\n * opener POSTs them to `auth/complete` so credentialed cookies stay in\n * the same CHIPS partition as `auth/init-session`.\n */\nexport const OAUTH_POPUP_CALLBACK_MESSAGE_TYPE =\n 'hubspot-app-connect:oauth-callback';\n"],"mappings":";;;;;;;;;;;;;;;AA4BA,MAAa,sBAAsB;;;;;AAMnC,MAAa,2BAA2B;;;;;AAMxC,MAAa,4BAA4B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hubspot/app-connect-sdk",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.15",
|
|
4
4
|
"description": "HubSpot App Connect SDK (alpha release). Documentation and integration guidance forthcoming.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"typescript": "6.0.3",
|
|
46
46
|
"vitest": "4.1.6",
|
|
47
47
|
"@private/eslint-config": "0.1.0",
|
|
48
|
-
"@private/
|
|
49
|
-
"@private/
|
|
48
|
+
"@private/prettier-config": "0.1.0",
|
|
49
|
+
"@private/tsconfig": "0.1.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"clean": "rm -rf dist build *.tsbuildinfo node_modules .turbo",
|
|
@@ -57,8 +57,11 @@ flowchart TD
|
|
|
57
57
|
## Module map
|
|
58
58
|
|
|
59
59
|
- [create.ts](./create.ts) — factory; the only file [`../index.ts`](../index.ts) imports from. Wires the context, defines `connectToHubSpot` / `disconnectFromHubSpot`, and applies `memoizeLast` to `getSnapshot`.
|
|
60
|
-
- [init.ts](./init.ts) — runs once on `start()`.
|
|
61
|
-
- [connect-start.ts](./connect-start.ts) — `GET`s
|
|
60
|
+
- [init.ts](./init.ts) — runs once on `start()`. Redirect flow: POSTs `code` + `state` to `/auth/complete`, persists `expires_at`, `history.replaceState`s to `return_path`. Popup flow (`window.opener`): relays `code` + `state` to the opener and closes (no `auth/complete` in the popup).
|
|
61
|
+
- [connect-start.ts](./connect-start.ts) — `GET`s `/auth/init-session`, then redirects or opens a popup per `config.oauthConnectMode` (`auto` uses a popup when embedded in an iframe). See [oauth-popup.ts](./oauth-popup.ts).
|
|
62
|
+
- [oauth-popup.ts](./oauth-popup.ts) — opener waits for popup `postMessage` with `code` + `state`, then POSTs `/auth/complete`.
|
|
63
|
+
- [oauth-complete.ts](./oauth-complete.ts) — shared credentialed `POST /auth/complete` used by redirect init and the opener popup handler.
|
|
64
|
+
- [utils/resolve-oauth-connect-mode.ts](./utils/resolve-oauth-connect-mode.ts) / [utils/iframe-utils.ts](./utils/iframe-utils.ts) — map `oauthConnectMode` + iframe detection to redirect vs popup.
|
|
62
65
|
- [disconnect.ts](./disconnect.ts) — `POST`s `/auth/logout`, clears local session storage, and redirects to the server-supplied `redirect_to`. Errors are caught and surfaced via `state.error`.
|
|
63
66
|
- [refresh.ts](./refresh.ts) — subscribes to the store and (re)schedules a `/auth/refresh` call whenever `expiresAt` changes. Exposes `RefreshSchedulerHandle.stop()` for teardown.
|
|
64
67
|
- [view-state.ts](./view-state.ts) — `getDerivedStatus` and `SERVER_VIEW` (the SSR snapshot returned by `getServerSnapshot`).
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
const expiresAt = Date.now() + 1800 * 1000;
|
|
118
|
+
vi.spyOn(globalThis, 'fetch')
|
|
119
|
+
.mockResolvedValueOnce(
|
|
120
|
+
new Response(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
authorization_url: 'https://auth.example/authorize',
|
|
123
|
+
}),
|
|
124
|
+
{ status: 200 }
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
.mockResolvedValueOnce(
|
|
128
|
+
new Response(
|
|
129
|
+
JSON.stringify({ expires_at: expiresAt, return_path: '/dashboard' }),
|
|
130
|
+
{ status: 200 }
|
|
131
|
+
)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const context = createTestContext('popup');
|
|
135
|
+
const connectPromise = startHubSpotConnection(context);
|
|
136
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
137
|
+
|
|
138
|
+
expect(window.open).toHaveBeenCalledWith(
|
|
139
|
+
'https://auth.example/authorize',
|
|
140
|
+
expect.any(String),
|
|
141
|
+
expect.stringContaining('popup=yes')
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
messageListeners[0]!({
|
|
145
|
+
origin: 'https://app.example.com',
|
|
146
|
+
data: {
|
|
147
|
+
type: 'hubspot-app-connect:oauth-callback',
|
|
148
|
+
code: 'auth-code',
|
|
149
|
+
state: 'auth-state',
|
|
150
|
+
},
|
|
151
|
+
} as MessageEvent);
|
|
152
|
+
|
|
153
|
+
await connectPromise;
|
|
154
|
+
expect(context.store.getSnapshot().expiresAt).toBe(expiresAt);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -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_CALLBACK_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,32 @@ describe('initAppConnect', () => {
|
|
|
153
168
|
expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
|
|
154
169
|
});
|
|
155
170
|
|
|
171
|
+
it('relays code and state to the opener without calling auth/complete when in an OAuth popup', async () => {
|
|
172
|
+
const openerPostMessage = vi.fn();
|
|
173
|
+
const fake = installFakeWindow({
|
|
174
|
+
href: `https://app.example.com${OAUTH_CALLBACK_PATH}?code=auth-code&state=auth-state`,
|
|
175
|
+
opener: { closed: false, postMessage: openerPostMessage },
|
|
176
|
+
});
|
|
177
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
178
|
+
const context = createTestContext();
|
|
179
|
+
|
|
180
|
+
await initAppConnect(context);
|
|
181
|
+
|
|
182
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
183
|
+
expect(openerPostMessage).toHaveBeenCalledWith(
|
|
184
|
+
{
|
|
185
|
+
type: OAUTH_POPUP_CALLBACK_MESSAGE_TYPE,
|
|
186
|
+
code: 'auth-code',
|
|
187
|
+
state: 'auth-state',
|
|
188
|
+
},
|
|
189
|
+
'https://app.example.com'
|
|
190
|
+
);
|
|
191
|
+
expect(fake.close).toHaveBeenCalled();
|
|
192
|
+
expect(fake.history.calls).toHaveLength(0);
|
|
193
|
+
expect(context.store.getSnapshot().expiresAt).toBeNull();
|
|
194
|
+
expect(window.location.pathname).toBe(OAUTH_CALLBACK_PATH);
|
|
195
|
+
});
|
|
196
|
+
|
|
156
197
|
it('skips the callback step on the callback path when code or state are missing', async () => {
|
|
157
198
|
installFakeWindow({
|
|
158
199
|
href: `https://app.example.com${OAUTH_CALLBACK_PATH}`,
|
|
@@ -3,32 +3,30 @@ import {
|
|
|
3
3
|
AUTH_COMPLETE_STATE_PARAM,
|
|
4
4
|
OAUTH_CALLBACK_PATH,
|
|
5
5
|
} from '../../shared/constants.ts';
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
6
|
+
import { completeHubSpotOAuthSession } from './oauth-complete.ts';
|
|
7
|
+
import {
|
|
8
|
+
hasOAuthPopupOpener,
|
|
9
|
+
relayOAuthCallbackToOpener,
|
|
10
|
+
} from './oauth-popup.ts';
|
|
8
11
|
import type { AppConnectContext } from './types.ts';
|
|
9
|
-
import {
|
|
12
|
+
import { storeExpiresAt } from './utils/session-utils.ts';
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* On `controller.start()`:
|
|
13
16
|
*
|
|
14
17
|
* 1. If the browser has been redirected back to the SDK's frontend
|
|
15
|
-
* OAuth callback path (`
|
|
16
|
-
* `?
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* 2. Pick up `?__hs_expires_at` from `window.location` (placed there
|
|
26
|
-
* by step 1, or by an in-progress refresh hop), persist it to the
|
|
27
|
-
* controller's store + sessionStorage, and strip it from the
|
|
28
|
-
* address bar so it isn't logged or bookmarked.
|
|
18
|
+
* OAuth callback path (`/__hubspot_oauth_callback`) with `?code` +
|
|
19
|
+
* `?state`:
|
|
20
|
+
* - **Redirect flow** (no `window.opener`): POST to `auth/complete`
|
|
21
|
+
* from this window, persist `expires_at`, and `history.replaceState`
|
|
22
|
+
* to `return_path`.
|
|
23
|
+
* - **Popup flow** (`window.opener` present): relay `code` + `state`
|
|
24
|
+
* to the opener via `postMessage` and close. The opener POSTs to
|
|
25
|
+
* `auth/complete` so partitioned cookies match `init-session`.
|
|
26
|
+
* 2. Pick up `?__hs_expires_at` from `window.location` (refresh hop),
|
|
27
|
+
* persist it, and strip it from the address bar.
|
|
29
28
|
*
|
|
30
|
-
* A no-op when neither set of parameters is present
|
|
31
|
-
* load other than the OAuth return trip).
|
|
29
|
+
* A no-op when neither set of parameters is present.
|
|
32
30
|
*/
|
|
33
31
|
export async function initAppConnect(
|
|
34
32
|
context: AppConnectContext
|
|
@@ -44,38 +42,15 @@ async function consumeOAuthCallback(context: AppConnectContext): Promise<void> {
|
|
|
44
42
|
const state = params.get(AUTH_COMPLETE_STATE_PARAM);
|
|
45
43
|
if (!code || !state) return;
|
|
46
44
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
);
|
|
51
|
-
completeUrl.searchParams.set(AUTH_COMPLETE_CODE_PARAM, code);
|
|
52
|
-
completeUrl.searchParams.set(AUTH_COMPLETE_STATE_PARAM, state);
|
|
53
|
-
|
|
54
|
-
let response: Response;
|
|
55
|
-
try {
|
|
56
|
-
response = await fetch(completeUrl.toString(), {
|
|
57
|
-
method: 'POST',
|
|
58
|
-
credentials: 'include',
|
|
59
|
-
});
|
|
60
|
-
} catch (err) {
|
|
61
|
-
clearSessionStorage(context);
|
|
62
|
-
throw new Error(
|
|
63
|
-
`Failed to complete HubSpot OAuth: ${err instanceof Error ? err.message : String(err)}`
|
|
64
|
-
);
|
|
45
|
+
if (hasOAuthPopupOpener()) {
|
|
46
|
+
relayOAuthCallbackToOpener({ code, state });
|
|
47
|
+
return;
|
|
65
48
|
}
|
|
66
49
|
|
|
67
|
-
|
|
68
|
-
clearSessionStorage(context);
|
|
69
|
-
throw new Error(
|
|
70
|
-
`Failed to complete HubSpot OAuth: ${response.status} ${response.statusText}`
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const body = (await response.json()) as AuthCompleteResponse;
|
|
75
|
-
|
|
50
|
+
const body = await completeHubSpotOAuthSession(context, { code, state });
|
|
76
51
|
const { expires_at: expiresAt, return_path: returnPath } = body;
|
|
77
|
-
|
|
78
|
-
context
|
|
52
|
+
|
|
53
|
+
storeExpiresAt({ context, expiresAtMs: expiresAt });
|
|
79
54
|
|
|
80
55
|
const targetUrl = new URL(returnPath, window.location.origin);
|
|
81
56
|
history.replaceState(
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { noopLogger } from '../../shared/logger.ts';
|
|
4
|
+
import { EXPIRES_AT_KEY } from './constants.ts';
|
|
5
|
+
import { completeHubSpotOAuthSession } from './oauth-complete.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: false,
|
|
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('completeHubSpotOAuthSession', () => {
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.unstubAllGlobals();
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('POSTs code and state to auth/complete with credentials', async () => {
|
|
53
|
+
const expiresAt = Date.now() + 1800 * 1000;
|
|
54
|
+
vi.stubGlobal('window', {
|
|
55
|
+
location: { origin: 'https://app.example.com' },
|
|
56
|
+
});
|
|
57
|
+
const fetchSpy = vi
|
|
58
|
+
.spyOn(globalThis, 'fetch')
|
|
59
|
+
.mockResolvedValue(
|
|
60
|
+
new Response(
|
|
61
|
+
JSON.stringify({ expires_at: expiresAt, return_path: '/home' }),
|
|
62
|
+
{ status: 200 }
|
|
63
|
+
)
|
|
64
|
+
);
|
|
65
|
+
const context = createTestContext();
|
|
66
|
+
|
|
67
|
+
const body = await completeHubSpotOAuthSession(context, {
|
|
68
|
+
code: 'my-code',
|
|
69
|
+
state: 'my-state',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(body.expires_at).toBe(expiresAt);
|
|
73
|
+
expect(body.return_path).toBe('/home');
|
|
74
|
+
const [calledUrl, calledInit] = fetchSpy.mock.calls[0]!;
|
|
75
|
+
const url = new URL(calledUrl as string);
|
|
76
|
+
expect(url.origin + url.pathname).toBe(
|
|
77
|
+
`${HUBSPOT_CONNECT_BASE_URL}/auth/complete`
|
|
78
|
+
);
|
|
79
|
+
expect(url.searchParams.get('code')).toBe('my-code');
|
|
80
|
+
expect(url.searchParams.get('state')).toBe('my-state');
|
|
81
|
+
expect((calledInit as RequestInit).method).toBe('POST');
|
|
82
|
+
expect((calledInit as RequestInit).credentials).toBe('include');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('clears session storage when auth/complete fails', async () => {
|
|
86
|
+
vi.stubGlobal('window', {
|
|
87
|
+
location: { origin: 'https://app.example.com' },
|
|
88
|
+
});
|
|
89
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
90
|
+
new Response('{"error":"State mismatch"}', {
|
|
91
|
+
status: 403,
|
|
92
|
+
statusText: 'Forbidden',
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
const context = createTestContext();
|
|
96
|
+
context.sessionStorage.setItem(EXPIRES_AT_KEY, '999');
|
|
97
|
+
|
|
98
|
+
await expect(
|
|
99
|
+
completeHubSpotOAuthSession(context, {
|
|
100
|
+
code: 'code',
|
|
101
|
+
state: 'bad-state',
|
|
102
|
+
})
|
|
103
|
+
).rejects.toThrow(/Failed to complete/);
|
|
104
|
+
expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTH_COMPLETE_CODE_PARAM,
|
|
3
|
+
AUTH_COMPLETE_STATE_PARAM,
|
|
4
|
+
} from '../../shared/constants.ts';
|
|
5
|
+
import type { AuthCompleteResponse } from '../../shared/wire-types.ts';
|
|
6
|
+
import type { AppConnectContext } from './types.ts';
|
|
7
|
+
import { clearSessionStorage } from './utils/session-utils.ts';
|
|
8
|
+
|
|
9
|
+
interface CompleteHubSpotOAuthSessionOptions {
|
|
10
|
+
code: string;
|
|
11
|
+
state: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Finishes the OAuth token exchange by POSTing `code` and `state` to the
|
|
16
|
+
* SDK's `auth/complete` route with credentialed cookies from the current
|
|
17
|
+
* browsing context (must match the partition used by `auth/init-session`).
|
|
18
|
+
*/
|
|
19
|
+
export async function completeHubSpotOAuthSession(
|
|
20
|
+
context: AppConnectContext,
|
|
21
|
+
options: CompleteHubSpotOAuthSessionOptions
|
|
22
|
+
): Promise<AuthCompleteResponse> {
|
|
23
|
+
const { code, state } = options;
|
|
24
|
+
|
|
25
|
+
const completeUrl = new URL(
|
|
26
|
+
`${context.config.hubSpotConnectBaseUrl}/auth/complete`,
|
|
27
|
+
window.location.origin
|
|
28
|
+
);
|
|
29
|
+
completeUrl.searchParams.set(AUTH_COMPLETE_CODE_PARAM, code);
|
|
30
|
+
completeUrl.searchParams.set(AUTH_COMPLETE_STATE_PARAM, state);
|
|
31
|
+
|
|
32
|
+
let response: Response;
|
|
33
|
+
try {
|
|
34
|
+
response = await fetch(completeUrl.toString(), {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
credentials: 'include',
|
|
37
|
+
});
|
|
38
|
+
} catch (err) {
|
|
39
|
+
clearSessionStorage(context);
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Failed to complete HubSpot OAuth: ${err instanceof Error ? err.message : String(err)}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
clearSessionStorage(context);
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Failed to complete HubSpot OAuth: ${response.status} ${response.statusText}`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (await response.json()) as AuthCompleteResponse;
|
|
53
|
+
}
|