@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
|
@@ -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
|
-
|
|
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
|
|
102
|
+
const proxy = createHubSpotProxy({
|
|
88
103
|
userCredentials,
|
|
89
104
|
appKeys,
|
|
90
105
|
logger,
|
|
91
106
|
});
|
|
92
107
|
|
|
93
|
-
const
|
|
94
|
-
plugins: [
|
|
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
|
-
|
|
101
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
44
|
-
const state = c.req.query(
|
|
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.
|
|
78
|
+
return c.json({ error: 'Missing code or state' }, 400);
|
|
48
79
|
}
|
|
49
80
|
|
|
50
81
|
if (hubspotConnectEnv.isAppPrivateKeyRequired && !appKeys) {
|
|
51
|
-
return c.
|
|
52
|
-
|
|
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.
|
|
97
|
+
return c.json({ error: 'State mismatch' }, 403);
|
|
63
98
|
}
|
|
64
99
|
if (!codeVerifier) {
|
|
65
|
-
return c.
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
203
|
+
return c.json({ error: 'Token response missing refresh_token' }, 502);
|
|
164
204
|
}
|
|
165
205
|
if (!isPositiveFiniteNumber(expires_in)) {
|
|
166
|
-
return c.
|
|
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
|
-
|
|
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
|
}
|