@hubspot/app-connect-sdk 1.0.0-alpha.20 → 1.0.0-alpha.21
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 +59 -56
- package/.turbo/turbo-tsdown.log +18 -16
- package/build/tsconfig.browser.tsbuildinfo +1 -1
- package/build/tsconfig.server.tsbuildinfo +1 -1
- package/dist/browser/{HubSpotAppConnect-DFe9b90e.js → HubSpotAppConnect-721kYr9d.js} +14 -21
- package/dist/browser/{HubSpotAppConnect-DFe9b90e.js.map → HubSpotAppConnect-721kYr9d.js.map} +1 -1
- package/dist/browser/{create-BNQazCF-.js → create-DxEyGG-k.js} +82 -37
- package/dist/browser/create-DxEyGG-k.js.map +1 -0
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/react/lovable.d.ts +1 -1
- package/dist/browser/react/lovable.js +2 -2
- package/dist/browser/react.d.ts +1 -1
- package/dist/browser/react.js +1 -1
- package/dist/browser/{types-DkAmHcZt.d.ts → types-C3wed8dU.d.ts} +31 -1
- package/dist/server/hono/hubspot-connect-routes/auth-complete.js +4 -1
- package/dist/server/hono/hubspot-connect-routes/auth-complete.js.map +1 -1
- package/dist/server/hono/hubspot-connect-routes/whoami.js +51 -0
- package/dist/server/hono/hubspot-connect-routes/whoami.js.map +1 -0
- package/package.json +3 -3
- package/src/browser/app-connect-controller/connect-start.test.ts +1 -0
- package/src/browser/app-connect-controller/constants.ts +6 -4
- package/src/browser/app-connect-controller/create.ts +8 -2
- package/src/browser/app-connect-controller/disconnect.ts +1 -0
- package/src/browser/app-connect-controller/init.test.ts +30 -15
- package/src/browser/app-connect-controller/init.ts +2 -2
- package/src/browser/app-connect-controller/oauth-complete.test.ts +7 -3
- package/src/browser/app-connect-controller/oauth-popup.test.ts +1 -0
- package/src/browser/app-connect-controller/types.ts +3 -0
- package/src/browser/app-connect-controller/utils/session-utils.test.ts +73 -22
- package/src/browser/app-connect-controller/utils/session-utils.ts +74 -33
- package/src/browser/app-connect-controller/view-state.test.ts +1 -0
- package/src/browser/app-connect-controller/view-state.ts +1 -0
- package/src/browser/react/components/AppConnectHeader/AppConnectHeader.tsx +18 -29
- package/src/browser/types.ts +9 -0
- package/src/server/hono/hubspot-connect-routes/auth-complete.test.ts +129 -20
- package/src/server/hono/hubspot-connect-routes/auth-complete.ts +8 -1
- package/src/server/hono/hubspot-connect-routes/whoami.ts +74 -0
- package/src/shared/wire-types.ts +30 -0
- package/dist/browser/create-BNQazCF-.js.map +0 -1
|
@@ -1,55 +1,96 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AuthCompleteWhoami } from '../../../shared/wire-types.ts';
|
|
2
|
+
import { SESSION_STORAGE_KEY } from '../constants.ts';
|
|
2
3
|
import type { AppConnectContext } from '../types.ts';
|
|
3
4
|
|
|
4
|
-
interface
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
interface StoredSession {
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
whoami: AuthCompleteWhoami | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readStoredSession(context: AppConnectContext): StoredSession | null {
|
|
11
|
+
const raw = context.sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
12
|
+
if (!raw) return null;
|
|
13
|
+
let parsed: StoredSession;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(raw) as StoredSession;
|
|
16
|
+
} catch {
|
|
17
|
+
context.sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (typeof parsed.expiresAt !== 'number' || isNaN(parsed.expiresAt)) {
|
|
21
|
+
context.sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (Date.now() > parsed.expiresAt) {
|
|
25
|
+
context.sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeStoredSession(
|
|
32
|
+
context: AppConnectContext,
|
|
33
|
+
session: StoredSession
|
|
34
|
+
): void {
|
|
35
|
+
context.sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
|
7
36
|
}
|
|
8
37
|
|
|
9
38
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
39
|
+
* Reads the persisted session blob from `sessionStorage`. Returns
|
|
40
|
+
* `null` (and auto-removes the entry) if the blob is missing,
|
|
41
|
+
* malformed, or already expired.
|
|
13
42
|
*/
|
|
14
|
-
export function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
43
|
+
export function getSessionFromSessionStorage(
|
|
44
|
+
context: AppConnectContext
|
|
45
|
+
): StoredSession | null {
|
|
46
|
+
return readStoredSession(context);
|
|
18
47
|
}
|
|
19
48
|
|
|
20
49
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
50
|
+
* Persists both `expiresAt` and `whoami` to the in-memory store and
|
|
51
|
+
* `sessionStorage` as a single JSON blob. Called from `init.ts` after
|
|
52
|
+
* a successful OAuth token exchange.
|
|
24
53
|
*/
|
|
25
|
-
export function
|
|
26
|
-
context: AppConnectContext
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
export function storeSession(options: {
|
|
55
|
+
context: AppConnectContext;
|
|
56
|
+
expiresAtMs: number;
|
|
57
|
+
whoami: AuthCompleteWhoami;
|
|
58
|
+
}): void {
|
|
59
|
+
const { context, expiresAtMs, whoami } = options;
|
|
60
|
+
context.store.setState({ expiresAt: expiresAtMs, whoami });
|
|
61
|
+
writeStoredSession(context, { expiresAt: expiresAtMs, whoami });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Updates only `expiresAt` in both the store and `sessionStorage`,
|
|
66
|
+
* preserving the existing `whoami` from in-memory store state. Called
|
|
67
|
+
* from `refresh.ts` after a successful token refresh.
|
|
68
|
+
*/
|
|
69
|
+
export function storeExpiresAt(options: {
|
|
70
|
+
context: AppConnectContext;
|
|
71
|
+
expiresAtMs: number;
|
|
72
|
+
}): void {
|
|
73
|
+
const { context, expiresAtMs } = options;
|
|
74
|
+
const currentWhoami = context.store.getSnapshot().whoami;
|
|
75
|
+
context.store.setState({ expiresAt: expiresAtMs });
|
|
76
|
+
writeStoredSession(context, {
|
|
77
|
+
expiresAt: expiresAtMs,
|
|
78
|
+
whoami: currentWhoami,
|
|
79
|
+
});
|
|
40
80
|
}
|
|
41
81
|
|
|
42
82
|
/**
|
|
43
|
-
*
|
|
83
|
+
* Removes the single session-storage key, clearing all persisted
|
|
84
|
+
* session state. Called on disconnect and on auth-complete errors.
|
|
44
85
|
*/
|
|
45
86
|
export function clearSessionStorage(context: AppConnectContext): void {
|
|
46
|
-
context.sessionStorage.removeItem(
|
|
87
|
+
context.sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
|
47
88
|
}
|
|
48
89
|
|
|
49
90
|
/**
|
|
50
91
|
* Returns `true` when the controller has an `expiresAt` whose value
|
|
51
|
-
* is still in the future. Used
|
|
52
|
-
*
|
|
92
|
+
* is still in the future. Used to drive the UI status and the refresh
|
|
93
|
+
* scheduler.
|
|
53
94
|
*/
|
|
54
95
|
export function isClientSessionActive(context: AppConnectContext): boolean {
|
|
55
96
|
const state = context.store.getSnapshot();
|
|
@@ -9,28 +9,6 @@ import { LogoutIcon } from '../icons/LogoutIcon.tsx';
|
|
|
9
9
|
import { ShareButton } from '../ShareButton/ShareButton.tsx';
|
|
10
10
|
import { styles } from './AppConnectHeader.css.ts';
|
|
11
11
|
|
|
12
|
-
interface FakeUser {
|
|
13
|
-
firstName: string;
|
|
14
|
-
lastName: string;
|
|
15
|
-
email: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const FAKE_USER: FakeUser = {
|
|
19
|
-
firstName: 'Gabby',
|
|
20
|
-
lastName: 'Martinez',
|
|
21
|
-
email: 'gabby.martinez@acmecorp.com',
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
function getUserInitials(user: FakeUser): string {
|
|
25
|
-
const first = user.firstName.charAt(0).toUpperCase();
|
|
26
|
-
const last = user.lastName.charAt(0).toUpperCase();
|
|
27
|
-
return `${first}${last}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function getFullName(user: FakeUser): string {
|
|
31
|
-
return `${user.firstName} ${user.lastName}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
12
|
interface AppConnectHeaderProps {
|
|
35
13
|
title: string;
|
|
36
14
|
}
|
|
@@ -56,6 +34,8 @@ export function AppConnectHeader({ title }: AppConnectHeaderProps) {
|
|
|
56
34
|
}
|
|
57
35
|
|
|
58
36
|
function ViewingHubSpotContextRow() {
|
|
37
|
+
const { whoami } = useHubSpotAppConnect();
|
|
38
|
+
const hubLabel = whoami?.hub?.domain ?? whoami?.hub?.id ?? 'HubSpot';
|
|
59
39
|
return (
|
|
60
40
|
<div className={styles.contextRow}>
|
|
61
41
|
<HubSpotDataSourceIcon className={styles.contextIcon} />
|
|
@@ -67,7 +47,7 @@ function ViewingHubSpotContextRow() {
|
|
|
67
47
|
event.preventDefault();
|
|
68
48
|
}}
|
|
69
49
|
>
|
|
70
|
-
|
|
50
|
+
{hubLabel}
|
|
71
51
|
<ExternalLinkIcon className={styles.contextExternalIcon} />
|
|
72
52
|
</a>
|
|
73
53
|
</div>
|
|
@@ -75,9 +55,16 @@ function ViewingHubSpotContextRow() {
|
|
|
75
55
|
}
|
|
76
56
|
|
|
77
57
|
function UserMenu() {
|
|
78
|
-
const { disconnectFromHubSpot } = useHubSpotAppConnect();
|
|
79
|
-
const
|
|
80
|
-
const
|
|
58
|
+
const { whoami, disconnectFromHubSpot } = useHubSpotAppConnect();
|
|
59
|
+
const user = whoami?.user;
|
|
60
|
+
const nameParts = [user?.firstName, user?.lastName].filter(
|
|
61
|
+
(p): p is string => typeof p === 'string' && p.length > 0
|
|
62
|
+
);
|
|
63
|
+
const initials =
|
|
64
|
+
nameParts.map((n) => n[0]!.toUpperCase()).join('') ||
|
|
65
|
+
user?.email?.[0]?.toUpperCase() ||
|
|
66
|
+
'?';
|
|
67
|
+
const displayName = nameParts.join(' ') || user?.email || 'HubSpot User';
|
|
81
68
|
|
|
82
69
|
return (
|
|
83
70
|
<Menu.Root modal={false}>
|
|
@@ -88,7 +75,7 @@ function UserMenu() {
|
|
|
88
75
|
>
|
|
89
76
|
{initials}
|
|
90
77
|
</span>
|
|
91
|
-
<span className={styles.triggerName}>{
|
|
78
|
+
<span className={styles.triggerName}>{displayName}</span>
|
|
92
79
|
<ChevronDownIcon className={styles.chevron} />
|
|
93
80
|
</Menu.Trigger>
|
|
94
81
|
<Menu.Portal>
|
|
@@ -102,8 +89,10 @@ function UserMenu() {
|
|
|
102
89
|
{initials}
|
|
103
90
|
</span>
|
|
104
91
|
<div className={styles.userInfoText}>
|
|
105
|
-
<span className={styles.userInfoName}>{
|
|
106
|
-
|
|
92
|
+
<span className={styles.userInfoName}>{displayName}</span>
|
|
93
|
+
{user?.email ? (
|
|
94
|
+
<span className={styles.userInfoEmail}>{user.email}</span>
|
|
95
|
+
) : null}
|
|
107
96
|
</div>
|
|
108
97
|
</div>
|
|
109
98
|
<Menu.Separator className={styles.separator} />
|
package/src/browser/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AuthCompleteWhoami } from '../shared/wire-types.ts';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* How the browser navigates to HubSpot's OAuth authorize endpoint when
|
|
3
5
|
* the user calls `connectToHubSpot`.
|
|
@@ -55,6 +57,13 @@ export interface AppConnectState {
|
|
|
55
57
|
status: AppConnectStatus;
|
|
56
58
|
/** Last error message that occurred, or `null` if no error. */
|
|
57
59
|
error: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Identity of the connected HubSpot user and their account. Populated
|
|
62
|
+
* after a successful OAuth flow on a best-effort basis (fields may be
|
|
63
|
+
* absent if the access token lacked the required scopes). `null` before
|
|
64
|
+
* connect or after disconnect.
|
|
65
|
+
*/
|
|
66
|
+
whoami: AuthCompleteWhoami | null;
|
|
58
67
|
/**
|
|
59
68
|
* Begins the OAuth connect flow. On success the browser either
|
|
60
69
|
* redirects to HubSpot's authorize endpoint or opens it in a popup
|
|
@@ -78,6 +78,57 @@ function buildCompleteUrl(stateValue: string, code = 'test-auth-code'): string {
|
|
|
78
78
|
return url.toString();
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
const MOCK_TOKEN_RESPONSE = {
|
|
82
|
+
access_token: 'new-access-token',
|
|
83
|
+
refresh_token: 'new-refresh-token',
|
|
84
|
+
expires_in: 1800,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const MOCK_INTROSPECT_RESPONSE = {
|
|
88
|
+
active: true,
|
|
89
|
+
token_use: 'access_token',
|
|
90
|
+
hub_id: 42,
|
|
91
|
+
user_id: 99,
|
|
92
|
+
app_id: 1,
|
|
93
|
+
client_id: 'test-client-id',
|
|
94
|
+
is_private_distribution: false,
|
|
95
|
+
scopes: ['crm.objects.contacts.read'],
|
|
96
|
+
signed_access_token: {},
|
|
97
|
+
token: 'new-access-token',
|
|
98
|
+
token_type: 'Bearer',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const MOCK_PORTAL_RESPONSE = {
|
|
102
|
+
portalId: 42,
|
|
103
|
+
uiDomain: 'app.hubspot.com',
|
|
104
|
+
accountType: 'STANDARD',
|
|
105
|
+
timeZone: 'UTC',
|
|
106
|
+
companyCurrency: 'USD',
|
|
107
|
+
dataHostingLocation: 'US',
|
|
108
|
+
utcOffset: '+00:00',
|
|
109
|
+
utcOffsetMilliseconds: 0,
|
|
110
|
+
additionalCurrencies: [],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const MOCK_USER_RESPONSE = {
|
|
114
|
+
id: '99',
|
|
115
|
+
email: 'user@example.com',
|
|
116
|
+
firstName: 'Pat',
|
|
117
|
+
lastName: 'Test',
|
|
118
|
+
roleIds: [],
|
|
119
|
+
superAdmin: false,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function mockFetchSequence(responses: unknown[]): ReturnType<typeof vi.spyOn> {
|
|
123
|
+
let spy = vi.spyOn(globalThis, 'fetch');
|
|
124
|
+
for (const body of responses) {
|
|
125
|
+
spy = spy.mockResolvedValueOnce(
|
|
126
|
+
new Response(JSON.stringify(body), { status: 200 })
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return spy;
|
|
130
|
+
}
|
|
131
|
+
|
|
81
132
|
describe('handleAuthComplete', () => {
|
|
82
133
|
afterEach(() => {
|
|
83
134
|
vi.restoreAllMocks();
|
|
@@ -192,16 +243,12 @@ describe('handleAuthComplete', () => {
|
|
|
192
243
|
});
|
|
193
244
|
|
|
194
245
|
it('sends the OAuth token endpoint the same redirect_uri it advertised in init-session', async () => {
|
|
195
|
-
const fetchSpy =
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}),
|
|
202
|
-
{ status: 200 }
|
|
203
|
-
)
|
|
204
|
-
);
|
|
246
|
+
const fetchSpy = mockFetchSequence([
|
|
247
|
+
{ access_token: 'at', refresh_token: 'rt', expires_in: 1800 },
|
|
248
|
+
MOCK_INTROSPECT_RESPONSE,
|
|
249
|
+
MOCK_PORTAL_RESPONSE,
|
|
250
|
+
MOCK_USER_RESPONSE,
|
|
251
|
+
]);
|
|
205
252
|
const app = new Hono();
|
|
206
253
|
app.post(`${BASE_PATH}/auth/complete`, (c) =>
|
|
207
254
|
handleAuthComplete(c, buildOAuthRouteOptions())
|
|
@@ -223,16 +270,12 @@ describe('handleAuthComplete', () => {
|
|
|
223
270
|
|
|
224
271
|
it('returns 200 with expires_at + return_path and sets durable cookies on success', async () => {
|
|
225
272
|
const beforeMs = Date.now();
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}),
|
|
233
|
-
{ status: 200 }
|
|
234
|
-
)
|
|
235
|
-
);
|
|
273
|
+
mockFetchSequence([
|
|
274
|
+
MOCK_TOKEN_RESPONSE,
|
|
275
|
+
MOCK_INTROSPECT_RESPONSE,
|
|
276
|
+
MOCK_PORTAL_RESPONSE,
|
|
277
|
+
MOCK_USER_RESPONSE,
|
|
278
|
+
]);
|
|
236
279
|
const app = new Hono();
|
|
237
280
|
app.post(`${BASE_PATH}/auth/complete`, (c) =>
|
|
238
281
|
handleAuthComplete(c, buildOAuthRouteOptions())
|
|
@@ -250,9 +293,19 @@ describe('handleAuthComplete', () => {
|
|
|
250
293
|
const body = (await res.json()) as {
|
|
251
294
|
expires_at: number;
|
|
252
295
|
return_path: string;
|
|
296
|
+
whoami: { user?: { id: string; email: string }; hub?: { id: number } };
|
|
253
297
|
};
|
|
254
298
|
expect(body.return_path).toBe('/dashboard');
|
|
255
299
|
expect(body.expires_at).toBeGreaterThanOrEqual(beforeMs + 1800 * 1000 - 50);
|
|
300
|
+
expect(body.whoami.user).toMatchObject({
|
|
301
|
+
id: '99',
|
|
302
|
+
email: 'user@example.com',
|
|
303
|
+
firstName: 'Pat',
|
|
304
|
+
});
|
|
305
|
+
expect(body.whoami.hub).toMatchObject({
|
|
306
|
+
id: 42,
|
|
307
|
+
uiDomain: 'app.hubspot.com',
|
|
308
|
+
});
|
|
256
309
|
|
|
257
310
|
const setCookies = res.headers.getSetCookie();
|
|
258
311
|
|
|
@@ -282,4 +335,60 @@ describe('handleAuthComplete', () => {
|
|
|
282
335
|
);
|
|
283
336
|
expect(stateCleared).toContain('Max-Age=0');
|
|
284
337
|
});
|
|
338
|
+
|
|
339
|
+
it('returns 200 with whoami.hub only when introspect fails', async () => {
|
|
340
|
+
vi.spyOn(globalThis, 'fetch')
|
|
341
|
+
.mockResolvedValueOnce(
|
|
342
|
+
new Response(JSON.stringify(MOCK_TOKEN_RESPONSE), { status: 200 })
|
|
343
|
+
)
|
|
344
|
+
.mockResolvedValueOnce(
|
|
345
|
+
new Response(JSON.stringify({ error: 'unauthorized_client' }), {
|
|
346
|
+
status: 401,
|
|
347
|
+
})
|
|
348
|
+
)
|
|
349
|
+
.mockResolvedValueOnce(
|
|
350
|
+
new Response(JSON.stringify(MOCK_PORTAL_RESPONSE), { status: 200 })
|
|
351
|
+
);
|
|
352
|
+
const app = new Hono();
|
|
353
|
+
app.post(`${BASE_PATH}/auth/complete`, (c) =>
|
|
354
|
+
handleAuthComplete(c, buildOAuthRouteOptions())
|
|
355
|
+
);
|
|
356
|
+
const { stateValue, cookieHeader } = buildCompleteFixture();
|
|
357
|
+
const res = await app.request(buildCompleteUrl(stateValue), {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: { Cookie: cookieHeader },
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(res.status).toBe(200);
|
|
363
|
+
const body = (await res.json()) as {
|
|
364
|
+
whoami: { user: unknown; hub?: { id: number } };
|
|
365
|
+
};
|
|
366
|
+
expect(body.whoami.user).toEqual({});
|
|
367
|
+
expect(body.whoami.hub).toMatchObject({ id: 42 });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('returns 200 with empty whoami when all whoami calls fail', async () => {
|
|
371
|
+
vi.spyOn(globalThis, 'fetch')
|
|
372
|
+
.mockResolvedValueOnce(
|
|
373
|
+
new Response(JSON.stringify(MOCK_TOKEN_RESPONSE), { status: 200 })
|
|
374
|
+
)
|
|
375
|
+
.mockResolvedValueOnce(new Response('', { status: 500 }))
|
|
376
|
+
.mockResolvedValueOnce(new Response('', { status: 500 }));
|
|
377
|
+
const app = new Hono();
|
|
378
|
+
app.post(`${BASE_PATH}/auth/complete`, (c) =>
|
|
379
|
+
handleAuthComplete(c, buildOAuthRouteOptions())
|
|
380
|
+
);
|
|
381
|
+
const { stateValue, cookieHeader } = buildCompleteFixture();
|
|
382
|
+
const res = await app.request(buildCompleteUrl(stateValue), {
|
|
383
|
+
method: 'POST',
|
|
384
|
+
headers: { Cookie: cookieHeader },
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
expect(res.status).toBe(200);
|
|
388
|
+
const body = (await res.json()) as {
|
|
389
|
+
whoami: { user: unknown; hub: unknown };
|
|
390
|
+
};
|
|
391
|
+
expect(body.whoami.user).toEqual({});
|
|
392
|
+
expect(body.whoami.hub).toEqual({});
|
|
393
|
+
});
|
|
285
394
|
});
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
AUTH_COMPLETE_CODE_PARAM,
|
|
5
5
|
AUTH_COMPLETE_STATE_PARAM,
|
|
6
6
|
} from '../../../shared/constants.ts';
|
|
7
|
+
import type { AuthCompleteResponse } from '../../../shared/wire-types.ts';
|
|
7
8
|
import {
|
|
8
9
|
HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
|
|
9
10
|
HUBSPOT_APP_ORIGIN_COOKIE_NAME,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
isSafeReturnPath,
|
|
32
33
|
parseAppOriginHeader,
|
|
33
34
|
} from './utils.ts';
|
|
35
|
+
import { fetchWhoami } from './whoami.ts';
|
|
34
36
|
|
|
35
37
|
interface OAuthStatePayload {
|
|
36
38
|
return_path?: string;
|
|
@@ -238,5 +240,10 @@ export async function handleAuthComplete(
|
|
|
238
240
|
setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_PKCE_VERIFIER) });
|
|
239
241
|
setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_OAUTH_STATE) });
|
|
240
242
|
|
|
241
|
-
|
|
243
|
+
const whoami = await fetchWhoami(accessToken, hubspotConnectEnv);
|
|
244
|
+
return c.json({
|
|
245
|
+
expires_at: expiresAt,
|
|
246
|
+
return_path: returnPath,
|
|
247
|
+
whoami,
|
|
248
|
+
} satisfies AuthCompleteResponse);
|
|
242
249
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { AuthCompleteWhoami } from '../../../shared/wire-types.ts';
|
|
2
|
+
import {
|
|
3
|
+
account,
|
|
4
|
+
authOauth,
|
|
5
|
+
createHubSpotClient,
|
|
6
|
+
settingsUsers,
|
|
7
|
+
} from '../../api-client-core/index.ts';
|
|
8
|
+
import { fetchTransportPlugin } from '../../api-client-core/plugins/fetch-transport.ts';
|
|
9
|
+
import { getHubSpotApiOrigin } from '../../utils/env-utils.ts';
|
|
10
|
+
import type { HubSpotConnectRoutesEnv } from './load-hubspot-connect-routes-env.ts';
|
|
11
|
+
|
|
12
|
+
export async function fetchWhoami(
|
|
13
|
+
accessToken: string,
|
|
14
|
+
hubspotConnectEnv: HubSpotConnectRoutesEnv
|
|
15
|
+
): Promise<AuthCompleteWhoami> {
|
|
16
|
+
const apiClient = createHubSpotClient({
|
|
17
|
+
plugins: [
|
|
18
|
+
fetchTransportPlugin({
|
|
19
|
+
getEndpoint: getHubSpotApiOrigin,
|
|
20
|
+
getAccessToken: () => accessToken,
|
|
21
|
+
}),
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const introspectInput = hubspotConnectEnv.isCimdEnabled
|
|
26
|
+
? { token: accessToken }
|
|
27
|
+
: {
|
|
28
|
+
client_id: hubspotConnectEnv.hubspotClientId,
|
|
29
|
+
client_secret: hubspotConnectEnv.hubspotClientSecret,
|
|
30
|
+
token: accessToken,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Introspect and account.get are independent — run in parallel.
|
|
34
|
+
// settingsUsers.get requires user_id from introspect, so it runs after.
|
|
35
|
+
const [introspectResult, hubResult] = await Promise.allSettled([
|
|
36
|
+
apiClient.send(authOauth.introspectToken(introspectInput)),
|
|
37
|
+
apiClient.send(account.get()),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const whoami: AuthCompleteWhoami = {
|
|
41
|
+
hub: {},
|
|
42
|
+
user: {},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (hubResult.status === 'fulfilled') {
|
|
46
|
+
const portal = hubResult.value;
|
|
47
|
+
whoami.hub.id = portal.portalId;
|
|
48
|
+
whoami.hub.uiDomain = portal.uiDomain;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
introspectResult.status === 'fulfilled' &&
|
|
53
|
+
introspectResult.value.token_use === 'access_token'
|
|
54
|
+
) {
|
|
55
|
+
whoami.hub.domain = introspectResult.value.hub_domain;
|
|
56
|
+
|
|
57
|
+
const userId = String(introspectResult.value.user_id);
|
|
58
|
+
const userResult = await apiClient
|
|
59
|
+
.send(settingsUsers.get({ userId, idProperty: 'USER_ID' }))
|
|
60
|
+
.then(
|
|
61
|
+
(u) => ({ ok: true as const, value: u }),
|
|
62
|
+
() => ({ ok: false as const })
|
|
63
|
+
);
|
|
64
|
+
if (userResult.ok) {
|
|
65
|
+
const u = userResult.value;
|
|
66
|
+
whoami.user.id = u.id;
|
|
67
|
+
whoami.user.email = u.email;
|
|
68
|
+
whoami.user.firstName = u.firstName;
|
|
69
|
+
whoami.user.lastName = u.lastName;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return whoami;
|
|
74
|
+
}
|
package/src/shared/wire-types.ts
CHANGED
|
@@ -15,6 +15,30 @@ export interface InitSessionResponse {
|
|
|
15
15
|
authorization_url: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export interface AuthCompleteWhoamiUser {
|
|
19
|
+
id?: string | undefined;
|
|
20
|
+
email?: string | undefined;
|
|
21
|
+
firstName?: string | undefined;
|
|
22
|
+
lastName?: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AuthCompleteWhoamiHub {
|
|
26
|
+
id?: number | undefined;
|
|
27
|
+
domain?: string | undefined;
|
|
28
|
+
uiDomain?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Identity information for the authenticated user and their HubSpot
|
|
33
|
+
* account. Both fields are optional because the access token may not
|
|
34
|
+
* carry the scopes needed to fetch each piece (`public-users-read` for
|
|
35
|
+
* `user`, `external-settings-access` for `hub`).
|
|
36
|
+
*/
|
|
37
|
+
export interface AuthCompleteWhoami {
|
|
38
|
+
user: AuthCompleteWhoamiUser;
|
|
39
|
+
hub: AuthCompleteWhoamiHub;
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
/**
|
|
19
43
|
* Body returned by `POST /auth/complete` once the SDK has exchanged
|
|
20
44
|
* the OAuth authorization code for an access + refresh token. The
|
|
@@ -32,6 +56,12 @@ export interface AuthCompleteResponse {
|
|
|
32
56
|
* `/` and is restricted to safe internal paths.
|
|
33
57
|
*/
|
|
34
58
|
return_path: string;
|
|
59
|
+
/**
|
|
60
|
+
* Identity of the authenticated user and their HubSpot account.
|
|
61
|
+
* Populated on a best-effort basis — fields are absent when the
|
|
62
|
+
* access token lacks the required scopes.
|
|
63
|
+
*/
|
|
64
|
+
whoami: AuthCompleteWhoami;
|
|
35
65
|
}
|
|
36
66
|
|
|
37
67
|
/**
|