@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
@@ -1,7 +1,9 @@
1
1
  import { Hono } from 'hono';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
 
4
+ import { HUBSPOT_FRONTEND_CALLBACK_PATH } from '../../../shared/constants.ts';
4
5
  import {
6
+ HUBSPOT_APP_ORIGIN_COOKIE_NAME,
5
7
  HUBSPOT_APP_SID_COOKIE_NAME,
6
8
  TEMP_COOKIE_OAUTH_STATE,
7
9
  TEMP_COOKIE_PKCE_VERIFIER,
@@ -21,6 +23,7 @@ const hubspotConnectEnv = {
21
23
  } satisfies HubSpotConnectRoutesEnvClientSecret;
22
24
 
23
25
  const BASE_PATH = '/functions/v1/hubspot-connect';
26
+ const APP_ORIGIN = 'https://app.example.com';
24
27
 
25
28
  function buildOAuthRouteOptions(): HubSpotConnectOAuthRouteOptions {
26
29
  return {
@@ -38,60 +41,109 @@ function buildOAuthRouteOptions(): HubSpotConnectOAuthRouteOptions {
38
41
  };
39
42
  }
40
43
 
44
+ function buildInitSessionRequest(options: {
45
+ returnPath?: string;
46
+ origin?: string | null;
47
+ }): Request {
48
+ const url = new URL('http://localhost/auth/init-session');
49
+ if (options.returnPath !== undefined) {
50
+ url.searchParams.set('return_path', options.returnPath);
51
+ }
52
+ const headers = new Headers();
53
+ if (options.origin !== null && options.origin !== undefined) {
54
+ headers.set('Origin', options.origin);
55
+ }
56
+ return new Request(url.toString(), { method: 'GET', headers });
57
+ }
58
+
41
59
  describe('handleAuthInitSession', () => {
42
60
  it('returns 400 for an unsafe return_path (open redirect)', async () => {
43
61
  const app = new Hono();
44
62
  app.get('/auth/init-session', (c) =>
45
63
  handleAuthInitSession(c, buildOAuthRouteOptions())
46
64
  );
47
- const res = await app.request(
48
- 'http://localhost/auth/init-session?return_path=//evil.example.com',
49
- { method: 'GET' }
65
+ const res = await app.fetch(
66
+ buildInitSessionRequest({
67
+ returnPath: '//evil.example.com',
68
+ origin: APP_ORIGIN,
69
+ })
50
70
  );
51
71
  expect(res.status).toBe(400);
52
72
  expect(await res.text()).toContain('Invalid return_path');
53
73
  });
54
74
 
55
- it('returns JSON with authorization_url on success', async () => {
75
+ it('returns 400 when the Origin header is missing', async () => {
76
+ const app = new Hono();
77
+ app.get('/auth/init-session', (c) =>
78
+ handleAuthInitSession(c, buildOAuthRouteOptions())
79
+ );
80
+ const res = await app.fetch(
81
+ buildInitSessionRequest({ returnPath: '/dashboard', origin: null })
82
+ );
83
+ expect(res.status).toBe(400);
84
+ expect(await res.text()).toContain('Origin');
85
+ });
86
+
87
+ it('returns 400 when the Origin header is not an https:// or localhost origin', async () => {
56
88
  const app = new Hono();
57
89
  app.get('/auth/init-session', (c) =>
58
90
  handleAuthInitSession(c, buildOAuthRouteOptions())
59
91
  );
60
- const res = await app.request(
61
- 'http://localhost/auth/init-session?return_path=/dashboard',
62
- { method: 'GET' }
92
+ const res = await app.fetch(
93
+ buildInitSessionRequest({
94
+ returnPath: '/dashboard',
95
+ origin: 'http://evil.example.com',
96
+ })
97
+ );
98
+ expect(res.status).toBe(400);
99
+ });
100
+
101
+ it('builds the OAuth redirect_uri from the request Origin and the frontend callback path', async () => {
102
+ const app = new Hono();
103
+ app.get('/auth/init-session', (c) =>
104
+ handleAuthInitSession(c, buildOAuthRouteOptions())
105
+ );
106
+ const res = await app.fetch(
107
+ buildInitSessionRequest({ returnPath: '/dashboard', origin: APP_ORIGIN })
63
108
  );
64
109
 
65
110
  expect(res.status).toBe(200);
66
111
  const body = (await res.json()) as { authorization_url: string };
67
- expect(body.authorization_url).toBeDefined();
68
-
69
112
  const authUrl = new URL(body.authorization_url);
70
- expect(authUrl.origin).toBe('https://auth.example.test');
71
- expect(authUrl.searchParams.get('response_type')).toBe('code');
72
- expect(authUrl.searchParams.get('client_id')).toBe('test-client-id');
73
- expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256');
74
- expect(authUrl.searchParams.get('code_challenge')).toBeTruthy();
75
- expect(authUrl.searchParams.get('state')).toBeTruthy();
76
- expect(authUrl.searchParams.get('scope')).toContain(
77
- 'crm.objects.contacts.read'
78
- );
79
- expect(authUrl.searchParams.get('optional_scope')).toContain(
80
- 'crm.objects.deals.read'
113
+ expect(authUrl.searchParams.get('redirect_uri')).toBe(
114
+ `${APP_ORIGIN}${HUBSPOT_FRONTEND_CALLBACK_PATH}`
81
115
  );
82
116
  });
83
117
 
84
- it('sets session, PKCE verifier, and state cookies', async () => {
118
+ it('pins the request Origin in `__Host-hs_app_origin` with SameSite=None; Partitioned', async () => {
85
119
  const app = new Hono();
86
120
  app.get('/auth/init-session', (c) =>
87
121
  handleAuthInitSession(c, buildOAuthRouteOptions())
88
122
  );
89
- const res = await app.request(
90
- 'http://localhost/auth/init-session?return_path=/dashboard',
91
- { method: 'GET' }
123
+ const res = await app.fetch(
124
+ buildInitSessionRequest({ returnPath: '/dashboard', origin: APP_ORIGIN })
125
+ );
126
+ const setCookies = res.headers.getSetCookie();
127
+ const originCookie = setCookies.find((h) =>
128
+ h.startsWith(`${HUBSPOT_APP_ORIGIN_COOKIE_NAME}=`)
129
+ );
130
+ expect(originCookie).toBeDefined();
131
+ expect(originCookie).toContain(
132
+ `${HUBSPOT_APP_ORIGIN_COOKIE_NAME}=${APP_ORIGIN}`
92
133
  );
134
+ expect(originCookie).toContain('SameSite=None');
135
+ expect(originCookie).toContain('Secure');
136
+ expect(originCookie).toContain('Partitioned');
137
+ });
93
138
 
94
- expect(res.status).toBe(200);
139
+ it('sets all session cookies with SameSite=None; Partitioned', async () => {
140
+ const app = new Hono();
141
+ app.get('/auth/init-session', (c) =>
142
+ handleAuthInitSession(c, buildOAuthRouteOptions())
143
+ );
144
+ const res = await app.fetch(
145
+ buildInitSessionRequest({ returnPath: '/dashboard', origin: APP_ORIGIN })
146
+ );
95
147
  const setCookies = res.headers.getSetCookie();
96
148
 
97
149
  const sidCookie = setCookies.find((h) =>
@@ -99,18 +151,50 @@ describe('handleAuthInitSession', () => {
99
151
  );
100
152
  expect(sidCookie).toBeDefined();
101
153
  expect(sidCookie).toContain('HttpOnly');
154
+ expect(sidCookie).toContain('SameSite=None');
155
+ expect(sidCookie).toContain('Partitioned');
102
156
 
103
157
  const pkceCookie = setCookies.find((h) =>
104
158
  h.startsWith(`${TEMP_COOKIE_PKCE_VERIFIER}=`)
105
159
  );
106
160
  expect(pkceCookie).toBeDefined();
107
- expect(pkceCookie).toContain('SameSite=Lax');
161
+ expect(pkceCookie).toContain('SameSite=None');
162
+ expect(pkceCookie).toContain('Partitioned');
108
163
 
109
164
  const stateCookie = setCookies.find((h) =>
110
165
  h.startsWith(`${TEMP_COOKIE_OAUTH_STATE}=`)
111
166
  );
112
167
  expect(stateCookie).toBeDefined();
113
- expect(stateCookie).toContain('SameSite=Lax');
168
+ expect(stateCookie).toContain('SameSite=None');
169
+ expect(stateCookie).toContain('Partitioned');
170
+ });
171
+
172
+ it('returns JSON with authorization_url on success', async () => {
173
+ const app = new Hono();
174
+ app.get('/auth/init-session', (c) =>
175
+ handleAuthInitSession(c, buildOAuthRouteOptions())
176
+ );
177
+ const res = await app.fetch(
178
+ buildInitSessionRequest({ returnPath: '/dashboard', origin: APP_ORIGIN })
179
+ );
180
+
181
+ expect(res.status).toBe(200);
182
+ const body = (await res.json()) as { authorization_url: string };
183
+ expect(body.authorization_url).toBeDefined();
184
+
185
+ const authUrl = new URL(body.authorization_url);
186
+ expect(authUrl.origin).toBe('https://auth.example.test');
187
+ expect(authUrl.searchParams.get('response_type')).toBe('code');
188
+ expect(authUrl.searchParams.get('client_id')).toBe('test-client-id');
189
+ expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256');
190
+ expect(authUrl.searchParams.get('code_challenge')).toBeTruthy();
191
+ expect(authUrl.searchParams.get('state')).toBeTruthy();
192
+ expect(authUrl.searchParams.get('scope')).toContain(
193
+ 'crm.objects.contacts.read'
194
+ );
195
+ expect(authUrl.searchParams.get('optional_scope')).toContain(
196
+ 'crm.objects.deals.read'
197
+ );
114
198
  });
115
199
 
116
200
  it('defaults return_path to / when param is absent', async () => {
@@ -118,9 +202,9 @@ describe('handleAuthInitSession', () => {
118
202
  app.get('/auth/init-session', (c) =>
119
203
  handleAuthInitSession(c, buildOAuthRouteOptions())
120
204
  );
121
- const res = await app.request('http://localhost/auth/init-session', {
122
- method: 'GET',
123
- });
205
+ const res = await app.fetch(
206
+ buildInitSessionRequest({ origin: APP_ORIGIN })
207
+ );
124
208
  expect(res.status).toBe(200);
125
209
  const body = (await res.json()) as { authorization_url: string };
126
210
  const state =
@@ -1,6 +1,7 @@
1
1
  import type { Context } from 'hono';
2
2
 
3
3
  import {
4
+ HUBSPOT_APP_ORIGIN_COOKIE_NAME,
4
5
  HUBSPOT_APP_SID_COOKIE_NAME,
5
6
  TEMP_COOKIE_OAUTH_STATE,
6
7
  TEMP_COOKIE_PKCE_VERIFIER,
@@ -13,8 +14,9 @@ import { deriveHubSpotAuthorizeScopesFromClientMetadata } from './fetch-hubspot-
13
14
  import type { HubSpotConnectOAuthRouteOptions } from './types.ts';
14
15
  import {
15
16
  buildCimdClientIdUrlFromRequest,
16
- buildOAuthRedirectUriFromRequest,
17
+ buildFrontendOAuthRedirectUri,
17
18
  isSafeReturnPath,
19
+ parseAppOriginHeader,
18
20
  } from './utils.ts';
19
21
 
20
22
  export async function handleAuthInitSession(
@@ -31,6 +33,18 @@ export async function handleAuthInitSession(
31
33
  return c.text('Invalid return_path', 400);
32
34
  }
33
35
 
36
+ // The app origin pins the OAuth `redirect_uri` (which lands on the
37
+ // frontend, not on this edge function) and, via the persisted
38
+ // `__Host-hs_app_origin` cookie, drives credentialed
39
+ // `Access-Control-Allow-Origin` on every subsequent SDK response.
40
+ const appOrigin = parseAppOriginHeader(c.req.header('Origin'));
41
+ if (!appOrigin) {
42
+ return c.text(
43
+ 'Missing or invalid Origin header; init-session must be called from a browser',
44
+ 400
45
+ );
46
+ }
47
+
34
48
  const sessionIdBytes = new Uint8Array(32);
35
49
  crypto.getRandomValues(sessionIdBytes);
36
50
  const sessionId = base64url(sessionIdBytes);
@@ -60,13 +74,7 @@ export async function handleAuthInitSession(
60
74
  })
61
75
  : hubspotConnectEnv.hubspotClientId;
62
76
 
63
- const redirectUri = buildOAuthRedirectUriFromRequest({
64
- requestUrl: c.req.url,
65
- basePath: options.basePath,
66
- xForwardedProto,
67
- xForwardedHost,
68
- requestHostHeader,
69
- });
77
+ const redirectUri = buildFrontendOAuthRedirectUri(appOrigin);
70
78
 
71
79
  const authorizeUrl = new URL(hubspotConnectEnv.hubspotAuthorizationEndpoint);
72
80
  authorizeUrl.searchParams.set('response_type', 'code');
@@ -92,12 +100,25 @@ export async function handleAuthInitSession(
92
100
  }
93
101
  }
94
102
 
103
+ setResponseCookie({
104
+ c,
105
+ value: serializeCookie({
106
+ name: HUBSPOT_APP_ORIGIN_COOKIE_NAME,
107
+ value: appOrigin,
108
+ path: '/',
109
+ sameSite: 'None',
110
+ partitioned: true,
111
+ maxAge: SESSION_MAX_AGE_SEC,
112
+ }),
113
+ });
95
114
  setResponseCookie({
96
115
  c,
97
116
  value: serializeCookie({
98
117
  name: HUBSPOT_APP_SID_COOKIE_NAME,
99
118
  value: sessionId,
100
119
  path: '/',
120
+ sameSite: 'None',
121
+ partitioned: true,
101
122
  maxAge: SESSION_MAX_AGE_SEC,
102
123
  }),
103
124
  });
@@ -107,7 +128,8 @@ export async function handleAuthInitSession(
107
128
  name: TEMP_COOKIE_PKCE_VERIFIER,
108
129
  value: encodeURIComponent(codeVerifier),
109
130
  path: '/',
110
- sameSite: 'Lax',
131
+ sameSite: 'None',
132
+ partitioned: true,
111
133
  maxAge: OAUTH_TEMP_MAX_AGE_SEC,
112
134
  }),
113
135
  });
@@ -117,7 +139,8 @@ export async function handleAuthInitSession(
117
139
  name: TEMP_COOKIE_OAUTH_STATE,
118
140
  value: encodeURIComponent(stateValue),
119
141
  path: '/',
120
- sameSite: 'Lax',
142
+ sameSite: 'None',
143
+ partitioned: true,
121
144
  maxAge: OAUTH_TEMP_MAX_AGE_SEC,
122
145
  }),
123
146
  });
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
4
4
  import type { Logger } from '../../../shared/logger.ts';
5
5
  import {
6
6
  HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
7
+ HUBSPOT_APP_ORIGIN_COOKIE_NAME,
7
8
  HUBSPOT_APP_SID_COOKIE_NAME,
8
9
  } from '../../constants.ts';
9
10
  import { handleAuthLogout } from './auth-logout.ts';
@@ -76,12 +77,24 @@ describe('handleAuthLogout', () => {
76
77
  );
77
78
  expect(accessCookie).toBeDefined();
78
79
  expect(accessCookie).toContain('Max-Age=0');
80
+ expect(accessCookie).toContain('SameSite=None');
81
+ expect(accessCookie).toContain('Partitioned');
79
82
 
80
83
  const sidCookie = setCookies.find((header) =>
81
84
  header.startsWith(`${HUBSPOT_APP_SID_COOKIE_NAME}=`)
82
85
  );
83
86
  expect(sidCookie).toBeDefined();
84
87
  expect(sidCookie).toContain('Max-Age=0');
88
+ expect(sidCookie).toContain('SameSite=None');
89
+ expect(sidCookie).toContain('Partitioned');
90
+
91
+ const originCookie = setCookies.find((header) =>
92
+ header.startsWith(`${HUBSPOT_APP_ORIGIN_COOKIE_NAME}=`)
93
+ );
94
+ expect(originCookie).toBeDefined();
95
+ expect(originCookie).toContain('Max-Age=0');
96
+ expect(originCookie).toContain('SameSite=None');
97
+ expect(originCookie).toContain('Partitioned');
85
98
  });
86
99
 
87
100
  it('clears cookies and logs a warning when revoke returns non-OK HTTP', async () => {
@@ -3,6 +3,7 @@ import type { Context } from 'hono';
3
3
  import type { Logger } from '../../../shared/logger.ts';
4
4
  import {
5
5
  HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
6
+ HUBSPOT_APP_ORIGIN_COOKIE_NAME,
6
7
  HUBSPOT_APP_SID_COOKIE_NAME,
7
8
  HUBSPOT_REFRESH_COOKIE_PREFIX,
8
9
  } from '../../constants.ts';
@@ -109,6 +110,8 @@ export async function handleAuthLogout(
109
110
  name: HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
110
111
  value: '',
111
112
  path: '/',
113
+ sameSite: 'None',
114
+ partitioned: true,
112
115
  maxAge: 0,
113
116
  }),
114
117
  });
@@ -118,6 +121,19 @@ export async function handleAuthLogout(
118
121
  name: HUBSPOT_APP_SID_COOKIE_NAME,
119
122
  value: '',
120
123
  path: '/',
124
+ sameSite: 'None',
125
+ partitioned: true,
126
+ maxAge: 0,
127
+ }),
128
+ });
129
+ setResponseCookie({
130
+ c,
131
+ value: serializeCookie({
132
+ name: HUBSPOT_APP_ORIGIN_COOKIE_NAME,
133
+ value: '',
134
+ path: '/',
135
+ sameSite: 'None',
136
+ partitioned: true,
121
137
  maxAge: 0,
122
138
  }),
123
139
  });
@@ -130,6 +146,8 @@ export async function handleAuthLogout(
130
146
  name: cookieName,
131
147
  value: '',
132
148
  path: refreshCookiePath,
149
+ sameSite: 'None',
150
+ partitioned: true,
133
151
  maxAge: 0,
134
152
  }),
135
153
  });
@@ -175,11 +175,15 @@ describe('handleAuthRefresh', () => {
175
175
  h.startsWith(`${HUBSPOT_ACCESS_TOKEN_COOKIE_NAME}=`)
176
176
  );
177
177
  expect(accessCookie).toContain('new-access-token');
178
+ expect(accessCookie).toContain('SameSite=None');
179
+ expect(accessCookie).toContain('Partitioned');
178
180
 
179
181
  const refreshCookie = setCookies.find((h) =>
180
182
  h.startsWith(`${refreshCookieName}=`)
181
183
  );
182
184
  expect(refreshCookie).toContain('new-refresh-token');
185
+ expect(refreshCookie).toContain('SameSite=None');
186
+ expect(refreshCookie).toContain('Partitioned');
183
187
  });
184
188
 
185
189
  it('clears stale refresh cookies on success', async () => {
@@ -220,5 +224,7 @@ describe('handleAuthRefresh', () => {
220
224
  h.startsWith(`${staleCookieName}=`)
221
225
  );
222
226
  expect(staleCleared).toContain('Max-Age=0');
227
+ expect(staleCleared).toContain('SameSite=None');
228
+ expect(staleCleared).toContain('Partitioned');
223
229
  });
224
230
  });
@@ -135,6 +135,8 @@ export async function handleAuthRefresh(
135
135
  name: HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
136
136
  value: newAccessToken,
137
137
  path: '/',
138
+ sameSite: 'None',
139
+ partitioned: true,
138
140
  maxAge: expires_in,
139
141
  }),
140
142
  });
