@hubspot/app-connect-sdk 1.0.0-alpha.2 → 1.0.0-alpha.3

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 (70) hide show
  1. package/.turbo/turbo-format$colon$check.log +1 -1
  2. package/.turbo/turbo-test.log +60 -58
  3. package/.turbo/turbo-tsdown.log +55 -52
  4. package/build/tsconfig.browser.tsbuildinfo +1 -1
  5. package/build/tsconfig.server.tsbuildinfo +1 -1
  6. package/dist/browser/{HubSpotAppConnect-BW45gyDs.js → HubSpotAppConnect-COQgPrFn.js} +5 -3
  7. package/dist/browser/HubSpotAppConnect-COQgPrFn.js.map +1 -0
  8. package/dist/browser/{create-vctOhpX9.js → create-crdncXsh.js} +53 -24
  9. package/dist/browser/create-crdncXsh.js.map +1 -0
  10. package/dist/browser/index.js +1 -1
  11. package/dist/browser/react/lovable.js +2 -2
  12. package/dist/browser/react.js +1 -1
  13. package/dist/server/api-client-core/plugins/fetch-transport.js +5 -1
  14. package/dist/server/api-client-core/plugins/fetch-transport.js.map +1 -1
  15. package/dist/server/constants.js +33 -6
  16. package/dist/server/constants.js.map +1 -1
  17. package/dist/server/hono/hono-request-handler.js +18 -13
  18. package/dist/server/hono/hono-request-handler.js.map +1 -1
  19. package/dist/server/hono/hubspot-connect-routes/auth-complete.js +154 -0
  20. package/dist/server/hono/hubspot-connect-routes/auth-complete.js.map +1 -0
  21. package/dist/server/hono/hubspot-connect-routes/auth-init-session.js +22 -11
  22. package/dist/server/hono/hubspot-connect-routes/auth-init-session.js.map +1 -1
  23. package/dist/server/hono/hubspot-connect-routes/auth-logout.js +18 -1
  24. package/dist/server/hono/hubspot-connect-routes/auth-logout.js.map +1 -1
  25. package/dist/server/hono/hubspot-connect-routes/auth-refresh.js +6 -0
  26. package/dist/server/hono/hubspot-connect-routes/auth-refresh.js.map +1 -1
  27. package/dist/server/hono/hubspot-connect-routes/hubspot-connect-routes.js +4 -2
  28. package/dist/server/hono/hubspot-connect-routes/hubspot-connect-routes.js.map +1 -1
  29. package/dist/server/hono/hubspot-connect-routes/utils.js +50 -3
  30. package/dist/server/hono/hubspot-connect-routes/utils.js.map +1 -1
  31. package/dist/server/hono/types.d.ts +13 -9
  32. package/dist/server/hono/utils/cookie-utils.js +2 -1
  33. package/dist/server/hono/utils/cookie-utils.js.map +1 -1
  34. package/dist/server/hono/utils/cors-middleware.js +85 -0
  35. package/dist/server/hono/utils/cors-middleware.js.map +1 -0
  36. package/dist/server/sanitize-request.js +24 -10
  37. package/dist/server/sanitize-request.js.map +1 -1
  38. package/dist/server/shared/constants.js +22 -9
  39. package/dist/server/shared/constants.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/browser/app-connect-controller/init.test.ts +167 -0
  42. package/src/browser/app-connect-controller/init.ts +70 -19
  43. package/src/browser/react/components/AppConnectHeader/AppConnectHeader.tsx +3 -5
  44. package/src/browser/react/components/ConnectButton/ConnectButton.tsx +2 -1
  45. package/src/server/api-client-core/plugins/fetch-transport.ts +5 -1
  46. package/src/server/constants.ts +29 -4
  47. package/src/server/hono/hono-request-handler.ts +42 -15
  48. package/src/server/hono/hubspot-connect-routes/auth-complete.test.ts +285 -0
  49. package/src/server/hono/hubspot-connect-routes/{auth-callback.ts → auth-complete.ts} +73 -30
  50. package/src/server/hono/hubspot-connect-routes/auth-init-session.test.ts +114 -30
  51. package/src/server/hono/hubspot-connect-routes/auth-init-session.ts +33 -10
  52. package/src/server/hono/hubspot-connect-routes/auth-logout.test.ts +13 -0
  53. package/src/server/hono/hubspot-connect-routes/auth-logout.ts +18 -0
  54. package/src/server/hono/hubspot-connect-routes/auth-refresh.test.ts +6 -0
  55. package/src/server/hono/hubspot-connect-routes/auth-refresh.ts +6 -0
  56. package/src/server/hono/hubspot-connect-routes/hubspot-connect-routes.ts +9 -2
  57. package/src/server/hono/hubspot-connect-routes/utils.ts +57 -1
  58. package/src/server/hono/types.ts +15 -9
  59. package/src/server/hono/utils/cookie-utils.ts +27 -2
  60. package/src/server/hono/utils/cors-middleware.test.ts +79 -0
  61. package/src/server/hono/utils/cors-middleware.ts +95 -0
  62. package/src/server/sanitize-request.ts +25 -11
  63. package/src/server/types.ts +2 -2
  64. package/src/shared/constants.ts +31 -3
  65. package/src/shared/wire-types.ts +19 -0
  66. package/dist/browser/HubSpotAppConnect-BW45gyDs.js.map +0 -1
  67. package/dist/browser/create-vctOhpX9.js.map +0 -1
  68. package/dist/server/hono/hubspot-connect-routes/auth-callback.js +0 -125
  69. package/dist/server/hono/hubspot-connect-routes/auth-callback.js.map +0 -1
  70. package/src/server/hono/hubspot-connect-routes/auth-callback.test.ts +0 -225
