@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
|
@@ -14,13 +14,14 @@ function setResponseCookie(options) {
|
|
|
14
14
|
* cookies share the same policy.
|
|
15
15
|
*/
|
|
16
16
|
function serializeCookie(options) {
|
|
17
|
-
const { name, value, path, sameSite = "Strict", maxAge, secure = true, httpOnly = true } = options;
|
|
17
|
+
const { name, value, path, sameSite = "Strict", maxAge, secure = true, httpOnly = true, partitioned = false } = options;
|
|
18
18
|
const parts = [`${name}=${value}`];
|
|
19
19
|
if (httpOnly) parts.push("HttpOnly");
|
|
20
20
|
if (secure) parts.push("Secure");
|
|
21
21
|
parts.push(`SameSite=${sameSite}`);
|
|
22
22
|
parts.push(`Path=${path}`);
|
|
23
23
|
parts.push(`Max-Age=${maxAge}`);
|
|
24
|
+
if (partitioned) parts.push("Partitioned");
|
|
24
25
|
return parts.join("; ");
|
|
25
26
|
}
|
|
26
27
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cookie-utils.js","names":[],"sources":["../../../../src/server/hono/utils/cookie-utils.ts"],"sourcesContent":["import type { Context } from 'hono';\n\nexport interface SetResponseCookieOptions {\n c: Context;\n value: string;\n}\n\n/**\n * Appends a `Set-Cookie` header to the response. Hono replaces single\n * headers by default, so this uses `{ append: true }` to emit multiple\n * cookies on the same response.\n */\nexport function setResponseCookie(options: SetResponseCookieOptions): void {\n const { c, value } = options;\n c.header('Set-Cookie', value, { append: true });\n}\n\nexport interface SerializeCookieOptions {\n name: string;\n value: string;\n /** `__Host-` prefix requires `Path=/` and is recommended for session cookies. */\n path: string;\n
|
|
1
|
+
{"version":3,"file":"cookie-utils.js","names":[],"sources":["../../../../src/server/hono/utils/cookie-utils.ts"],"sourcesContent":["import type { Context } from 'hono';\n\nexport interface SetResponseCookieOptions {\n c: Context;\n value: string;\n}\n\n/**\n * Appends a `Set-Cookie` header to the response. Hono replaces single\n * headers by default, so this uses `{ append: true }` to emit multiple\n * cookies on the same response.\n */\nexport function setResponseCookie(options: SetResponseCookieOptions): void {\n const { c, value } = options;\n c.header('Set-Cookie', value, { append: true });\n}\n\nexport interface SerializeCookieOptions {\n name: string;\n value: string;\n /** `__Host-` prefix requires `Path=/` and is recommended for session cookies. */\n path: string;\n /**\n * Defaults to `Strict`.\n *\n * - `Strict`: only sent on same-site requests. Default for self-hosted\n * same-origin deployments.\n * - `Lax`: also sent on top-level cross-site GET navigations. Use for\n * short-lived OAuth temp cookies that need to survive a redirect.\n * - `None`: sent on all cross-site requests; **requires `Secure=true`\n * and is typically combined with `Partitioned=true`** for the\n * cross-origin Lovable / Supabase deployment shape.\n */\n sameSite?: 'Strict' | 'Lax' | 'None';\n /** Lifetime in seconds. `0` deletes the cookie. */\n maxAge: number;\n /** Defaults to `true`; only set `false` for tests or non-HTTPS dev hosts. */\n secure?: boolean;\n /** Defaults to `true`. */\n httpOnly?: boolean;\n /**\n * When `true`, appends the `Partitioned` attribute (CHIPS — Cookies\n * Having Independent Partitioned State). The browser then keys the\n * cookie by `(top-level site, cookie host)` instead of by cookie\n * host alone, which is required for the cross-origin SDK shape\n * where the React app and the SDK's edge functions live on\n * different sites and third-party cookies are blocked.\n *\n * Defaults to `false`. Browsers ignore `Partitioned` on cookies\n * without `Secure=true` and reject it on cookies without\n * `SameSite=None`.\n */\n partitioned?: boolean;\n}\n\n/**\n * Builds a `Set-Cookie` header value with HubSpot's default attributes\n * (HttpOnly, Secure, SameSite). Centralizes the serialization so all\n * cookies share the same policy.\n */\nexport function serializeCookie(options: SerializeCookieOptions): string {\n const {\n name,\n value,\n path,\n sameSite = 'Strict',\n maxAge,\n secure = true,\n httpOnly = true,\n partitioned = false,\n } = options;\n const parts: string[] = [`${name}=${value}`];\n if (httpOnly) parts.push('HttpOnly');\n if (secure) parts.push('Secure');\n parts.push(`SameSite=${sameSite}`);\n parts.push(`Path=${path}`);\n parts.push(`Max-Age=${maxAge}`);\n if (partitioned) parts.push('Partitioned');\n return parts.join('; ');\n}\n"],"mappings":";;;;;;AAYA,SAAgB,kBAAkB,SAAyC;CACzE,MAAM,EAAE,GAAG,UAAU;CACrB,EAAE,OAAO,cAAc,OAAO,EAAE,QAAQ,MAAM,CAAC;;;;;;;AA8CjD,SAAgB,gBAAgB,SAAyC;CACvE,MAAM,EACJ,MACA,OACA,MACA,WAAW,UACX,QACA,SAAS,MACT,WAAW,MACX,cAAc,UACZ;CACJ,MAAM,QAAkB,CAAC,GAAG,KAAK,GAAG,QAAQ;CAC5C,IAAI,UAAU,MAAM,KAAK,WAAW;CACpC,IAAI,QAAQ,MAAM,KAAK,SAAS;CAChC,MAAM,KAAK,YAAY,WAAW;CAClC,MAAM,KAAK,QAAQ,OAAO;CAC1B,MAAM,KAAK,WAAW,SAAS;CAC/B,IAAI,aAAa,MAAM,KAAK,cAAc;CAC1C,OAAO,MAAM,KAAK,KAAK"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { HUBSPOT_APP_ORIGIN_COOKIE_NAME } from "../../constants.js";
|
|
2
|
+
import { parseCookies } from "../../utils/cookie-utils.js";
|
|
3
|
+
//#region src/server/hono/utils/cors-middleware.ts
|
|
4
|
+
/**
|
|
5
|
+
* Comma-separated list of request headers the SDK accepts on
|
|
6
|
+
* cross-site fetches. Mirrors the Supabase Edge Functions defaults
|
|
7
|
+
* the Lovable AI agent emits today, plus `content-type` for the
|
|
8
|
+
* `auth/complete` POST body and `accept` so JSON content negotiation
|
|
9
|
+
* works.
|
|
10
|
+
*/
|
|
11
|
+
const ALLOWED_HEADERS = [
|
|
12
|
+
"authorization",
|
|
13
|
+
"x-client-info",
|
|
14
|
+
"apikey",
|
|
15
|
+
"content-type",
|
|
16
|
+
"accept",
|
|
17
|
+
"x-supabase-client-platform",
|
|
18
|
+
"x-supabase-client-platform-version",
|
|
19
|
+
"x-supabase-client-runtime",
|
|
20
|
+
"x-supabase-client-runtime-version"
|
|
21
|
+
].join(", ");
|
|
22
|
+
const ALLOWED_METHODS = "GET, POST, OPTIONS";
|
|
23
|
+
const PREFLIGHT_MAX_AGE_SECONDS = "600";
|
|
24
|
+
/**
|
|
25
|
+
* Reads the persisted app-origin cookie from the request, falling
|
|
26
|
+
* back to the literal `Origin` request header. The cookie is the
|
|
27
|
+
* authoritative pin once `auth/init-session` has run; on the very
|
|
28
|
+
* first init-session call (no cookie yet) we just echo whatever
|
|
29
|
+
* `Origin` the caller sent — the actual access decision is enforced
|
|
30
|
+
* by cookie-based authentication on every other route, not by CORS.
|
|
31
|
+
*/
|
|
32
|
+
function resolveAllowedOrigin(c) {
|
|
33
|
+
const pinned = parseCookies(c.req.header("Cookie"))[HUBSPOT_APP_ORIGIN_COOKIE_NAME];
|
|
34
|
+
if (pinned) return pinned;
|
|
35
|
+
return c.req.header("Origin") ?? null;
|
|
36
|
+
}
|
|
37
|
+
function setSharedCorsHeaders(c, allowOrigin) {
|
|
38
|
+
c.res.headers.set("Access-Control-Allow-Origin", allowOrigin);
|
|
39
|
+
c.res.headers.set("Access-Control-Allow-Credentials", "true");
|
|
40
|
+
c.res.headers.set("Vary", "Origin, Cookie");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Hono middleware that emits credentialed CORS response headers for
|
|
44
|
+
* the cross-origin Lovable / Supabase deployment shape.
|
|
45
|
+
*
|
|
46
|
+
* - On `OPTIONS` preflight: short-circuits with a 204 carrying
|
|
47
|
+
* `Access-Control-Allow-*` headers. The browser will then send the
|
|
48
|
+
* real request with cookies attached.
|
|
49
|
+
* - On every other method: echoes the pinned `__Host-hs_app_origin`
|
|
50
|
+
* cookie value (or, before init-session has run, the request
|
|
51
|
+
* `Origin` header) as `Access-Control-Allow-Origin`, with
|
|
52
|
+
* `Access-Control-Allow-Credentials: true`. The wildcard `*` is
|
|
53
|
+
* forbidden by browsers when credentials are included, so the
|
|
54
|
+
* middleware always echoes a concrete origin.
|
|
55
|
+
*
|
|
56
|
+
* Skips header emission entirely when the request has no `Origin`
|
|
57
|
+
* (server-to-server calls, curl, etc.) so non-browser callers are
|
|
58
|
+
* left untouched.
|
|
59
|
+
*/
|
|
60
|
+
function corsMiddleware() {
|
|
61
|
+
return async (c, next) => {
|
|
62
|
+
const allowOrigin = resolveAllowedOrigin(c);
|
|
63
|
+
if (c.req.method === "OPTIONS") {
|
|
64
|
+
const headers = new Headers();
|
|
65
|
+
if (allowOrigin) {
|
|
66
|
+
headers.set("Access-Control-Allow-Origin", allowOrigin);
|
|
67
|
+
headers.set("Access-Control-Allow-Credentials", "true");
|
|
68
|
+
headers.set("Vary", "Origin, Cookie");
|
|
69
|
+
}
|
|
70
|
+
headers.set("Access-Control-Allow-Methods", ALLOWED_METHODS);
|
|
71
|
+
headers.set("Access-Control-Allow-Headers", ALLOWED_HEADERS);
|
|
72
|
+
headers.set("Access-Control-Max-Age", PREFLIGHT_MAX_AGE_SECONDS);
|
|
73
|
+
return new Response(null, {
|
|
74
|
+
status: 204,
|
|
75
|
+
headers
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
await next();
|
|
79
|
+
if (allowOrigin) setSharedCorsHeaders(c, allowOrigin);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
export { corsMiddleware };
|
|
84
|
+
|
|
85
|
+
//# sourceMappingURL=cors-middleware.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cors-middleware.js","names":[],"sources":["../../../../src/server/hono/utils/cors-middleware.ts"],"sourcesContent":["import type { Context, MiddlewareHandler } from 'hono';\n\nimport { HUBSPOT_APP_ORIGIN_COOKIE_NAME } from '../../constants.ts';\nimport { parseCookies } from '../../utils/cookie-utils.ts';\n\n/**\n * Comma-separated list of request headers the SDK accepts on\n * cross-site fetches. Mirrors the Supabase Edge Functions defaults\n * the Lovable AI agent emits today, plus `content-type` for the\n * `auth/complete` POST body and `accept` so JSON content negotiation\n * works.\n */\nconst ALLOWED_HEADERS = [\n 'authorization',\n 'x-client-info',\n 'apikey',\n 'content-type',\n 'accept',\n 'x-supabase-client-platform',\n 'x-supabase-client-platform-version',\n 'x-supabase-client-runtime',\n 'x-supabase-client-runtime-version',\n].join(', ');\n\nconst ALLOWED_METHODS = 'GET, POST, OPTIONS';\n\nconst PREFLIGHT_MAX_AGE_SECONDS = '600';\n\n/**\n * Reads the persisted app-origin cookie from the request, falling\n * back to the literal `Origin` request header. The cookie is the\n * authoritative pin once `auth/init-session` has run; on the very\n * first init-session call (no cookie yet) we just echo whatever\n * `Origin` the caller sent — the actual access decision is enforced\n * by cookie-based authentication on every other route, not by CORS.\n */\nfunction resolveAllowedOrigin(c: Context): string | null {\n const cookies = parseCookies(c.req.header('Cookie'));\n const pinned = cookies[HUBSPOT_APP_ORIGIN_COOKIE_NAME];\n if (pinned) return pinned;\n return c.req.header('Origin') ?? null;\n}\n\nfunction setSharedCorsHeaders(c: Context, allowOrigin: string): void {\n c.res.headers.set('Access-Control-Allow-Origin', allowOrigin);\n c.res.headers.set('Access-Control-Allow-Credentials', 'true');\n // `Origin` so caches differentiate per-caller responses; `Cookie`\n // because the allowed origin is derived from the persisted\n // `__Host-hs_app_origin` cookie.\n c.res.headers.set('Vary', 'Origin, Cookie');\n}\n\n/**\n * Hono middleware that emits credentialed CORS response headers for\n * the cross-origin Lovable / Supabase deployment shape.\n *\n * - On `OPTIONS` preflight: short-circuits with a 204 carrying\n * `Access-Control-Allow-*` headers. The browser will then send the\n * real request with cookies attached.\n * - On every other method: echoes the pinned `__Host-hs_app_origin`\n * cookie value (or, before init-session has run, the request\n * `Origin` header) as `Access-Control-Allow-Origin`, with\n * `Access-Control-Allow-Credentials: true`. The wildcard `*` is\n * forbidden by browsers when credentials are included, so the\n * middleware always echoes a concrete origin.\n *\n * Skips header emission entirely when the request has no `Origin`\n * (server-to-server calls, curl, etc.) so non-browser callers are\n * left untouched.\n */\nexport function corsMiddleware(): MiddlewareHandler {\n return async (c, next) => {\n const allowOrigin = resolveAllowedOrigin(c);\n\n if (c.req.method === 'OPTIONS') {\n const headers = new Headers();\n if (allowOrigin) {\n headers.set('Access-Control-Allow-Origin', allowOrigin);\n headers.set('Access-Control-Allow-Credentials', 'true');\n headers.set('Vary', 'Origin, Cookie');\n }\n headers.set('Access-Control-Allow-Methods', ALLOWED_METHODS);\n headers.set('Access-Control-Allow-Headers', ALLOWED_HEADERS);\n headers.set('Access-Control-Max-Age', PREFLIGHT_MAX_AGE_SECONDS);\n return new Response(null, { status: 204, headers });\n }\n\n await next();\n\n if (allowOrigin) {\n setSharedCorsHeaders(c, allowOrigin);\n }\n return;\n };\n}\n"],"mappings":";;;;;;;;;;AAYA,MAAM,kBAAkB;CACtB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK;AAEZ,MAAM,kBAAkB;AAExB,MAAM,4BAA4B;;;;;;;;;AAUlC,SAAS,qBAAqB,GAA2B;CAEvD,MAAM,SADU,aAAa,EAAE,IAAI,OAAO,SAAS,CAC7B,CAAC;CACvB,IAAI,QAAQ,OAAO;CACnB,OAAO,EAAE,IAAI,OAAO,SAAS,IAAI;;AAGnC,SAAS,qBAAqB,GAAY,aAA2B;CACnE,EAAE,IAAI,QAAQ,IAAI,+BAA+B,YAAY;CAC7D,EAAE,IAAI,QAAQ,IAAI,oCAAoC,OAAO;CAI7D,EAAE,IAAI,QAAQ,IAAI,QAAQ,iBAAiB;;;;;;;;;;;;;;;;;;;;AAqB7C,SAAgB,iBAAoC;CAClD,OAAO,OAAO,GAAG,SAAS;EACxB,MAAM,cAAc,qBAAqB,EAAE;EAE3C,IAAI,EAAE,IAAI,WAAW,WAAW;GAC9B,MAAM,UAAU,IAAI,SAAS;GAC7B,IAAI,aAAa;IACf,QAAQ,IAAI,+BAA+B,YAAY;IACvD,QAAQ,IAAI,oCAAoC,OAAO;IACvD,QAAQ,IAAI,QAAQ,iBAAiB;;GAEvC,QAAQ,IAAI,gCAAgC,gBAAgB;GAC5D,QAAQ,IAAI,gCAAgC,gBAAgB;GAC5D,QAAQ,IAAI,0BAA0B,0BAA0B;GAChE,OAAO,IAAI,SAAS,MAAM;IAAE,QAAQ;IAAK;IAAS,CAAC;;EAGrD,MAAM,MAAM;EAEZ,IAAI,aACF,qBAAqB,GAAG,YAAY"}
|
|
@@ -7,22 +7,36 @@ function serializeCookies(cookies) {
|
|
|
7
7
|
return parts.join("; ");
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* protected cookie
|
|
12
|
-
*
|
|
13
|
-
* remain, the header is dropped entirely.
|
|
10
|
+
* Mutates `headers` in place: parses the `Cookie` header, drops every
|
|
11
|
+
* protected cookie (see {@link isProtectedCookieName}), and rewrites
|
|
12
|
+
* the header. Deletes the header entirely when nothing survives.
|
|
14
13
|
*
|
|
15
|
-
* Used by
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Used by the SDK's auth middleware after it has read the access
|
|
15
|
+
* token / session ID, so user route handlers never see — and
|
|
16
|
+
* therefore cannot leak — those cookies, while CORS / auth
|
|
17
|
+
* middleware that ran first still got the raw values.
|
|
18
18
|
*/
|
|
19
|
-
function
|
|
20
|
-
const cookies = parseCookies(
|
|
19
|
+
function stripProtectedCookies(headers) {
|
|
20
|
+
const cookies = parseCookies(headers.get("Cookie"));
|
|
21
21
|
const surviving = /* @__PURE__ */ new Map();
|
|
22
22
|
for (const [name, value] of Object.entries(cookies)) if (!isProtectedCookieName(name)) surviving.set(name, value);
|
|
23
|
-
const headers = new Headers(original.headers);
|
|
24
23
|
if (surviving.size === 0) headers.delete("cookie");
|
|
25
24
|
else headers.set("cookie", serializeCookies(surviving));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Returns a clone of `original` whose `Cookie` header has every
|
|
28
|
+
* protected cookie removed (see {@link isProtectedCookieName}). When
|
|
29
|
+
* no other cookies remain, the header is dropped entirely.
|
|
30
|
+
*
|
|
31
|
+
* Standalone helper retained for callers that want a new Request
|
|
32
|
+
* (e.g. tests). Inside the SDK's per-request handler, the auth
|
|
33
|
+
* middleware uses {@link stripProtectedCookies} directly on
|
|
34
|
+
* `c.req.raw.headers` so the auth check itself can still read the
|
|
35
|
+
* protected cookies before they are stripped.
|
|
36
|
+
*/
|
|
37
|
+
function sanitizeRequest(original) {
|
|
38
|
+
const headers = new Headers(original.headers);
|
|
39
|
+
stripProtectedCookies(headers);
|
|
26
40
|
const init = {
|
|
27
41
|
method: original.method,
|
|
28
42
|
headers,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sanitize-request.js","names":[],"sources":["../../src/server/sanitize-request.ts"],"sourcesContent":["import { isProtectedCookieName } from './constants.ts';\nimport { parseCookies } from './utils/cookie-utils.ts';\n\nfunction serializeCookies(cookies: Map<string, string>): string {\n const parts: string[] = [];\n for (const [name, value] of cookies) {\n parts.push(`${name}=${value}`);\n }\n return parts.join('; ');\n}\n\n/**\n *
|
|
1
|
+
{"version":3,"file":"sanitize-request.js","names":[],"sources":["../../src/server/sanitize-request.ts"],"sourcesContent":["import { isProtectedCookieName } from './constants.ts';\nimport { parseCookies } from './utils/cookie-utils.ts';\n\nfunction serializeCookies(cookies: Map<string, string>): string {\n const parts: string[] = [];\n for (const [name, value] of cookies) {\n parts.push(`${name}=${value}`);\n }\n return parts.join('; ');\n}\n\n/**\n * Mutates `headers` in place: parses the `Cookie` header, drops every\n * protected cookie (see {@link isProtectedCookieName}), and rewrites\n * the header. Deletes the header entirely when nothing survives.\n *\n * Used by the SDK's auth middleware after it has read the access\n * token / session ID, so user route handlers never see — and\n * therefore cannot leak — those cookies, while CORS / auth\n * middleware that ran first still got the raw values.\n */\nexport function stripProtectedCookies(headers: Headers): void {\n const cookies = parseCookies(headers.get('Cookie'));\n const surviving = new Map<string, string>();\n for (const [name, value] of Object.entries(cookies)) {\n if (!isProtectedCookieName(name)) {\n surviving.set(name, value);\n }\n }\n\n if (surviving.size === 0) {\n headers.delete('cookie');\n } else {\n headers.set('cookie', serializeCookies(surviving));\n }\n}\n\n/**\n * Returns a clone of `original` whose `Cookie` header has every\n * protected cookie removed (see {@link isProtectedCookieName}). When\n * no other cookies remain, the header is dropped entirely.\n *\n * Standalone helper retained for callers that want a new Request\n * (e.g. tests). Inside the SDK's per-request handler, the auth\n * middleware uses {@link stripProtectedCookies} directly on\n * `c.req.raw.headers` so the auth check itself can still read the\n * protected cookies before they are stripped.\n */\nexport function sanitizeRequest(original: Request): Request {\n const headers = new Headers(original.headers);\n stripProtectedCookies(headers);\n\n const init: RequestInit = {\n method: original.method,\n headers,\n redirect: original.redirect,\n signal: original.signal,\n };\n\n if (original.body !== null) {\n init.body = original.body;\n (init as RequestInit & { duplex: 'half' }).duplex = 'half';\n }\n\n return new Request(original.url, init);\n}\n"],"mappings":";;;AAGA,SAAS,iBAAiB,SAAsC;CAC9D,MAAM,QAAkB,EAAE;CAC1B,KAAK,MAAM,CAAC,MAAM,UAAU,SAC1B,MAAM,KAAK,GAAG,KAAK,GAAG,QAAQ;CAEhC,OAAO,MAAM,KAAK,KAAK;;;;;;;;;;;;AAazB,SAAgB,sBAAsB,SAAwB;CAC5D,MAAM,UAAU,aAAa,QAAQ,IAAI,SAAS,CAAC;CACnD,MAAM,4BAAY,IAAI,KAAqB;CAC3C,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAQ,EACjD,IAAI,CAAC,sBAAsB,KAAK,EAC9B,UAAU,IAAI,MAAM,MAAM;CAI9B,IAAI,UAAU,SAAS,GACrB,QAAQ,OAAO,SAAS;MAExB,QAAQ,IAAI,UAAU,iBAAiB,UAAU,CAAC;;;;;;;;;;;;;AAetD,SAAgB,gBAAgB,UAA4B;CAC1D,MAAM,UAAU,IAAI,QAAQ,SAAS,QAAQ;CAC7C,sBAAsB,QAAQ;CAE9B,MAAM,OAAoB;EACxB,QAAQ,SAAS;EACjB;EACA,UAAU,SAAS;EACnB,QAAQ,SAAS;EAClB;CAED,IAAI,SAAS,SAAS,MAAM;EAC1B,KAAK,OAAO,SAAS;EACrB,KAA2C,SAAS;;CAGtD,OAAO,IAAI,QAAQ,SAAS,KAAK,KAAK"}
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
//#region src/shared/constants.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Path the browser visits after HubSpot's authorize endpoint
|
|
4
|
+
* redirects back to the app. Mounted on the **frontend** origin (not
|
|
5
|
+
* the SDK's edge function host) so all OAuth-related cookies live in
|
|
6
|
+
* the `(frontend, edge)` CHIPS partition.
|
|
7
|
+
*
|
|
8
|
+
* The SDK's `auth/init-session` builds the OAuth `redirect_uri` as
|
|
9
|
+
* `${requestOrigin}${HUBSPOT_FRONTEND_CALLBACK_PATH}`. The browser
|
|
10
|
+
* controller, on `start()`, recognizes this path on `window.location`
|
|
11
|
+
* and forwards `?code` + `?state` to the SDK's `auth/complete`
|
|
12
|
+
* endpoint via a credentialed cross-site fetch. The host app must
|
|
13
|
+
* register `${app_origin}${HUBSPOT_FRONTEND_CALLBACK_PATH}` as a
|
|
14
|
+
* redirect URI in its HubSpot app settings.
|
|
6
15
|
*/
|
|
16
|
+
const HUBSPOT_FRONTEND_CALLBACK_PATH = "/__hubspot_oauth_callback";
|
|
7
17
|
/**
|
|
8
|
-
* Query parameter on the
|
|
9
|
-
*
|
|
10
|
-
* the `auth/callback` redirect; the browser parses it during `initSdk`
|
|
11
|
-
* and then strips it from the URL via `history.replaceState`.
|
|
18
|
+
* Query parameter on the `auth/complete` POST request carrying the
|
|
19
|
+
* authorization `code` HubSpot returned to the frontend callback.
|
|
12
20
|
*/
|
|
13
|
-
const
|
|
21
|
+
const AUTH_COMPLETE_CODE_PARAM = "code";
|
|
22
|
+
/**
|
|
23
|
+
* Query parameter on the `auth/complete` POST request carrying the
|
|
24
|
+
* OAuth `state` HubSpot echoed back to the frontend callback.
|
|
25
|
+
*/
|
|
26
|
+
const AUTH_COMPLETE_STATE_PARAM = "state";
|
|
14
27
|
//#endregion
|
|
15
|
-
export {
|
|
28
|
+
export { AUTH_COMPLETE_CODE_PARAM, AUTH_COMPLETE_STATE_PARAM, HUBSPOT_FRONTEND_CALLBACK_PATH };
|
|
16
29
|
|
|
17
30
|
//# sourceMappingURL=constants.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.js","names":[],"sources":["../../../src/shared/constants.ts"],"sourcesContent":["/**\n * Constants whose values are part of the contract between the browser\n * controller and the server-side hubspot-connect routes. Both halves\n * import from this module so the wire format stays in sync.\n */\n\n/**\n * Query parameter on the OAuth return URL that carries the new access\n * token's expiry (Unix epoch milliseconds). The
|
|
1
|
+
{"version":3,"file":"constants.js","names":[],"sources":["../../../src/shared/constants.ts"],"sourcesContent":["/**\n * Constants whose values are part of the contract between the browser\n * controller and the server-side hubspot-connect routes. Both halves\n * import from this module so the wire format stays in sync.\n */\n\n/**\n * Query parameter on the OAuth return URL that carries the new access\n * token's expiry (Unix epoch milliseconds). The browser controller\n * sets this in the URL after a successful `auth/complete` call and\n * then strips it during `initAppConnect` via `history.replaceState`.\n */\nexport const EXPIRES_AT_URL_PARAM = '__hs_expires_at';\n\n/**\n * Path the browser visits after HubSpot's authorize endpoint\n * redirects back to the app. Mounted on the **frontend** origin (not\n * the SDK's edge function host) so all OAuth-related cookies live in\n * the `(frontend, edge)` CHIPS partition.\n *\n * The SDK's `auth/init-session` builds the OAuth `redirect_uri` as\n * `${requestOrigin}${HUBSPOT_FRONTEND_CALLBACK_PATH}`. The browser\n * controller, on `start()`, recognizes this path on `window.location`\n * and forwards `?code` + `?state` to the SDK's `auth/complete`\n * endpoint via a credentialed cross-site fetch. The host app must\n * register `${app_origin}${HUBSPOT_FRONTEND_CALLBACK_PATH}` as a\n * redirect URI in its HubSpot app settings.\n */\nexport const HUBSPOT_FRONTEND_CALLBACK_PATH = '/__hubspot_oauth_callback';\n\n/**\n * Query parameter on the `auth/complete` POST request carrying the\n * authorization `code` HubSpot returned to the frontend callback.\n */\nexport const AUTH_COMPLETE_CODE_PARAM = 'code';\n\n/**\n * Query parameter on the `auth/complete` POST request carrying the\n * OAuth `state` HubSpot echoed back to the frontend callback.\n */\nexport const AUTH_COMPLETE_STATE_PARAM = 'state';\n"],"mappings":";;;;;;;;;;;;;;;AA4BA,MAAa,iCAAiC;;;;;AAM9C,MAAa,2BAA2B;;;;;AAMxC,MAAa,4BAA4B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hubspot/app-connect-sdk",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.3",
|
|
4
4
|
"description": "HubSpot App Connect SDK (alpha release). Documentation and integration guidance forthcoming.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -50,9 +50,9 @@
|
|
|
50
50
|
"tsdown": "0.22.0-beta.3",
|
|
51
51
|
"typescript": "6.0.3",
|
|
52
52
|
"vitest": "4.0.18",
|
|
53
|
+
"@private/prettier-config": "0.1.0",
|
|
53
54
|
"@private/tsconfig": "0.1.0",
|
|
54
|
-
"@private/eslint-config": "0.1.0"
|
|
55
|
-
"@private/prettier-config": "0.1.0"
|
|
55
|
+
"@private/eslint-config": "0.1.0"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
|
58
58
|
"clean": "rm -rf dist build *.tsbuildinfo node_modules .turbo",
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { HUBSPOT_FRONTEND_CALLBACK_PATH } from '../../shared/constants.ts';
|
|
4
|
+
import { noopLogger } from '../../shared/logger.ts';
|
|
5
|
+
import { EXPIRES_AT_KEY } from './constants.ts';
|
|
6
|
+
import { initAppConnect } from './init.ts';
|
|
7
|
+
import type {
|
|
8
|
+
AppConnectContext,
|
|
9
|
+
AppConnectInternalState,
|
|
10
|
+
SessionStorage,
|
|
11
|
+
} from './types.ts';
|
|
12
|
+
import { createStore } from './utils/store-utils.ts';
|
|
13
|
+
|
|
14
|
+
const HUBSPOT_CONNECT_BASE_URL =
|
|
15
|
+
'https://edge.example.com/functions/v1/hubspot-connect';
|
|
16
|
+
|
|
17
|
+
function createInMemorySessionStorage(): SessionStorage {
|
|
18
|
+
const map = new Map<string, string>();
|
|
19
|
+
return {
|
|
20
|
+
getItem: (key) => map.get(key) ?? null,
|
|
21
|
+
setItem: (key, value) => {
|
|
22
|
+
map.set(key, value);
|
|
23
|
+
},
|
|
24
|
+
removeItem: (key) => {
|
|
25
|
+
map.delete(key);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createTestContext(): AppConnectContext {
|
|
31
|
+
const initialState: AppConnectInternalState = {
|
|
32
|
+
isInitComplete: false,
|
|
33
|
+
isConnectInFlight: false,
|
|
34
|
+
isSessionConnected: false,
|
|
35
|
+
isDisconnectInFlight: false,
|
|
36
|
+
error: null,
|
|
37
|
+
expiresAt: null,
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
config: { hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL },
|
|
41
|
+
logger: noopLogger,
|
|
42
|
+
sessionStorage: createInMemorySessionStorage(),
|
|
43
|
+
store: createStore<AppConnectInternalState>(initialState),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface FakeWindowOptions {
|
|
48
|
+
href: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface FakeWindow {
|
|
52
|
+
history: { calls: Array<[unknown, string, string]> };
|
|
53
|
+
windowProxy: {
|
|
54
|
+
location: Pick<Location, 'pathname' | 'search' | 'origin'>;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function installFakeWindow(options: FakeWindowOptions): FakeWindow {
|
|
59
|
+
const url = new URL(options.href);
|
|
60
|
+
const calls: Array<[unknown, string, string]> = [];
|
|
61
|
+
const fakeWindow = {
|
|
62
|
+
location: {
|
|
63
|
+
pathname: url.pathname,
|
|
64
|
+
search: url.search,
|
|
65
|
+
origin: url.origin,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const fakeHistory = {
|
|
69
|
+
replaceState: (state: unknown, title: string, newUrl: string) => {
|
|
70
|
+
calls.push([state, title, newUrl]);
|
|
71
|
+
const replaced = new URL(newUrl, url.origin);
|
|
72
|
+
fakeWindow.location.pathname = replaced.pathname;
|
|
73
|
+
fakeWindow.location.search = replaced.search;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
vi.stubGlobal('window', fakeWindow);
|
|
77
|
+
vi.stubGlobal('history', fakeHistory);
|
|
78
|
+
return { history: { calls }, windowProxy: fakeWindow };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('initAppConnect', () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
vi.useFakeTimers();
|
|
84
|
+
vi.setSystemTime(new Date(1_700_000_000_000));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.unstubAllGlobals();
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
vi.useRealTimers();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('is a no-op on a normal page (no callback path, no expires_at param)', async () => {
|
|
94
|
+
installFakeWindow({ href: 'https://app.example.com/dashboard' });
|
|
95
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
96
|
+
const context = createTestContext();
|
|
97
|
+
|
|
98
|
+
await initAppConnect(context);
|
|
99
|
+
|
|
100
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
101
|
+
expect(context.store.getSnapshot().expiresAt).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('POSTs to /auth/complete on the OAuth callback path and persists expires_at + return_path', async () => {
|
|
105
|
+
const expiresAt = Date.now() + 1800 * 1000;
|
|
106
|
+
installFakeWindow({
|
|
107
|
+
href: `https://app.example.com${HUBSPOT_FRONTEND_CALLBACK_PATH}?code=auth-code&state=auth-state`,
|
|
108
|
+
});
|
|
109
|
+
const fetchSpy = vi
|
|
110
|
+
.spyOn(globalThis, 'fetch')
|
|
111
|
+
.mockResolvedValue(
|
|
112
|
+
new Response(
|
|
113
|
+
JSON.stringify({ expires_at: expiresAt, return_path: '/dashboard' }),
|
|
114
|
+
{ status: 200 }
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
const context = createTestContext();
|
|
118
|
+
|
|
119
|
+
await initAppConnect(context);
|
|
120
|
+
|
|
121
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
122
|
+
const [calledUrl, calledInit] = fetchSpy.mock.calls[0]!;
|
|
123
|
+
const url = new URL(calledUrl as string);
|
|
124
|
+
expect(url.origin + url.pathname).toBe(
|
|
125
|
+
`${HUBSPOT_CONNECT_BASE_URL}/auth/complete`
|
|
126
|
+
);
|
|
127
|
+
expect(url.searchParams.get('code')).toBe('auth-code');
|
|
128
|
+
expect(url.searchParams.get('state')).toBe('auth-state');
|
|
129
|
+
expect((calledInit as RequestInit).method).toBe('POST');
|
|
130
|
+
expect((calledInit as RequestInit).credentials).toBe('include');
|
|
131
|
+
|
|
132
|
+
expect(context.store.getSnapshot().expiresAt).toBe(expiresAt);
|
|
133
|
+
expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBe(
|
|
134
|
+
String(expiresAt)
|
|
135
|
+
);
|
|
136
|
+
expect(window.location.pathname).toBe('/dashboard');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('throws and clears session storage when /auth/complete returns a non-OK response', async () => {
|
|
140
|
+
installFakeWindow({
|
|
141
|
+
href: `https://app.example.com${HUBSPOT_FRONTEND_CALLBACK_PATH}?code=auth-code&state=bad-state`,
|
|
142
|
+
});
|
|
143
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
144
|
+
new Response('{"error":"State mismatch"}', {
|
|
145
|
+
status: 403,
|
|
146
|
+
statusText: 'Forbidden',
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
const context = createTestContext();
|
|
150
|
+
context.sessionStorage.setItem(EXPIRES_AT_KEY, '999');
|
|
151
|
+
|
|
152
|
+
await expect(initAppConnect(context)).rejects.toThrow(/Failed to complete/);
|
|
153
|
+
expect(context.sessionStorage.getItem(EXPIRES_AT_KEY)).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('skips the callback step on the callback path when code or state are missing', async () => {
|
|
157
|
+
installFakeWindow({
|
|
158
|
+
href: `https://app.example.com${HUBSPOT_FRONTEND_CALLBACK_PATH}`,
|
|
159
|
+
});
|
|
160
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
161
|
+
const context = createTestContext();
|
|
162
|
+
|
|
163
|
+
await initAppConnect(context);
|
|
164
|
+
|
|
165
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -1,35 +1,86 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AUTH_COMPLETE_CODE_PARAM,
|
|
3
|
+
AUTH_COMPLETE_STATE_PARAM,
|
|
4
|
+
HUBSPOT_FRONTEND_CALLBACK_PATH,
|
|
5
|
+
} from '../../shared/constants.ts';
|
|
6
|
+
import type { AuthCompleteResponse } from '../../shared/wire-types.ts';
|
|
7
|
+
import { EXPIRES_AT_KEY } from './constants.ts';
|
|
2
8
|
import type { AppConnectContext } from './types.ts';
|
|
9
|
+
import { clearSessionStorage } from './utils/session-utils.ts';
|
|
3
10
|
|
|
4
11
|
/**
|
|
5
|
-
*
|
|
6
|
-
* the redirect URL, persists it to the controller, and strips it from
|
|
7
|
-
* the address bar so it's not logged or bookmarked.
|
|
12
|
+
* On `controller.start()`:
|
|
8
13
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
14
|
+
* 1. If the browser has been redirected back to the SDK's frontend
|
|
15
|
+
* OAuth callback path (`HUBSPOT_FRONTEND_CALLBACK_PATH`) with
|
|
16
|
+
* `?code` + `?state`, POST those values to the SDK's
|
|
17
|
+
* `auth/complete` endpoint to finish the token exchange. The
|
|
18
|
+
* server sets the durable session cookies on the response (in the
|
|
19
|
+
* same `(frontend, edge)` CHIPS partition the SDK reads them from
|
|
20
|
+
* on subsequent API fetches) and returns `{ expires_at,
|
|
21
|
+
* return_path }`. Replace the URL with `${return_path}?
|
|
22
|
+
* ${EXPIRES_AT_URL_PARAM}=${expires_at}` so the rest of init
|
|
23
|
+
* runs against the page the user actually started the connect
|
|
24
|
+
* flow from.
|
|
25
|
+
* 2. Pick up `?__hs_expires_at` from `window.location` (placed there
|
|
26
|
+
* by step 1, or by an in-progress refresh hop), persist it to the
|
|
27
|
+
* controller's store + sessionStorage, and strip it from the
|
|
28
|
+
* address bar so it isn't logged or bookmarked.
|
|
29
|
+
*
|
|
30
|
+
* A no-op when neither set of parameters is present (every page
|
|
31
|
+
* load other than the OAuth return trip).
|
|
12
32
|
*/
|
|
13
33
|
export async function initAppConnect(
|
|
14
34
|
context: AppConnectContext
|
|
15
35
|
): Promise<void> {
|
|
36
|
+
await consumeOAuthCallback(context);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function consumeOAuthCallback(context: AppConnectContext): Promise<void> {
|
|
40
|
+
if (window.location.pathname !== HUBSPOT_FRONTEND_CALLBACK_PATH) return;
|
|
41
|
+
|
|
16
42
|
const params = new URLSearchParams(window.location.search);
|
|
17
|
-
const
|
|
18
|
-
|
|
43
|
+
const code = params.get(AUTH_COMPLETE_CODE_PARAM);
|
|
44
|
+
const state = params.get(AUTH_COMPLETE_STATE_PARAM);
|
|
45
|
+
if (!code || !state) return;
|
|
46
|
+
|
|
47
|
+
const completeUrl = new URL(
|
|
48
|
+
`${context.config.hubSpotConnectBaseUrl}/auth/complete`,
|
|
49
|
+
window.location.origin
|
|
50
|
+
);
|
|
51
|
+
completeUrl.searchParams.set(AUTH_COMPLETE_CODE_PARAM, code);
|
|
52
|
+
completeUrl.searchParams.set(AUTH_COMPLETE_STATE_PARAM, state);
|
|
53
|
+
|
|
54
|
+
let response: Response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetch(completeUrl.toString(), {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
credentials: 'include',
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
clearSessionStorage(context);
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Failed to complete HubSpot OAuth: ${err instanceof Error ? err.message : String(err)}`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
19
66
|
|
|
20
|
-
|
|
21
|
-
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
clearSessionStorage(context);
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Failed to complete HubSpot OAuth: ${response.status} ${response.statusText}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
22
73
|
|
|
23
|
-
|
|
24
|
-
|
|
74
|
+
const body = (await response.json()) as AuthCompleteResponse;
|
|
75
|
+
|
|
76
|
+
const { expires_at: expiresAt, return_path: returnPath } = body;
|
|
77
|
+
context.store.setState({ expiresAt });
|
|
78
|
+
context.sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt));
|
|
79
|
+
|
|
80
|
+
const targetUrl = new URL(returnPath, window.location.origin);
|
|
25
81
|
history.replaceState(
|
|
26
82
|
null,
|
|
27
83
|
'',
|
|
28
|
-
|
|
29
|
-
? `${window.location.pathname}?${cleanSearch}`
|
|
30
|
-
: window.location.pathname
|
|
84
|
+
`${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`
|
|
31
85
|
);
|
|
32
|
-
|
|
33
|
-
context.store.setState({ expiresAt });
|
|
34
|
-
context.sessionStorage.setItem(EXPIRES_AT_KEY, String(expiresAt));
|
|
35
86
|
}
|
|
@@ -37,6 +37,8 @@ interface AppConnectHeaderProps {
|
|
|
37
37
|
|
|
38
38
|
export function AppConnectHeader({ title }: AppConnectHeaderProps) {
|
|
39
39
|
const { status } = useHubSpotAppConnect();
|
|
40
|
+
const connectButton =
|
|
41
|
+
status === 'initializing' ? null : <ConnectButton variant="secondary" />;
|
|
40
42
|
return (
|
|
41
43
|
<header className={styles.header}>
|
|
42
44
|
<div className={styles.titleRow}>
|
|
@@ -48,11 +50,7 @@ export function AppConnectHeader({ title }: AppConnectHeaderProps) {
|
|
|
48
50
|
{status === 'connected' ? <ViewingHubSpotContextRow /> : null}
|
|
49
51
|
</div>
|
|
50
52
|
</div>
|
|
51
|
-
{status === 'connected' ?
|
|
52
|
-
<UserMenu />
|
|
53
|
-
) : (
|
|
54
|
-
<ConnectButton variant="secondary" />
|
|
55
|
-
)}
|
|
53
|
+
{status === 'connected' ? <UserMenu /> : connectButton}
|
|
56
54
|
</header>
|
|
57
55
|
);
|
|
58
56
|
}
|
|
@@ -20,7 +20,8 @@ export function ConnectButton({
|
|
|
20
20
|
className,
|
|
21
21
|
}: ConnectButtonProps) {
|
|
22
22
|
const { status, connectToHubSpot } = useHubSpotAppConnect();
|
|
23
|
-
|
|
23
|
+
console.log('status', status);
|
|
24
|
+
const isConnecting = status === 'connecting' || status === 'initializing';
|
|
24
25
|
const composedClassName = [root, className].filter(Boolean).join(' ');
|
|
25
26
|
const labelClassName = isConnecting ? `${label} ${labelMuted}` : label;
|
|
26
27
|
return (
|
|
@@ -101,11 +101,15 @@ export function fetchTransportPlugin(
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
const response = await fetch(url, init);
|
|
104
|
+
const responseHeaders: Record<string, string> = Object.create(null);
|
|
105
|
+
response.headers.forEach((value, key) => {
|
|
106
|
+
responseHeaders[key] = value;
|
|
107
|
+
});
|
|
104
108
|
|
|
105
109
|
return {
|
|
106
110
|
status: response.status,
|
|
107
111
|
statusText: response.statusText,
|
|
108
|
-
headers:
|
|
112
|
+
headers: responseHeaders,
|
|
109
113
|
// 204 No Content responses have no body to parse
|
|
110
114
|
bodyJson: response.status === 204 ? undefined : await response.json(),
|
|
111
115
|
};
|
package/src/server/constants.ts
CHANGED
|
@@ -12,6 +12,25 @@ export const HUBSPOT_ACCESS_TOKEN_COOKIE_NAME = '__Host-hs_access_token';
|
|
|
12
12
|
*/
|
|
13
13
|
export const HUBSPOT_APP_SID_COOKIE_NAME = '__Host-hs_app_sid';
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Cookie pinning the browser-facing app origin (e.g.
|
|
17
|
+
* `https://app.example.com`) for the lifetime of an app session.
|
|
18
|
+
* Set by `auth/init-session` from the request's `Origin` header and
|
|
19
|
+
* read by:
|
|
20
|
+
*
|
|
21
|
+
* - The CORS middleware to emit a credentialed
|
|
22
|
+
* `Access-Control-Allow-Origin` value (`*` is forbidden when
|
|
23
|
+
* `Access-Control-Allow-Credentials: true`).
|
|
24
|
+
* - `auth/complete` to rebuild the OAuth `redirect_uri` it sent to
|
|
25
|
+
* HubSpot during `init-session` (the token endpoint validates that
|
|
26
|
+
* the two values match).
|
|
27
|
+
*
|
|
28
|
+
* `__Host-` prefixed (Path=/, Secure, no Domain), so the same
|
|
29
|
+
* function host can serve both `hubspot-connect` and `api` routes
|
|
30
|
+
* and read the cookie from either.
|
|
31
|
+
*/
|
|
32
|
+
export const HUBSPOT_APP_ORIGIN_COOKIE_NAME = '__Host-hs_app_origin';
|
|
33
|
+
|
|
15
34
|
/**
|
|
16
35
|
* Prefix used for refresh-token cookies. Each session gets its own
|
|
17
36
|
* cookie name (`hs_refresh_<sidHash>`) so multiple devices/tabs can
|
|
@@ -22,6 +41,7 @@ export const HUBSPOT_REFRESH_COOKIE_PREFIX = 'hs_refresh_';
|
|
|
22
41
|
const PROTECTED_COOKIE_NAMES = new Set([
|
|
23
42
|
HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,
|
|
24
43
|
HUBSPOT_APP_SID_COOKIE_NAME,
|
|
44
|
+
HUBSPOT_APP_ORIGIN_COOKIE_NAME,
|
|
25
45
|
]);
|
|
26
46
|
|
|
27
47
|
/**
|
|
@@ -40,14 +60,19 @@ export function isProtectedCookieName(cookieName: string): boolean {
|
|
|
40
60
|
|
|
41
61
|
/**
|
|
42
62
|
* Cookie carrying the PKCE code verifier between `init-session` and
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
63
|
+
* `auth/complete`. Set with `SameSite=None; Secure; Partitioned` so the
|
|
64
|
+
* frontend's credentialed cross-site `POST /auth/complete` (made from
|
|
65
|
+
* the OAuth callback page on the app origin to the SDK's edge function
|
|
66
|
+
* origin) carries it through. `Lax` would silently drop it on that
|
|
67
|
+
* fetch, breaking every successful HubSpot redirect.
|
|
45
68
|
*/
|
|
46
69
|
export const TEMP_COOKIE_PKCE_VERIFIER = '__hs_pkce_verifier';
|
|
47
70
|
|
|
48
71
|
/**
|
|
49
72
|
* Cookie carrying the OAuth `state` value between `init-session` and
|
|
50
|
-
* `
|
|
51
|
-
* primary CSRF defense.
|
|
73
|
+
* `auth/complete`. Compared against the `state` query parameter as the
|
|
74
|
+
* primary CSRF defense. Same `SameSite=None; Secure; Partitioned`
|
|
75
|
+
* attributes as `TEMP_COOKIE_PKCE_VERIFIER` for the same cross-site
|
|
76
|
+
* `POST /auth/complete` reason.
|
|
52
77
|
*/
|
|
53
78
|
export const TEMP_COOKIE_OAUTH_STATE = '__hs_oauth_state';
|