@@ -144,6 +146,8 @@ export async function handleAuthRefresh(
144
146
  name: refreshCookieName,
145
147
  value: newRefreshToken,
146
148
  path: refreshCookiePath,
149
+ sameSite: 'None',
150
+ partitioned: true,
147
151
  maxAge: REFRESH_COOKIE_MAX_AGE_SEC,
148
152
  }),
149
153
  });
@@ -161,6 +165,8 @@ export async function handleAuthRefresh(
161
165
  name: cookieName,
162
166
  value: '',
163
167
  path: refreshCookiePath,
168
+ sameSite: 'None',
169
+ partitioned: true,
164
170
  maxAge: 0,
165
171
  }),
166
172
  });
@@ -2,7 +2,8 @@ import type { Hono } from 'hono';
2
2
 
3
3
  import { noopLogger, type Logger } from '../../../shared/logger.ts';
4
4
  import type { AppKeys } from '../../types.ts';
5
- import { handleAuthCallback } from './auth-callback.ts';
5
+ import { corsMiddleware } from '../utils/cors-middleware.ts';
6
+ import { handleAuthComplete } from './auth-complete.ts';
6
7
  import { handleAuthInitSession } from './auth-init-session.ts';
7
8
  import { handleAuthLogout } from './auth-logout.ts';