@@ -12,6 +12,7 @@ import { sanitizeRequest } from '../sanitize-request.ts';
12
12
  import type { AppKeys, UserCredentials } from '../types.ts';
13
13
  import { parseCookies } from '../utils/cookie-utils.ts';
14
14
  import type { AppConnectHonoBindings, AppConnectHonoEnv } from './types.ts';
15
+ import { corsMiddleware } from './utils/cors-middleware.ts';
15
16
 
16
17
  /**
17
18
  * Web-standard fetch handler signature returned by
@@ -62,6 +63,29 @@ export function createAppConnectRequestHandler(
62
63
  ): AppConnectFetchHandler {
63
64
  const { registerRoutes, appKeys, logger = noopLogger } = options;
64
65
  const app = new Hono<AppConnectHonoEnv>();
66
+ // Credentialed CORS first: preflights short-circuit with 204
67
+ // before the auth check runs, and 401 responses still carry
68
+ // `Access-Control-Allow-*` headers (the browser drops responses
69
+ // without them on credentialed cross-site fetches).
70
+ app.use('*', corsMiddleware());
71
+
72
+ // Auth gate: every non-OPTIONS request must arrive with the
73
+ // SDK-managed access-token + session-id cookies. The CORS
74
+ // middleware above short-circuits OPTIONS, so this only runs on
75
+ // real requests; missing cookies now surface as a normal 401 with
76
+ // CORS headers attached on the way back out (the previous
77
+ // implementation threw on a missing `Cookie` header before any
78
+ // middleware ran, which broke browser preflights).
79
+ app.use('*', async (c, next) => {
80
+ const { authenticated } = c.env.hubSpot;
81
+ if (!authenticated) {
82
+ return c.json({ error: 'Unauthorized' }, 401);
83
+ }
84
+
85
+ await next();
86
+ return;
87
+ });
88
+
65
89
  registerRoutes(app);
66
90
 
67
91
  return (request: Request) => {
@@ -72,33 +96,36 @@ export function createAppConnectRequestHandler(
72
96
  const cookies = parseCookies(cookie);
73
97
  const accessToken = cookies[HUBSPOT_ACCESS_TOKEN_COOKIE_NAME];
74
98
  const sessionId = cookies[HUBSPOT_APP_SID_COOKIE_NAME];
75
- if (!accessToken || !sessionId) {
76
- // Return 401 Unauthorized
77
- return new Response(null, {
78
- status: 401,
79
- statusText: 'Unauthorized',
80
- headers: {
81
- 'Content-Type': 'application/json',
82
- },
83
- });
84
- }
99
+
85
100
  const userCredentials: UserCredentials = { accessToken, sessionId };
86
101
 
87
- const hubSpotProxy = createHubSpotProxy({
102
+ const proxy = createHubSpotProxy({
88
103
  userCredentials,
89
104
  appKeys,
90
105
  logger,
91
106
  });
92
107
 
93
- const hubSpotClient = createHubSpotClient({
94
- plugins: [fetchTransportPlugin({ getAccessToken: () => accessToken })],
108
+ const client = createHubSpotClient({
109
+ plugins: [
110
+ fetchTransportPlugin({
111
+ getAccessToken: () => {
112
+ if (!accessToken) {
113
+ throw new Error('Missing access token');
114
+ }
115
+ return accessToken;
116
+ },
117
+ }),
118
+ ],
95
119
  });
96
120
 
97
121
  const sanitizedRequest = sanitizeRequest(request);
98
122
 
99
123
  const honoBindings: AppConnectHonoBindings = {
100
- hubSpotProxy,
101
- hubSpotClient,
124
+ hubSpot: {
125
+ proxy,
126
+ client,
127
+ authenticated: proxy.authenticated,
128
+ },
102
129
  };
103
130
 
104
131
  return app.fetch(sanitizedRequest, honoBindings);
@@ -0,0 +1,285 @@
1
+ import { Hono } from 'hono';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { HUBSPOT_FRONTEND_CALLBACK_PATH } from '../../../shared/constants.ts';
5
+ import {
6
+ HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
7
+ HUBSPOT_APP_ORIGIN_COOKIE_NAME,
8
+ HUBSPOT_REFRESH_COOKIE_PREFIX,
9
+ TEMP_COOKIE_OAUTH_STATE,
10
+ TEMP_COOKIE_PKCE_VERIFIER,
11
+ } from '../../constants.ts';
12
+ import { handleAuthComplete } from './auth-complete.ts';
13
+ import type { HubSpotConnectRoutesEnvClientSecret } from './load-hubspot-connect-routes-env.ts';
14
+ import type { HubSpotConnectOAuthRouteOptions } from './types.ts';
15
+
16
+ const hubspotConnectEnv = {
17
+ hubspotAuthorizationEndpoint: 'https://auth.example.test/oauth/authorize',
18
+ hubspotOAuthApiOrigin: 'https://auth.example.test',
19
+ isCimdEnabled: false,
20
+ isDpopEnabled: false,
21
+ isAppPrivateKeyRequired: false,
22
+ hubspotClientId: 'test-client-id',
23
+ hubspotClientSecret: 'test-client-secret',
24
+ } satisfies HubSpotConnectRoutesEnvClientSecret;
25
+
26
+ const BASE_PATH = '/functions/v1/hubspot-connect';
27
+ const APP_ORIGIN = 'https://app.example.com';
28
+
29
+ function buildOAuthRouteOptions(): HubSpotConnectOAuthRouteOptions {
30
+ return {
31
+ appKeys: null,
32
+ refreshCookiePath: `${BASE_PATH}/auth`,
33
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
34
+ basePath: BASE_PATH,
35
+ hubspotConnectEnv,
36
+ cimdClientMetadata: { scope: { required: ['crm.objects.contacts.read'] } },
37
+ };
38
+ }
39
+
40
+ function base64urlEncode(input: string): string {
41
+ let binary = '';
42
+ const bytes = new TextEncoder().encode(input);
43
+ for (const byte of bytes) binary += String.fromCharCode(byte);
44
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
45
+ }
46
+
47
+ interface CompleteFixture {
48
+ stateValue: string;
49
+ cookieHeader: string;
50
+ }
51
+
52
+ function buildCompleteFixture(options?: {
53
+ returnPath?: string;
54
+ sid?: string;
55
+ appOrigin?: string | null;
56
+ }): CompleteFixture {
57
+ const returnPath = options?.returnPath ?? '/dashboard';
58
+ const sid = options?.sid ?? 'test-session-id-hash';
59
+ const appOrigin =
60
+ options?.appOrigin === null ? null : (options?.appOrigin ?? APP_ORIGIN);
61
+ const stateValue = base64urlEncode(
62
+ JSON.stringify({ return_path: returnPath, sid })
63
+ );
64
+ const cookieParts = [
65
+ `${TEMP_COOKIE_OAUTH_STATE}=${encodeURIComponent(stateValue)}`,
66
+ `${TEMP_COOKIE_PKCE_VERIFIER}=${encodeURIComponent('test-pkce-verifier')}`,
67
+ ];
68
+ if (appOrigin !== null) {
69
+ cookieParts.push(`${HUBSPOT_APP_ORIGIN_COOKIE_NAME}=${appOrigin}`);
70
+ }
71
+ return { stateValue, cookieHeader: cookieParts.join('; ') };
72
+ }
73
+
74
+ function buildCompleteUrl(stateValue: string, code = 'test-auth-code'): string {
75
+ const url = new URL(`http://localhost${BASE_PATH}/auth/complete`);
76
+ url.searchParams.set('code', code);
77
+ url.searchParams.set('state', stateValue);
78
+ return url.toString();
79
+ }
80
+
81
+ describe('handleAuthComplete', () => {
82
+ afterEach(() => {
83
+ vi.restoreAllMocks();
84
+ });
85
+
86
+ it('returns 400 when code is missing', async () => {
87
+ const app = new Hono();
88
+ app.post('/auth/complete', (c) =>
89
+ handleAuthComplete(c, buildOAuthRouteOptions())
90
+ );
91
+ const res = await app.request('http://localhost/auth/complete?state=abc', {
92
+ method: 'POST',
93
+ });
94
+ expect(res.status).toBe(400);
95
+ expect(await res.json()).toMatchObject({ error: 'Missing code or state' });
96
+ });
97
+
98
+ it('returns 400 when state is missing', async () => {
99
+ const app = new Hono();
100
+ app.post('/auth/complete', (c) =>
101
+ handleAuthComplete(c, buildOAuthRouteOptions())
102
+ );
103
+ const res = await app.request('http://localhost/auth/complete?code=abc', {
104
+ method: 'POST',
105
+ });
106
+ expect(res.status).toBe(400);
107
+ expect(await res.json()).toMatchObject({ error: 'Missing code or state' });
108
+ });
109
+
110
+ it('returns 403 when state cookie is missing', async () => {
111
+ const app = new Hono();
112
+ app.post(`${BASE_PATH}/auth/complete`, (c) =>
113
+ handleAuthComplete(c, buildOAuthRouteOptions())
114
+ );
115
+ const { stateValue } = buildCompleteFixture();
116
+ const res = await app.request(buildCompleteUrl(stateValue), {
117
+ method: 'POST',
118
+ });
119
+ expect(res.status).toBe(403);
120
+ expect(await res.json()).toMatchObject({ error: 'State mismatch' });
121
+ });
122
+
123
+ it('returns 403 when state does not match cookie', async () => {
124
+ const app = new Hono();
125
+ app.post(`${BASE_PATH}/auth/complete`, (c) =>
126
+ handleAuthComplete(c, buildOAuthRouteOptions())
127
+ );
128
+ const { cookieHeader } = buildCompleteFixture();
129
+ const res = await app.request(buildCompleteUrl('wrong-state-value'), {
130
+ method: 'POST',
131
+ headers: { Cookie: cookieHeader },
132
+ });
133
+ expect(res.status).toBe(403);
134
+ });
135
+
136
+ it('returns 400 when state payload has invalid return_path', async () => {
137
+ const app = new Hono();
138
+ app.post(`${BASE_PATH}/auth/complete`, (c) =>
139
+ handleAuthComplete(c, buildOAuthRouteOptions())
140
+ );
141
+ const { stateValue, cookieHeader } = buildCompleteFixture({
142
+ returnPath: '//evil.example.com',
143
+ });
144
+ const res = await app.request(buildCompleteUrl(stateValue), {
145
+ method: 'POST',
146
+ headers: { Cookie: cookieHeader },
147
+ });
148
+ expect(res.status).toBe(400);
149
+ expect(await res.json()).toMatchObject({
150
+ error: expect.stringContaining('Invalid return path'),
151
+ });
152
+ });
153
+
154
+ it('returns 400 when the pinned app-origin cookie is absent', async () => {
155
+ const app = new Hono();
156
+ app.post(`${BASE_PATH}/auth/complete`, (c) =>
157
+ handleAuthComplete(c, buildOAuthRouteOptions())
158
+ );
159
+ const { stateValue, cookieHeader } = buildCompleteFixture({
160
+ appOrigin: null,
161
+ });
162
+ const res = await app.request(buildCompleteUrl(stateValue), {
163
+ method: 'POST',
164
+ headers: { Cookie: cookieHeader },
165
+ });
166
+ expect(res.status).toBe(400);
167
+ expect(await res.json()).toMatchObject({
168
+ error: expect.stringContaining('app origin'),
169
+ });
170
+ });
171
+
172
+ it('returns 502 when upstream token exchange fails', async () => {
173
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
174
+ new Response('{"error":"invalid_grant"}', {
175
+ status: 400,
176
+ statusText: 'Bad Request',
177
+ })
178
+ );
179
+ const app = new Hono();
180
+ app.post(`${BASE_PATH}/auth/complete`, (c) =>
181
+ handleAuthComplete(c, buildOAuthRouteOptions())
182
+ );
183
+ const { stateValue, cookieHeader } = buildCompleteFixture();
184
+ const res = await app.request(buildCompleteUrl(stateValue), {
185
+ method: 'POST',
186
+ headers: { Cookie: cookieHeader },
187
+ });
188
+ expect(res.status).toBe(502);
189
+ expect(await res.json()).toMatchObject({
190
+ error: expect.stringContaining('Token exchange failed'),
191
+ });
192
+ });
193
+
194
+ it('sends the OAuth token endpoint the same redirect_uri it advertised in init-session', async () => {
195
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
196
+ new Response(
197
+ JSON.stringify({
198
+ access_token: 'at',
199
+ refresh_token: 'rt',
200
+ expires_in: 1800,
201
+ }),
202
+ { status: 200 }
203
+ )
204
+ );
205
+ const app = new Hono();
206
+ app.post(`${BASE_PATH}/auth/complete`, (c) =>
207
+ handleAuthComplete(c, buildOAuthRouteOptions())
208
+ );
209
+ const { stateValue, cookieHeader } = buildCompleteFixture();
210
+ await app.request(buildCompleteUrl(stateValue), {
211
+ method: 'POST',
212
+ headers: { Cookie: cookieHeader },
213
+ });
214
+
215
+ const [, init] = fetchSpy.mock.calls[0]!;
216
+ const body = (init as RequestInit).body as URLSearchParams | string;
217
+ const formParams = new URLSearchParams(body as string);
218
+ expect(formParams.get('redirect_uri')).toBe(
219
+ `${APP_ORIGIN}${HUBSPOT_FRONTEND_CALLBACK_PATH}`
220
+ );
221
+ expect(formParams.get('grant_type')).toBe('authorization_code');
222
+ });
223
+
224
+ it('returns 200 with expires_at + return_path and sets durable cookies on success', async () => {
225
+ const beforeMs = Date.now();
226
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
227
+ new Response(
228
+ JSON.stringify({
229
+ access_token: 'new-access-token',
230
+ refresh_token: 'new-refresh-token',
231
+ expires_in: 1800,
232
+ }),
233
+ { status: 200 }
234
+ )
235
+ );
236
+ const app = new Hono();
237
+ app.post(`${BASE_PATH}/auth/complete`, (c) =>
238
+ handleAuthComplete(c, buildOAuthRouteOptions())
239
+ );
240
+ const { stateValue, cookieHeader } = buildCompleteFixture({
241
+ returnPath: '/dashboard',
242
+ sid: 'abc123sid',
243
+ });
244
+ const res = await app.request(buildCompleteUrl(stateValue), {
245
+ method: 'POST',
246
+ headers: { Cookie: cookieHeader },
247
+ });
248
+
249
+ expect(res.status).toBe(200);
250
+ const body = (await res.json()) as {
251
+ expires_at: number;
252
+ return_path: string;
253
+ };
254
+ expect(body.return_path).toBe('/dashboard');
255
+ expect(body.expires_at).toBeGreaterThanOrEqual(beforeMs + 1800 * 1000 - 50);
256
+
257
+ const setCookies = res.headers.getSetCookie();
258
+
259
+ const accessCookie = setCookies.find((h) =>
260
+ h.startsWith(`${HUBSPOT_ACCESS_TOKEN_COOKIE_NAME}=`)
261
+ );
262
+ expect(accessCookie).toBeDefined();
263
+ expect(accessCookie).toContain('new-access-token');
264
+ expect(accessCookie).toContain('SameSite=None');
265
+ expect(accessCookie).toContain('Partitioned');
266
+
267
+ const refreshCookie = setCookies.find((h) =>
268
+ h.startsWith(`${HUBSPOT_REFRESH_COOKIE_PREFIX}`)
269
+ );
270
+ expect(refreshCookie).toBeDefined();
271
+ expect(refreshCookie).toContain('new-refresh-token');
272
+ expect(refreshCookie).toContain('SameSite=None');
273
+ expect(refreshCookie).toContain('Partitioned');
274
+
275
+ const pkceCleared = setCookies.find((h) =>
276
+ h.startsWith(`${TEMP_COOKIE_PKCE_VERIFIER}=`)
277
+ );
278
+ expect(pkceCleared).toContain('Max-Age=0');
279
+
280
+ const stateCleared = setCookies.find((h) =>
281
+ h.startsWith(`${TEMP_COOKIE_OAUTH_STATE}=`)
282
+ );
283
+ expect(stateCleared).toContain('Max-Age=0');
284
+ });
285
+ });
@@ -1,8 +1,12 @@
1
1
  import type { Context } from 'hono';
2
2
 
3
- import { EXPIRES_AT_URL_PARAM } from '../../../shared/constants.ts';
3
+ import {
4
+ AUTH_COMPLETE_CODE_PARAM,
5
+ AUTH_COMPLETE_STATE_PARAM,
6
+ } from '../../../shared/constants.ts';
4
7
  import {
5
8
  HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
9
+ HUBSPOT_APP_ORIGIN_COOKIE_NAME,
6
10
  HUBSPOT_REFRESH_COOKIE_PREFIX,
7
11
  TEMP_COOKIE_OAUTH_STATE,
8
12
  TEMP_COOKIE_PKCE_VERIFIER,
@@ -21,10 +25,11 @@ import {
21
25
  import type { HubSpotConnectOAuthRouteOptions } from './types.ts';
22
26
  import {
23
27
  buildCimdClientIdUrlFromRequest,
24
- buildOAuthRedirectUriFromRequest,
28
+ buildFrontendOAuthRedirectUri,
25
29
  clearTempCookie,
26
30
  isPositiveFiniteNumber,
27
31
  isSafeReturnPath,
32
+ parseAppOriginHeader,
28
33
  } from './utils.ts';
29
34
 
30
35
  interface OAuthStatePayload {
@@ -32,24 +37,53 @@ interface OAuthStatePayload {
32
37
  sid?: string;
33
38
  }
34
39
 
35
- export async function handleAuthCallback(
40
+ /**
41
+ * Cross-origin OAuth completion endpoint.
42
+ *
43
+ * Called from the React app on the frontend OAuth callback path
44
+ * (`HUBSPOT_FRONTEND_CALLBACK_PATH`) once HubSpot has redirected the
45
+ * browser back with `?code` + `?state`. The browser POSTs both
46
+ * values here as a credentialed cross-site fetch — same partition as
47
+ * `init-session`, so the temp PKCE/state cookies are visible — and
48
+ * the SDK:
49
+ *
50
+ * 1. Validates `state` against the temp `__hs_oauth_state` cookie.
51
+ * 2. Pulls the PKCE verifier from `__hs_pkce_verifier`.
52
+ * 3. Rebuilds the same `redirect_uri` it sent to HubSpot during
53
+ * `init-session` (frontend origin + the fixed callback path);
54
+ * the OAuth token endpoint requires the two values to match.
55
+ * 4. Exchanges `code` for an access + refresh token (with DPoP /
56
+ * CIMD client-assertion when enabled).
57
+ * 5. Sets the durable session cookies (access token, refresh) with
58
+ * `SameSite=None; Secure; Partitioned` so they live in the
59
+ * `(frontend, edge)` partition where subsequent API fetches will
60
+ * read them.
61
+ * 6. Clears the temp cookies.
62
+ * 7. Returns `{ expires_at, return_path }` so the controller can
63
+ * update its session-storage expiry tracking and navigate back to
64
+ * the page the user started the connect flow from.
65
+ */
66
+ export async function handleAuthComplete(
36
67
  c: Context,
37
68
  options: HubSpotConnectOAuthRouteOptions
38
69
  ) {
39
- const { appKeys, refreshCookiePath, basePath, hubspotConnectEnv } = options;
70
+ const { appKeys, refreshCookiePath, hubspotConnectEnv } = options;
40
71
  const xForwardedProto = c.req.header('x-forwarded-proto') ?? undefined;
41
72
  const xForwardedHost = c.req.header('x-forwarded-host') ?? undefined;
42
73
  const requestHostHeader = c.req.header('host') ?? undefined;
43
- const code = c.req.query('code');
44
- const state = c.req.query('state');
74
+ const code = c.req.query(AUTH_COMPLETE_CODE_PARAM);
75
+ const state = c.req.query(AUTH_COMPLETE_STATE_PARAM);
45
76
 
46
77
  if (!code || !state) {
47
- return c.text('Missing code or state', 400);
78
+ return c.json({ error: 'Missing code or state' }, 400);
48
79
  }
49
80
 
50
81
  if (hubspotConnectEnv.isAppPrivateKeyRequired && !appKeys) {
51
- return c.text(
52
- 'Server misconfiguration: HUBSPOT_APP_PRIVATE_KEY is required when CIMD or DPoP is enabled',
82
+ return c.json(
83
+ {
84
+ error:
85
+ 'Server misconfiguration: HUBSPOT_APP_PRIVATE_KEY is required when CIMD or DPoP is enabled',
86
+ },
53
87
  500
54
88
  );
55
89
  }
@@ -57,12 +91,21 @@ export async function handleAuthCallback(
57
91
  const cookies = parseCookies(c.req.header('Cookie'));
58
92
  const expectedState = cookies[TEMP_COOKIE_OAUTH_STATE];
59
93
  const codeVerifier = cookies[TEMP_COOKIE_PKCE_VERIFIER];
94
+ const appOriginCookie = cookies[HUBSPOT_APP_ORIGIN_COOKIE_NAME];
60
95
 
61
96
  if (!expectedState || state !== decodeURIComponent(expectedState)) {
62
- return c.text('State mismatch', 403);
97
+ return c.json({ error: 'State mismatch' }, 403);
63
98
  }
64
99
  if (!codeVerifier) {
65
- return c.text('Missing PKCE verifier', 400);
100
+ return c.json({ error: 'Missing PKCE verifier' }, 400);
101
+ }
102
+ // The redirect_uri the OAuth token endpoint validates must equal
103
+ // the one we sent during init-session. We rebuild it from the
104
+ // pinned origin cookie so that value is anchored server-side, not
105
+ // taken from the (caller-controlled) request `Origin` on this call.
106
+ const appOrigin = parseAppOriginHeader(appOriginCookie);
107
+ if (!appOrigin) {
108
+ return c.json({ error: 'Missing app origin cookie' }, 400);
66
109
  }
67
110
 
68
111
  let statePayload: OAuthStatePayload;
@@ -71,16 +114,16 @@ export async function handleAuthCallback(
71
114
  new TextDecoder().decode(base64urlDecode(decodeURIComponent(state)))
72
115
  ) as OAuthStatePayload;
73
116
  } catch {
74
- return c.text('Malformed state value', 400);
117
+ return c.json({ error: 'Malformed state value' }, 400);
75
118
  }
76
119
  const returnPath = statePayload.return_path;
77
120
  if (!returnPath || !isSafeReturnPath(returnPath)) {
78
- return c.text('Invalid return path in state', 400);
121
+ return c.json({ error: 'Invalid return path in state' }, 400);
79
122
  }
80
123
 
81
124
  const sessionId = statePayload.sid;
82
125
  if (!sessionId) {
83
- return c.text('Missing app session cookie', 400);
126
+ return c.json({ error: 'Missing app session cookie' }, 400);
84
127
  }
85
128
 
86
129
  const decodedCodeVerifier = decodeURIComponent(codeVerifier);
@@ -88,20 +131,14 @@ export async function handleAuthCallback(
88
131
  const clientId = hubspotConnectEnv.isCimdEnabled
89
132
  ? buildCimdClientIdUrlFromRequest({
90
133
  requestUrl: c.req.url,
91
- basePath,
134
+ basePath: options.basePath,
92
135
  xForwardedProto,
93
136
  xForwardedHost,
94
137
  requestHostHeader,
95
138
  })
96
139
  : hubspotConnectEnv.hubspotClientId;
97
140
 
98
- const redirectUri = buildOAuthRedirectUriFromRequest({
99
- requestUrl: c.req.url,
100
- basePath,
101
- xForwardedProto,
102
- xForwardedHost,
103
- requestHostHeader,
104
- });
141
+ const redirectUri = buildFrontendOAuthRedirectUri(appOrigin);
105
142
 
106
143
  const tokenEndpointUrl = new URL(
107
144
  '/oauth/v1/token',
@@ -151,7 +188,10 @@ export async function handleAuthCallback(
151
188
  formParams,
152
189
  });
153
190
  if (!tokenResult.ok) {
154
- return c.text(`Token exchange failed: ${tokenResult.errorText}`, 502);
191
+ return c.json(
192
+ { error: `Token exchange failed: ${tokenResult.errorText}` },
193
+ 502
194
+ );
155
195
  }
156
196
 
157
197
  const {
@@ -160,10 +200,13 @@ export async function handleAuthCallback(
160
200
  expires_in,
161
201
  } = tokenResult.body;
162
202
  if (!refreshToken) {
163
- return c.text('Token response missing refresh_token', 502);
203
+ return c.json({ error: 'Token response missing refresh_token' }, 502);
164
204
  }
165
205
  if (!isPositiveFiniteNumber(expires_in)) {
166
- return c.text('Token response missing or invalid expires_in', 502);
206
+ return c.json(
207
+ { error: 'Token response missing or invalid expires_in' },
208
+ 502
209
+ );
167
210
  }
168
211
 
169
212
  const expiresAt = Date.now() + expires_in * 1000;
@@ -175,6 +218,8 @@ export async function handleAuthCallback(
175
218
  name: HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
176
219
  value: accessToken,
177
220
  path: '/',
221
+ sameSite: 'None',
222
+ partitioned: true,
178
223
  maxAge: expires_in,
179
224
  }),
180
225
  });
@@ -184,15 +229,13 @@ export async function handleAuthCallback(
184
229
  name: refreshCookieName,
185
230
  value: refreshToken,
186
231
  path: refreshCookiePath,
232
+ sameSite: 'None',
233
+ partitioned: true,
187
234
  maxAge: REFRESH_COOKIE_MAX_AGE_SEC,
188
235
  }),
189
236
  });
190
237
  setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_PKCE_VERIFIER) });
191
238
  setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_OAUTH_STATE) });
192
239
 
193
- const separator = returnPath.includes('?') ? '&' : '?';
194
- return c.redirect(
195
- `${returnPath}${separator}${EXPIRES_AT_URL_PARAM}=${expiresAt}`,
196
- 302
197
- );
240
+ return c.json({ expires_at: expiresAt, return_path: returnPath });
198
241
  }