@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.
- package/.turbo/turbo-format$colon$check.log +1 -1
- package/.turbo/turbo-test.log +60 -58
- package/.turbo/turbo-tsdown.log +55 -52
- package/build/tsconfig.browser.tsbuildinfo +1 -1
- package/build/tsconfig.server.tsbuildinfo +1 -1
- package/dist/browser/{HubSpotAppConnect-BW45gyDs.js → HubSpotAppConnect-COQgPrFn.js} +5 -3
- package/dist/browser/HubSpotAppConnect-COQgPrFn.js.map +1 -0
- package/dist/browser/{create-vctOhpX9.js → create-crdncXsh.js} +53 -24
- package/dist/browser/create-crdncXsh.js.map +1 -0
- package/dist/browser/index.js +1 -1
- package/dist/browser/react/lovable.js +2 -2
- package/dist/browser/react.js +1 -1
- package/dist/server/api-client-core/plugins/fetch-transport.js +5 -1
- package/dist/server/api-client-core/plugins/fetch-transport.js.map +1 -1
- package/dist/server/constants.js +33 -6
- package/dist/server/constants.js.map +1 -1
- package/dist/server/hono/hono-request-handler.js +18 -13
- package/dist/server/hono/hono-request-handler.js.map +1 -1
- package/dist/server/hono/hubspot-connect-routes/auth-complete.js +154 -0
- package/dist/server/hono/hubspot-connect-routes/auth-complete.js.map +1 -0
- package/dist/server/hono/hubspot-connect-routes/auth-init-session.js +22 -11
- package/dist/server/hono/hubspot-connect-routes/auth-init-session.js.map +1 -1
- package/dist/server/hono/hubspot-connect-routes/auth-logout.js +18 -1
- package/dist/server/hono/hubspot-connect-routes/auth-logout.js.map +1 -1
- package/dist/server/hono/hubspot-connect-routes/auth-refresh.js +6 -0
- package/dist/server/hono/hubspot-connect-routes/auth-refresh.js.map +1 -1
- package/dist/server/hono/hubspot-connect-routes/hubspot-connect-routes.js +4 -2
- package/dist/server/hono/hubspot-connect-routes/hubspot-connect-routes.js.map +1 -1
- package/dist/server/hono/hubspot-connect-routes/utils.js +50 -3
- package/dist/server/hono/hubspot-connect-routes/utils.js.map +1 -1
- package/dist/server/hono/types.d.ts +13 -9
- package/dist/server/hono/utils/cookie-utils.js +2 -1
- package/dist/server/hono/utils/cookie-utils.js.map +1 -1
- package/dist/server/hono/utils/cors-middleware.js +85 -0
- package/dist/server/hono/utils/cors-middleware.js.map +1 -0
- package/dist/server/sanitize-request.js +24 -10
- package/dist/server/sanitize-request.js.map +1 -1
- package/dist/server/shared/constants.js +22 -9
- package/dist/server/shared/constants.js.map +1 -1
- package/package.json +3 -3
- package/src/browser/app-connect-controller/init.test.ts +167 -0
- package/src/browser/app-connect-controller/init.ts +70 -19
- package/src/browser/react/components/AppConnectHeader/AppConnectHeader.tsx +3 -5
- package/src/browser/react/components/ConnectButton/ConnectButton.tsx +2 -1
- package/src/server/api-client-core/plugins/fetch-transport.ts +5 -1
- package/src/server/constants.ts +29 -4
- package/src/server/hono/hono-request-handler.ts +42 -15
- package/src/server/hono/hubspot-connect-routes/auth-complete.test.ts +285 -0
- package/src/server/hono/hubspot-connect-routes/{auth-callback.ts → auth-complete.ts} +73 -30
- package/src/server/hono/hubspot-connect-routes/auth-init-session.test.ts +114 -30
- package/src/server/hono/hubspot-connect-routes/auth-init-session.ts +33 -10
- package/src/server/hono/hubspot-connect-routes/auth-logout.test.ts +13 -0
- package/src/server/hono/hubspot-connect-routes/auth-logout.ts +18 -0
- package/src/server/hono/hubspot-connect-routes/auth-refresh.test.ts +6 -0
- package/src/server/hono/hubspot-connect-routes/auth-refresh.ts +6 -0
- package/src/server/hono/hubspot-connect-routes/hubspot-connect-routes.ts +9 -2
- package/src/server/hono/hubspot-connect-routes/utils.ts +57 -1
- package/src/server/hono/types.ts +15 -9
- package/src/server/hono/utils/cookie-utils.ts +27 -2
- package/src/server/hono/utils/cors-middleware.test.ts +79 -0
- package/src/server/hono/utils/cors-middleware.ts +95 -0
- package/src/server/sanitize-request.ts +25 -11
- package/src/server/types.ts +2 -2
- package/src/shared/constants.ts +31 -3
- package/src/shared/wire-types.ts +19 -0
- package/dist/browser/HubSpotAppConnect-BW45gyDs.js.map +0 -1
- package/dist/browser/create-vctOhpX9.js.map +0 -1
- package/dist/server/hono/hubspot-connect-routes/auth-callback.js +0 -125
- package/dist/server/hono/hubspot-connect-routes/auth-callback.js.map +0 -1
- 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.
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
71
|
-
|
|
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('
|
|
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.
|
|
90
|
-
'
|
|
91
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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.
|
|
122
|
-
|
|
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
|
-
|
|
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 =
|
|
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: '
|
|
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: '
|
|
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 {
|
|
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.
|
|
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: '
|
|
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;
|
package/src/server/hono/types.ts
CHANGED
|
@@ -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
|
|
4
|
+
export interface AppConnectRequestContext {
|
|
5
|
+
/**
|
|
6
|
+
* HubSpot API proxy.
|
|
7
|
+
*/
|
|
8
|
+
proxy: HubSpotProxy;
|
|
5
9
|
/**
|
|
6
|
-
*
|
|
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
|
-
|
|
12
|
+
client: HubSpotClient;
|
|
13
|
+
|
|
12
14
|
/**
|
|
13
|
-
*
|
|
15
|
+
* Whether the browser session is authenticated.
|
|
14
16
|
*/
|
|
15
|
-
|
|
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
|
|
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
|
-
/**
|
|
24
|
-
|
|
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
|
}
|