8
9
  import { handleAuthRefresh } from './auth-refresh.ts';
@@ -81,6 +82,12 @@ export function registerHubSpotConnectRoutes(
81
82
  cimdClientMetadata,
82
83
  };
83
84
 
85
+ // Credentialed CORS for the cross-origin Lovable / Supabase shape.
86
+ // Echoes the request `Origin` (or the pinned `__Host-hs_app_origin`
87
+ // cookie value once init-session has run) and short-circuits OPTIONS
88
+ // preflights with a 204 before any route handler runs.
89
+ app.use('*', corsMiddleware());
90
+
84
91
  app.get('/client.json', (c) => handleCimdClientJson(c, oauthRouteOptions));
85
92
  if (hubspotConnectEnv.isCimdEnabled) {
86
93
  app.get('/jwks.json', (c) => handleCimdAppJwks(c, oauthRouteOptions));
@@ -89,7 +96,7 @@ export function registerHubSpotConnectRoutes(
89
96
  app.get('/auth/init-session', (c) =>
90
97
  handleAuthInitSession(c, oauthRouteOptions)
91
98
  );
92
- app.get('/auth/callback', (c) => handleAuthCallback(c, oauthRouteOptions));
99
+ app.post('/auth/complete', (c) => handleAuthComplete(c, oauthRouteOptions));
93
100
  app.post('/auth/refresh', (c) => handleAuthRefresh(c, oauthRouteOptions));
94
101
  app.post('/auth/logout', (c) => handleAuthLogout(c, oauthRouteOptions));
95
102
  }
@@ -1,3 +1,4 @@
1
+ import { HUBSPOT_FRONTEND_CALLBACK_PATH } from '../../../shared/constants.ts';
1
2
  import { serializeCookie } from '../utils/cookie-utils.ts';
2
3
 
3
4
  export function clearTempCookie(name: string): string {
@@ -5,11 +6,66 @@ export function clearTempCookie(name: string): string {
5
6
  name,
6
7
  value: '',
7
8
  path: '/',
8
- sameSite: 'Lax',
9
+ sameSite: 'None',
9
10
  maxAge: 0,
11
+ partitioned: true,
10
12
  });
11
13
  }
12
14
 
15
+ /**
16
+ * Parses the request `Origin` header into the canonical origin
17
+ * string (`URL.origin`) or returns `null` when the header is
18
+ * missing, malformed, or carries a scheme/host the SDK does not
19
+ * accept.
20
+ *
21
+ * Accepted shapes:
22
+ *
23
+ * - `https://<host>` for production deployments.
24
+ * - `http://localhost[:<port>]` and `http://127.0.0.1[:<port>]`
25
+ * for local development; browsers exempt these from the `Secure`
26
+ * cookie restriction.
27
+ *
28
+ * Rejects values with a path/query/hash component (the request
29
+ * `Origin` header is by spec a bare origin, so anything else
30
+ * indicates a malformed or hostile request).
31
+ */
32
+ export function parseAppOriginHeader(
33
+ originHeader: string | undefined
34
+ ): string | null {
35
+ if (!originHeader) return null;
36
+ let parsed: URL;
37
+ try {
38
+ parsed = new URL(originHeader);
39
+ } catch {
40
+ return null;
41
+ }
42
+ if (parsed.pathname !== '/' && parsed.pathname !== '') return null;
43
+ if (parsed.search !== '' || parsed.hash !== '') return null;
44
+ if (parsed.protocol === 'https:') return parsed.origin;
45
+ if (
46
+ parsed.protocol === 'http:' &&
47
+ (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')
48
+ ) {
49
+ return parsed.origin;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * OAuth `redirect_uri` for the cross-origin app shape: the OAuth
56
+ * callback lands on the **frontend** origin (not the SDK's edge
57
+ * function host), so all cookies set by `init-session` and read by
58
+ * `auth/complete` live in the same `(frontend, edge)` CHIPS
59
+ * partition.
60
+ *
61
+ * Used by `auth/init-session` (when building `authorization_url`)
62
+ * and `auth/complete` (which must rebuild the same value to satisfy
63
+ * the OAuth token endpoint's `redirect_uri` check).
64
+ */
65
+ export function buildFrontendOAuthRedirectUri(appOrigin: string): string {
66
+ return `${appOrigin}${HUBSPOT_FRONTEND_CALLBACK_PATH}`;
67
+ }
68
+
13
69
  export function isSafeReturnPath(rawPath: string): boolean {
14
70
  if (!rawPath.startsWith('/')) return false;
15
71
  if (rawPath.includes('\0')) return false;
@@ -1,24 +1,30 @@
1
1
  import type { HubSpotClient } from '../api-client-core/types.ts';
2
2
  import type { HubSpotProxy } from '../types.ts';
3
3
 
4
- export interface AppConnectHonoBindings {
4
+ export interface AppConnectRequestContext {
5
+ /**
6
+ * HubSpot API proxy.
7
+ */
8
+ proxy: HubSpotProxy;
5
9
  /**
6
- * Authenticated proxy that issues DPoP-bound calls to HubSpot's
7
- * API on behalf of the browser session that made the inbound
8
- * request. `authenticated: false` when the session cookies are
9
- * absent or invalid.
10
+ * HubSpot API client.
10
11
  */
11
- hubSpotProxy: HubSpotProxy;
12
+ client: HubSpotClient;
13
+
12
14
  /**
13
- * Authenticated HubSpot API client.
15
+ * Whether the browser session is authenticated.
14
16
  */
15
- hubSpotClient: HubSpotClient;
17
+ authenticated: boolean;
18
+ }
19
+
20
+ export interface AppConnectHonoBindings {
21
+ hubSpot: AppConnectRequestContext;
16
22
  }
17
23
 
18
24
  /**
19
25
  * Hono environment shape used by handlers running inside a hubspot-
20
26
  * connect request handler. Exposes the per-request
21
- * {@link HubSpotProxy} as `c.env.hubSpotProxy`.
27
+ * {@link AppConnectRequestContext} as `c.env.hubSpot`.
22
28
  */
23
29
  export interface AppConnectHonoEnv {
24
30
  Bindings: AppConnectHonoBindings;
@@ -20,14 +20,37 @@ export interface SerializeCookieOptions {
20
20
  value: string;
21
21
  /** `__Host-` prefix requires `Path=/` and is recommended for session cookies. */
22
22
  path: string;
23
- /** Defaults to `Strict`. Use `Lax` for short-lived OAuth temp cookies. */
24
- sameSite?: 'Strict' | 'Lax';
23
+ /**
24
+ * Defaults to `Strict`.
25
+ *
26
+ * - `Strict`: only sent on same-site requests. Default for self-hosted
27
+ * same-origin deployments.
28
+ * - `Lax`: also sent on top-level cross-site GET navigations. Use for
29
+ * short-lived OAuth temp cookies that need to survive a redirect.
30
+ * - `None`: sent on all cross-site requests; **requires `Secure=true`
31
+ * and is typically combined with `Partitioned=true`** for the
32
+ * cross-origin Lovable / Supabase deployment shape.
33
+ */
34
+ sameSite?: 'Strict' | 'Lax' | 'None';
25
35
  /** Lifetime in seconds. `0` deletes the cookie. */
26
36
  maxAge: number;
27
37
  /** Defaults to `true`; only set `false` for tests or non-HTTPS dev hosts. */
28
38
  secure?: boolean;
29
39
  /** Defaults to `true`. */
30
40
  httpOnly?: boolean;
41
+ /**
42
+ * When `true`, appends the `Partitioned` attribute (CHIPS — Cookies
43
+ * Having Independent Partitioned State). The browser then keys the
44
+ * cookie by `(top-level site, cookie host)` instead of by cookie
45
+ * host alone, which is required for the cross-origin SDK shape
46
+ * where the React app and the SDK's edge functions live on
47
+ * different sites and third-party cookies are blocked.
48
+ *
49
+ * Defaults to `false`. Browsers ignore `Partitioned` on cookies
50
+ * without `Secure=true` and reject it on cookies without
51
+ * `SameSite=None`.
52
+ */
53
+ partitioned?: boolean;
31
54
  }
32
55
 
33
56
  /**
@@ -44,6 +67,7 @@ export function serializeCookie(options: SerializeCookieOptions): string {
44
67
  maxAge,
45
68
  secure = true,
46
69
  httpOnly = true,
70
+ partitioned = false,
47
71
  } = options;
48
72
  const parts: string[] = [`${name}=${value}`];
49
73
  if (httpOnly) parts.push('HttpOnly');
@@ -51,5 +75,6 @@ export function serializeCookie(options: SerializeCookieOptions): string {
51
75
  parts.push(`SameSite=${sameSite}`);
52
76
  parts.push(`Path=${path}`);
53
77
  parts.push(`Max-Age=${maxAge}`);
78
+ if (partitioned) parts.push('Partitioned');
54
79
  return parts.join('; ');
55
80
  }