@hubspot/app-connect-sdk 1.0.0-alpha.20 → 1.0.0-alpha.21
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 +59 -56
- package/.turbo/turbo-tsdown.log +18 -16
- package/build/tsconfig.browser.tsbuildinfo +1 -1
- package/build/tsconfig.server.tsbuildinfo +1 -1
- package/dist/browser/{HubSpotAppConnect-DFe9b90e.js → HubSpotAppConnect-721kYr9d.js} +14 -21
- package/dist/browser/{HubSpotAppConnect-DFe9b90e.js.map → HubSpotAppConnect-721kYr9d.js.map} +1 -1
- package/dist/browser/{create-BNQazCF-.js → create-DxEyGG-k.js} +82 -37
- package/dist/browser/create-DxEyGG-k.js.map +1 -0
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/react/lovable.d.ts +1 -1
- package/dist/browser/react/lovable.js +2 -2
- package/dist/browser/react.d.ts +1 -1
- package/dist/browser/react.js +1 -1
- package/dist/browser/{types-DkAmHcZt.d.ts → types-C3wed8dU.d.ts} +31 -1
- package/dist/server/hono/hubspot-connect-routes/auth-complete.js +4 -1
- package/dist/server/hono/hubspot-connect-routes/auth-complete.js.map +1 -1
- package/dist/server/hono/hubspot-connect-routes/whoami.js +51 -0
- package/dist/server/hono/hubspot-connect-routes/whoami.js.map +1 -0
- package/package.json +3 -3
- package/src/browser/app-connect-controller/connect-start.test.ts +1 -0
- package/src/browser/app-connect-controller/constants.ts +6 -4
- package/src/browser/app-connect-controller/create.ts +8 -2
- package/src/browser/app-connect-controller/disconnect.ts +1 -0
- package/src/browser/app-connect-controller/init.test.ts +30 -15
- package/src/browser/app-connect-controller/init.ts +2 -2
- package/src/browser/app-connect-controller/oauth-complete.test.ts +7 -3
- package/src/browser/app-connect-controller/oauth-popup.test.ts +1 -0
- package/src/browser/app-connect-controller/types.ts +3 -0
- package/src/browser/app-connect-controller/utils/session-utils.test.ts +73 -22
- package/src/browser/app-connect-controller/utils/session-utils.ts +74 -33
- package/src/browser/app-connect-controller/view-state.test.ts +1 -0
- package/src/browser/app-connect-controller/view-state.ts +1 -0
- package/src/browser/react/components/AppConnectHeader/AppConnectHeader.tsx +18 -29
- package/src/browser/types.ts +9 -0
- package/src/server/hono/hubspot-connect-routes/auth-complete.test.ts +129 -20
- package/src/server/hono/hubspot-connect-routes/auth-complete.ts +8 -1
- package/src/server/hono/hubspot-connect-routes/whoami.ts +74 -0
- package/src/shared/wire-types.ts +30 -0
- package/dist/browser/create-BNQazCF-.js.map +0 -1
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
//#region src/shared/wire-types.d.ts
|
|
2
|
+
interface AuthCompleteWhoamiUser {
|
|
3
|
+
id?: string | undefined;
|
|
4
|
+
email?: string | undefined;
|
|
5
|
+
firstName?: string | undefined;
|
|
6
|
+
lastName?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
interface AuthCompleteWhoamiHub {
|
|
9
|
+
id?: number | undefined;
|
|
10
|
+
domain?: string | undefined;
|
|
11
|
+
uiDomain?: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Identity information for the authenticated user and their HubSpot
|
|
15
|
+
* account. Both fields are optional because the access token may not
|
|
16
|
+
* carry the scopes needed to fetch each piece (`public-users-read` for
|
|
17
|
+
* `user`, `external-settings-access` for `hub`).
|
|
18
|
+
*/
|
|
19
|
+
interface AuthCompleteWhoami {
|
|
20
|
+
user: AuthCompleteWhoamiUser;
|
|
21
|
+
hub: AuthCompleteWhoamiHub;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
1
24
|
//#region src/browser/types.d.ts
|
|
2
25
|
/**
|
|
3
26
|
* How the browser navigates to HubSpot's OAuth authorize endpoint when
|
|
@@ -48,6 +71,13 @@ interface AppConnectState {
|
|
|
48
71
|
status: AppConnectStatus;
|
|
49
72
|
/** Last error message that occurred, or `null` if no error. */
|
|
50
73
|
error: string | null;
|
|
74
|
+
/**
|
|
75
|
+
* Identity of the connected HubSpot user and their account. Populated
|
|
76
|
+
* after a successful OAuth flow on a best-effort basis (fields may be
|
|
77
|
+
* absent if the access token lacked the required scopes). `null` before
|
|
78
|
+
* connect or after disconnect.
|
|
79
|
+
*/
|
|
80
|
+
whoami: AuthCompleteWhoami | null;
|
|
51
81
|
/**
|
|
52
82
|
* Begins the OAuth connect flow. On success the browser either
|
|
53
83
|
* redirects to HubSpot's authorize endpoint or opens it in a popup
|
|
@@ -91,4 +121,4 @@ interface AppConnectController {
|
|
|
91
121
|
}
|
|
92
122
|
//#endregion
|
|
93
123
|
export { OAuthConnectMode as a, AppConnectStatus as i, AppConnectController as n, AppConnectState as r, AppConnectBrowserConfig as t };
|
|
94
|
-
//# sourceMappingURL=types-
|
|
124
|
+
//# sourceMappingURL=types-C3wed8dU.d.ts.map
|
|
@@ -6,6 +6,7 @@ import { serializeCookie, setResponseCookie } from "../utils/cookie-utils.js";
|
|
|
6
6
|
import { REFRESH_COOKIE_MAX_AGE_SEC } from "./constants.js";
|
|
7
7
|
import { buildClientAssertion, buildClientAssertionFormParams, buildClientSecretFormParams, buildTokenEndpointDpopProof, requestOAuthToken } from "./oauth-client.js";
|
|
8
8
|
import { buildCimdClientIdUrlFromRequest, buildFrontendOAuthRedirectUri, clearTempCookie, isPositiveFiniteNumber, isSafeReturnPath, parseAppOriginHeader } from "./utils.js";
|
|
9
|
+
import { fetchWhoami } from "./whoami.js";
|
|
9
10
|
//#region src/server/hono/hubspot-connect-routes/auth-complete.ts
|
|
10
11
|
/**
|
|
11
12
|
* Cross-origin OAuth completion endpoint.
|
|
@@ -144,9 +145,11 @@ async function handleAuthComplete(c, options) {
|
|
|
144
145
|
c,
|
|
145
146
|
value: clearTempCookie(TEMP_COOKIE_OAUTH_STATE)
|
|
146
147
|
});
|
|
148
|
+
const whoami = await fetchWhoami(accessToken, hubspotConnectEnv);
|
|
147
149
|
return c.json({
|
|
148
150
|
expires_at: expiresAt,
|
|
149
|
-
return_path: returnPath
|
|
151
|
+
return_path: returnPath,
|
|
152
|
+
whoami
|
|
150
153
|
});
|
|
151
154
|
}
|
|
152
155
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-complete.js","names":[],"sources":["../../../../src/server/hono/hubspot-connect-routes/auth-complete.ts"],"sourcesContent":["import type { Context } from 'hono';\n\nimport {\n AUTH_COMPLETE_CODE_PARAM,\n AUTH_COMPLETE_STATE_PARAM,\n} from '../../../shared/constants.ts';\nimport {\n HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,\n HUBSPOT_APP_ORIGIN_COOKIE_NAME,\n HUBSPOT_REFRESH_COOKIE_PREFIX,\n TEMP_COOKIE_OAUTH_STATE,\n TEMP_COOKIE_PKCE_VERIFIER,\n} from '../../constants.ts';\nimport { base64urlDecode } from '../../utils/base64-utils.ts';\nimport { parseCookies } from '../../utils/cookie-utils.ts';\nimport { serializeCookie, setResponseCookie } from '../utils/cookie-utils.ts';\nimport { REFRESH_COOKIE_MAX_AGE_SEC } from './constants.ts';\nimport {\n buildClientAssertion,\n buildClientAssertionFormParams,\n buildClientSecretFormParams,\n buildTokenEndpointDpopProof,\n requestOAuthToken,\n} from './oauth-client.ts';\nimport type { HubSpotConnectOAuthRouteOptions } from './types.ts';\nimport {\n buildCimdClientIdUrlFromRequest,\n buildFrontendOAuthRedirectUri,\n clearTempCookie,\n isPositiveFiniteNumber,\n isSafeReturnPath,\n parseAppOriginHeader,\n} from './utils.ts';\n\ninterface OAuthStatePayload {\n return_path?: string;\n sid?: string;\n}\n\n/**\n * Cross-origin OAuth completion endpoint.\n *\n * Called from the React app on the frontend OAuth callback path\n * (`HUBSPOT_FRONTEND_CALLBACK_PATH`) once HubSpot has redirected the\n * browser back with `?code` + `?state`. The browser POSTs both\n * values here as a credentialed cross-site fetch — same partition as\n * `init-session`, so the temp PKCE/state cookies are visible — and\n * the SDK:\n *\n * 1. Validates `state` against the temp `__hs_oauth_state` cookie.\n * 2. Pulls the PKCE verifier from `__hs_pkce_verifier`.\n * 3. Rebuilds the same `redirect_uri` it sent to HubSpot during\n * `init-session` (frontend origin + the fixed callback path);\n * the OAuth token endpoint requires the two values to match.\n * 4. Exchanges `code` for an access + refresh token (with DPoP /\n * CIMD client-assertion when enabled).\n * 5. Sets the durable session cookies (access token, refresh) with\n * `SameSite=None; Secure; Partitioned` so they live in the\n * `(frontend, edge)` partition where subsequent API fetches will\n * read them.\n * 6. Clears the temp cookies.\n * 7. Returns `{ expires_at, return_path }` so the controller can\n * update its session-storage expiry tracking and navigate back to\n * the page the user started the connect flow from.\n */\nexport async function handleAuthComplete(\n c: Context,\n options: HubSpotConnectOAuthRouteOptions\n) {\n const { appKeys, refreshCookiePath, hubspotConnectEnv } = options;\n const xForwardedProto = c.req.header('x-forwarded-proto') ?? undefined;\n const xForwardedHost = c.req.header('x-forwarded-host') ?? undefined;\n const requestHostHeader = c.req.header('host') ?? undefined;\n const code = c.req.query(AUTH_COMPLETE_CODE_PARAM);\n const state = c.req.query(AUTH_COMPLETE_STATE_PARAM);\n\n if (!code || !state) {\n return c.json({ error: 'Missing code or state' }, 400);\n }\n\n if (hubspotConnectEnv.isAppPrivateKeyRequired && !appKeys) {\n return c.json(\n {\n error:\n 'Server misconfiguration: HUBSPOT_APP_PRIVATE_KEY is required when CIMD or DPoP is enabled',\n },\n 500\n );\n }\n\n const cookies = parseCookies(c.req.header('Cookie'));\n const expectedState = cookies[TEMP_COOKIE_OAUTH_STATE];\n const codeVerifier = cookies[TEMP_COOKIE_PKCE_VERIFIER];\n const appOriginCookie = cookies[HUBSPOT_APP_ORIGIN_COOKIE_NAME];\n\n if (!expectedState || state !== decodeURIComponent(expectedState)) {\n return c.json({ error: 'State mismatch' }, 403);\n }\n if (!codeVerifier) {\n return c.json({ error: 'Missing PKCE verifier' }, 400);\n }\n // The redirect_uri the OAuth token endpoint validates must equal\n // the one we sent during init-session. We rebuild it from the\n // pinned origin cookie so that value is anchored server-side, not\n // taken from the (caller-controlled) request `Origin` on this call.\n const appOrigin = parseAppOriginHeader(appOriginCookie);\n if (!appOrigin) {\n return c.json({ error: 'Missing app origin cookie' }, 400);\n }\n\n let statePayload: OAuthStatePayload;\n try {\n statePayload = JSON.parse(\n new TextDecoder().decode(base64urlDecode(decodeURIComponent(state)))\n ) as OAuthStatePayload;\n } catch {\n return c.json({ error: 'Malformed state value' }, 400);\n }\n const returnPath = statePayload.return_path;\n if (!returnPath || !isSafeReturnPath(returnPath)) {\n return c.json({ error: 'Invalid return path in state' }, 400);\n }\n\n const sessionId = statePayload.sid;\n if (!sessionId) {\n return c.json({ error: 'Missing app session cookie' }, 400);\n }\n\n const decodedCodeVerifier = decodeURIComponent(codeVerifier);\n\n const clientId = hubspotConnectEnv.isCimdEnabled\n ? buildCimdClientIdUrlFromRequest({\n requestUrl: c.req.url,\n basePath: options.basePath,\n xForwardedProto,\n xForwardedHost,\n requestHostHeader,\n appOrigin,\n })\n : hubspotConnectEnv.hubspotClientId;\n\n const redirectUri = buildFrontendOAuthRedirectUri(appOrigin);\n\n const tokenEndpointUrl = new URL(\n '/oauth/v1/token',\n hubspotConnectEnv.hubspotOAuthApiOrigin\n ).href;\n\n let dpopProof: string | undefined;\n if (hubspotConnectEnv.isDpopEnabled) {\n dpopProof = await buildTokenEndpointDpopProof({\n appKeys: appKeys!,\n tokenEndpointUrl,\n sessionIdHash: sessionId,\n });\n }\n\n let formParams: Record<string, string>;\n if (hubspotConnectEnv.isCimdEnabled) {\n const clientAssertion = await buildClientAssertion({\n appKeys: appKeys!,\n clientId,\n audience: tokenEndpointUrl,\n });\n formParams = {\n grant_type: 'authorization_code',\n code,\n code_verifier: decodedCodeVerifier,\n redirect_uri: redirectUri,\n ...buildClientAssertionFormParams({ clientId, clientAssertion }),\n };\n } else {\n formParams = {\n grant_type: 'authorization_code',\n code,\n code_verifier: decodedCodeVerifier,\n redirect_uri: redirectUri,\n ...buildClientSecretFormParams({\n clientId,\n clientSecret: hubspotConnectEnv.hubspotClientSecret,\n }),\n };\n }\n\n const tokenResult = await requestOAuthToken({\n tokenEndpointUrl,\n isDpopEnabled: hubspotConnectEnv.isDpopEnabled,\n ...(dpopProof !== undefined ? { dpopProof } : {}),\n formParams,\n });\n if (!tokenResult.ok) {\n return c.json(\n { error: `Token exchange failed: ${tokenResult.errorText}` },\n 502\n );\n }\n\n const {\n access_token: accessToken,\n refresh_token: refreshToken,\n expires_in,\n } = tokenResult.body;\n if (!refreshToken) {\n return c.json({ error: 'Token response missing refresh_token' }, 502);\n }\n if (!isPositiveFiniteNumber(expires_in)) {\n return c.json(\n { error: 'Token response missing or invalid expires_in' },\n 502\n );\n }\n\n const expiresAt = Date.now() + expires_in * 1000;\n const refreshCookieName = `${HUBSPOT_REFRESH_COOKIE_PREFIX}${sessionId}`;\n\n setResponseCookie({\n c,\n value: serializeCookie({\n name: HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,\n value: accessToken,\n path: '/',\n sameSite: 'None',\n partitioned: true,\n maxAge: expires_in,\n }),\n });\n setResponseCookie({\n c,\n value: serializeCookie({\n name: refreshCookieName,\n value: refreshToken,\n path: refreshCookiePath,\n sameSite: 'None',\n partitioned: true,\n maxAge: REFRESH_COOKIE_MAX_AGE_SEC,\n }),\n });\n setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_PKCE_VERIFIER) });\n setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_OAUTH_STATE) });\n\n return c.json({ expires_at: expiresAt, return_path: returnPath });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"auth-complete.js","names":[],"sources":["../../../../src/server/hono/hubspot-connect-routes/auth-complete.ts"],"sourcesContent":["import type { Context } from 'hono';\n\nimport {\n AUTH_COMPLETE_CODE_PARAM,\n AUTH_COMPLETE_STATE_PARAM,\n} from '../../../shared/constants.ts';\nimport type { AuthCompleteResponse } from '../../../shared/wire-types.ts';\nimport {\n HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,\n HUBSPOT_APP_ORIGIN_COOKIE_NAME,\n HUBSPOT_REFRESH_COOKIE_PREFIX,\n TEMP_COOKIE_OAUTH_STATE,\n TEMP_COOKIE_PKCE_VERIFIER,\n} from '../../constants.ts';\nimport { base64urlDecode } from '../../utils/base64-utils.ts';\nimport { parseCookies } from '../../utils/cookie-utils.ts';\nimport { serializeCookie, setResponseCookie } from '../utils/cookie-utils.ts';\nimport { REFRESH_COOKIE_MAX_AGE_SEC } from './constants.ts';\nimport {\n buildClientAssertion,\n buildClientAssertionFormParams,\n buildClientSecretFormParams,\n buildTokenEndpointDpopProof,\n requestOAuthToken,\n} from './oauth-client.ts';\nimport type { HubSpotConnectOAuthRouteOptions } from './types.ts';\nimport {\n buildCimdClientIdUrlFromRequest,\n buildFrontendOAuthRedirectUri,\n clearTempCookie,\n isPositiveFiniteNumber,\n isSafeReturnPath,\n parseAppOriginHeader,\n} from './utils.ts';\nimport { fetchWhoami } from './whoami.ts';\n\ninterface OAuthStatePayload {\n return_path?: string;\n sid?: string;\n}\n\n/**\n * Cross-origin OAuth completion endpoint.\n *\n * Called from the React app on the frontend OAuth callback path\n * (`HUBSPOT_FRONTEND_CALLBACK_PATH`) once HubSpot has redirected the\n * browser back with `?code` + `?state`. The browser POSTs both\n * values here as a credentialed cross-site fetch — same partition as\n * `init-session`, so the temp PKCE/state cookies are visible — and\n * the SDK:\n *\n * 1. Validates `state` against the temp `__hs_oauth_state` cookie.\n * 2. Pulls the PKCE verifier from `__hs_pkce_verifier`.\n * 3. Rebuilds the same `redirect_uri` it sent to HubSpot during\n * `init-session` (frontend origin + the fixed callback path);\n * the OAuth token endpoint requires the two values to match.\n * 4. Exchanges `code` for an access + refresh token (with DPoP /\n * CIMD client-assertion when enabled).\n * 5. Sets the durable session cookies (access token, refresh) with\n * `SameSite=None; Secure; Partitioned` so they live in the\n * `(frontend, edge)` partition where subsequent API fetches will\n * read them.\n * 6. Clears the temp cookies.\n * 7. Returns `{ expires_at, return_path }` so the controller can\n * update its session-storage expiry tracking and navigate back to\n * the page the user started the connect flow from.\n */\nexport async function handleAuthComplete(\n c: Context,\n options: HubSpotConnectOAuthRouteOptions\n) {\n const { appKeys, refreshCookiePath, hubspotConnectEnv } = options;\n const xForwardedProto = c.req.header('x-forwarded-proto') ?? undefined;\n const xForwardedHost = c.req.header('x-forwarded-host') ?? undefined;\n const requestHostHeader = c.req.header('host') ?? undefined;\n const code = c.req.query(AUTH_COMPLETE_CODE_PARAM);\n const state = c.req.query(AUTH_COMPLETE_STATE_PARAM);\n\n if (!code || !state) {\n return c.json({ error: 'Missing code or state' }, 400);\n }\n\n if (hubspotConnectEnv.isAppPrivateKeyRequired && !appKeys) {\n return c.json(\n {\n error:\n 'Server misconfiguration: HUBSPOT_APP_PRIVATE_KEY is required when CIMD or DPoP is enabled',\n },\n 500\n );\n }\n\n const cookies = parseCookies(c.req.header('Cookie'));\n const expectedState = cookies[TEMP_COOKIE_OAUTH_STATE];\n const codeVerifier = cookies[TEMP_COOKIE_PKCE_VERIFIER];\n const appOriginCookie = cookies[HUBSPOT_APP_ORIGIN_COOKIE_NAME];\n\n if (!expectedState || state !== decodeURIComponent(expectedState)) {\n return c.json({ error: 'State mismatch' }, 403);\n }\n if (!codeVerifier) {\n return c.json({ error: 'Missing PKCE verifier' }, 400);\n }\n // The redirect_uri the OAuth token endpoint validates must equal\n // the one we sent during init-session. We rebuild it from the\n // pinned origin cookie so that value is anchored server-side, not\n // taken from the (caller-controlled) request `Origin` on this call.\n const appOrigin = parseAppOriginHeader(appOriginCookie);\n if (!appOrigin) {\n return c.json({ error: 'Missing app origin cookie' }, 400);\n }\n\n let statePayload: OAuthStatePayload;\n try {\n statePayload = JSON.parse(\n new TextDecoder().decode(base64urlDecode(decodeURIComponent(state)))\n ) as OAuthStatePayload;\n } catch {\n return c.json({ error: 'Malformed state value' }, 400);\n }\n const returnPath = statePayload.return_path;\n if (!returnPath || !isSafeReturnPath(returnPath)) {\n return c.json({ error: 'Invalid return path in state' }, 400);\n }\n\n const sessionId = statePayload.sid;\n if (!sessionId) {\n return c.json({ error: 'Missing app session cookie' }, 400);\n }\n\n const decodedCodeVerifier = decodeURIComponent(codeVerifier);\n\n const clientId = hubspotConnectEnv.isCimdEnabled\n ? buildCimdClientIdUrlFromRequest({\n requestUrl: c.req.url,\n basePath: options.basePath,\n xForwardedProto,\n xForwardedHost,\n requestHostHeader,\n appOrigin,\n })\n : hubspotConnectEnv.hubspotClientId;\n\n const redirectUri = buildFrontendOAuthRedirectUri(appOrigin);\n\n const tokenEndpointUrl = new URL(\n '/oauth/v1/token',\n hubspotConnectEnv.hubspotOAuthApiOrigin\n ).href;\n\n let dpopProof: string | undefined;\n if (hubspotConnectEnv.isDpopEnabled) {\n dpopProof = await buildTokenEndpointDpopProof({\n appKeys: appKeys!,\n tokenEndpointUrl,\n sessionIdHash: sessionId,\n });\n }\n\n let formParams: Record<string, string>;\n if (hubspotConnectEnv.isCimdEnabled) {\n const clientAssertion = await buildClientAssertion({\n appKeys: appKeys!,\n clientId,\n audience: tokenEndpointUrl,\n });\n formParams = {\n grant_type: 'authorization_code',\n code,\n code_verifier: decodedCodeVerifier,\n redirect_uri: redirectUri,\n ...buildClientAssertionFormParams({ clientId, clientAssertion }),\n };\n } else {\n formParams = {\n grant_type: 'authorization_code',\n code,\n code_verifier: decodedCodeVerifier,\n redirect_uri: redirectUri,\n ...buildClientSecretFormParams({\n clientId,\n clientSecret: hubspotConnectEnv.hubspotClientSecret,\n }),\n };\n }\n\n const tokenResult = await requestOAuthToken({\n tokenEndpointUrl,\n isDpopEnabled: hubspotConnectEnv.isDpopEnabled,\n ...(dpopProof !== undefined ? { dpopProof } : {}),\n formParams,\n });\n if (!tokenResult.ok) {\n return c.json(\n { error: `Token exchange failed: ${tokenResult.errorText}` },\n 502\n );\n }\n\n const {\n access_token: accessToken,\n refresh_token: refreshToken,\n expires_in,\n } = tokenResult.body;\n if (!refreshToken) {\n return c.json({ error: 'Token response missing refresh_token' }, 502);\n }\n if (!isPositiveFiniteNumber(expires_in)) {\n return c.json(\n { error: 'Token response missing or invalid expires_in' },\n 502\n );\n }\n\n const expiresAt = Date.now() + expires_in * 1000;\n const refreshCookieName = `${HUBSPOT_REFRESH_COOKIE_PREFIX}${sessionId}`;\n\n setResponseCookie({\n c,\n value: serializeCookie({\n name: HUBSPOT_ACCESS_TOKEN_COOKIE_NAME,\n value: accessToken,\n path: '/',\n sameSite: 'None',\n partitioned: true,\n maxAge: expires_in,\n }),\n });\n setResponseCookie({\n c,\n value: serializeCookie({\n name: refreshCookieName,\n value: refreshToken,\n path: refreshCookiePath,\n sameSite: 'None',\n partitioned: true,\n maxAge: REFRESH_COOKIE_MAX_AGE_SEC,\n }),\n });\n setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_PKCE_VERIFIER) });\n setResponseCookie({ c, value: clearTempCookie(TEMP_COOKIE_OAUTH_STATE) });\n\n const whoami = await fetchWhoami(accessToken, hubspotConnectEnv);\n return c.json({\n expires_at: expiresAt,\n return_path: returnPath,\n whoami,\n } satisfies AuthCompleteResponse);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHubSpotClient } from "../../api-client-core/client.js";
|
|
2
|
+
import { account } from "../../api-client-core/apis/account/account-info.generated.js";
|
|
3
|
+
import { authOauth } from "../../api-client-core/apis/auth/oauth.generated.js";
|
|
4
|
+
import { settingsUsers } from "../../api-client-core/apis/settings/user-provisioning.generated.js";
|
|
5
|
+
import { getHubSpotApiOrigin } from "../../utils/env-utils.js";
|
|
6
|
+
import { fetchTransportPlugin } from "../../api-client-core/plugins/fetch-transport.js";
|
|
7
|
+
//#region src/server/hono/hubspot-connect-routes/whoami.ts
|
|
8
|
+
async function fetchWhoami(accessToken, hubspotConnectEnv) {
|
|
9
|
+
const apiClient = createHubSpotClient({ plugins: [fetchTransportPlugin({
|
|
10
|
+
getEndpoint: getHubSpotApiOrigin,
|
|
11
|
+
getAccessToken: () => accessToken
|
|
12
|
+
})] });
|
|
13
|
+
const introspectInput = hubspotConnectEnv.isCimdEnabled ? { token: accessToken } : {
|
|
14
|
+
client_id: hubspotConnectEnv.hubspotClientId,
|
|
15
|
+
client_secret: hubspotConnectEnv.hubspotClientSecret,
|
|
16
|
+
token: accessToken
|
|
17
|
+
};
|
|
18
|
+
const [introspectResult, hubResult] = await Promise.allSettled([apiClient.send(authOauth.introspectToken(introspectInput)), apiClient.send(account.get())]);
|
|
19
|
+
const whoami = {
|
|
20
|
+
hub: {},
|
|
21
|
+
user: {}
|
|
22
|
+
};
|
|
23
|
+
if (hubResult.status === "fulfilled") {
|
|
24
|
+
const portal = hubResult.value;
|
|
25
|
+
whoami.hub.id = portal.portalId;
|
|
26
|
+
whoami.hub.uiDomain = portal.uiDomain;
|
|
27
|
+
}
|
|
28
|
+
if (introspectResult.status === "fulfilled" && introspectResult.value.token_use === "access_token") {
|
|
29
|
+
whoami.hub.domain = introspectResult.value.hub_domain;
|
|
30
|
+
const userId = String(introspectResult.value.user_id);
|
|
31
|
+
const userResult = await apiClient.send(settingsUsers.get({
|
|
32
|
+
userId,
|
|
33
|
+
idProperty: "USER_ID"
|
|
34
|
+
})).then((u) => ({
|
|
35
|
+
ok: true,
|
|
36
|
+
value: u
|
|
37
|
+
}), () => ({ ok: false }));
|
|
38
|
+
if (userResult.ok) {
|
|
39
|
+
const u = userResult.value;
|
|
40
|
+
whoami.user.id = u.id;
|
|
41
|
+
whoami.user.email = u.email;
|
|
42
|
+
whoami.user.firstName = u.firstName;
|
|
43
|
+
whoami.user.lastName = u.lastName;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return whoami;
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
export { fetchWhoami };
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=whoami.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"whoami.js","names":[],"sources":["../../../../src/server/hono/hubspot-connect-routes/whoami.ts"],"sourcesContent":["import type { AuthCompleteWhoami } from '../../../shared/wire-types.ts';\nimport {\n account,\n authOauth,\n createHubSpotClient,\n settingsUsers,\n} from '../../api-client-core/index.ts';\nimport { fetchTransportPlugin } from '../../api-client-core/plugins/fetch-transport.ts';\nimport { getHubSpotApiOrigin } from '../../utils/env-utils.ts';\nimport type { HubSpotConnectRoutesEnv } from './load-hubspot-connect-routes-env.ts';\n\nexport async function fetchWhoami(\n accessToken: string,\n hubspotConnectEnv: HubSpotConnectRoutesEnv\n): Promise<AuthCompleteWhoami> {\n const apiClient = createHubSpotClient({\n plugins: [\n fetchTransportPlugin({\n getEndpoint: getHubSpotApiOrigin,\n getAccessToken: () => accessToken,\n }),\n ],\n });\n\n const introspectInput = hubspotConnectEnv.isCimdEnabled\n ? { token: accessToken }\n : {\n client_id: hubspotConnectEnv.hubspotClientId,\n client_secret: hubspotConnectEnv.hubspotClientSecret,\n token: accessToken,\n };\n\n // Introspect and account.get are independent — run in parallel.\n // settingsUsers.get requires user_id from introspect, so it runs after.\n const [introspectResult, hubResult] = await Promise.allSettled([\n apiClient.send(authOauth.introspectToken(introspectInput)),\n apiClient.send(account.get()),\n ]);\n\n const whoami: AuthCompleteWhoami = {\n hub: {},\n user: {},\n };\n\n if (hubResult.status === 'fulfilled') {\n const portal = hubResult.value;\n whoami.hub.id = portal.portalId;\n whoami.hub.uiDomain = portal.uiDomain;\n }\n\n if (\n introspectResult.status === 'fulfilled' &&\n introspectResult.value.token_use === 'access_token'\n ) {\n whoami.hub.domain = introspectResult.value.hub_domain;\n\n const userId = String(introspectResult.value.user_id);\n const userResult = await apiClient\n .send(settingsUsers.get({ userId, idProperty: 'USER_ID' }))\n .then(\n (u) => ({ ok: true as const, value: u }),\n () => ({ ok: false as const })\n );\n if (userResult.ok) {\n const u = userResult.value;\n whoami.user.id = u.id;\n whoami.user.email = u.email;\n whoami.user.firstName = u.firstName;\n whoami.user.lastName = u.lastName;\n }\n }\n\n return whoami;\n}\n"],"mappings":";;;;;;;AAWA,eAAsB,YACpB,aACA,mBAC6B;CAC7B,MAAM,YAAY,oBAAoB,EACpC,SAAS,CACP,qBAAqB;EACnB,aAAa;EACb,sBAAsB;CACxB,CAAC,CACH,EACF,CAAC;CAED,MAAM,kBAAkB,kBAAkB,gBACtC,EAAE,OAAO,YAAY,IACrB;EACE,WAAW,kBAAkB;EAC7B,eAAe,kBAAkB;EACjC,OAAO;CACT;CAIJ,MAAM,CAAC,kBAAkB,aAAa,MAAM,QAAQ,WAAW,CAC7D,UAAU,KAAK,UAAU,gBAAgB,eAAe,CAAC,GACzD,UAAU,KAAK,QAAQ,IAAI,CAAC,CAC9B,CAAC;CAED,MAAM,SAA6B;EACjC,KAAK,CAAC;EACN,MAAM,CAAC;CACT;CAEA,IAAI,UAAU,WAAW,aAAa;EACpC,MAAM,SAAS,UAAU;EACzB,OAAO,IAAI,KAAK,OAAO;EACvB,OAAO,IAAI,WAAW,OAAO;CAC/B;CAEA,IACE,iBAAiB,WAAW,eAC5B,iBAAiB,MAAM,cAAc,gBACrC;EACA,OAAO,IAAI,SAAS,iBAAiB,MAAM;EAE3C,MAAM,SAAS,OAAO,iBAAiB,MAAM,OAAO;EACpD,MAAM,aAAa,MAAM,UACtB,KAAK,cAAc,IAAI;GAAE;GAAQ,YAAY;EAAU,CAAC,CAAC,EACzD,MACE,OAAO;GAAE,IAAI;GAAe,OAAO;EAAE,WAC/B,EAAE,IAAI,MAAe,EAC9B;EACF,IAAI,WAAW,IAAI;GACjB,MAAM,IAAI,WAAW;GACrB,OAAO,KAAK,KAAK,EAAE;GACnB,OAAO,KAAK,QAAQ,EAAE;GACtB,OAAO,KAAK,YAAY,EAAE;GAC1B,OAAO,KAAK,WAAW,EAAE;EAC3B;CACF;CAEA,OAAO;AACT"}
|
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.21",
|
|
4
4
|
"description": "HubSpot App Connect SDK (alpha release). Documentation and integration guidance forthcoming.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"typescript": "6.0.3",
|
|
46
46
|
"vitest": "4.1.6",
|
|
47
47
|
"@private/eslint-config": "0.1.0",
|
|
48
|
-
"@private/
|
|
49
|
-
"@private/
|
|
48
|
+
"@private/tsconfig": "0.1.0",
|
|
49
|
+
"@private/prettier-config": "0.1.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"clean": "rm -rf dist build *.tsbuildinfo node_modules .turbo",
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export { EXPIRES_AT_URL_PARAM } from '../../shared/constants.ts';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Key the controller persists the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Key under which the controller persists the entire session blob
|
|
5
|
+
* (expiresAt + whoami) as a single JSON string in `sessionStorage`.
|
|
6
|
+
* Using one key makes logout trivially correct — one `removeItem` call
|
|
7
|
+
* clears all session state. Survives full-page navigations within the
|
|
8
|
+
* same tab.
|
|
7
9
|
*/
|
|
8
|
-
export const
|
|
10
|
+
export const SESSION_STORAGE_KEY = 'hubspot_connect_session';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Number of milliseconds before `expiresAt` that the refresh
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
} from './types.ts';
|
|
17
17
|
import { memoizeLast } from './utils/memoize-utils.ts';
|
|
18
18
|
import {
|
|
19
|
-
|
|
19
|
+
getSessionFromSessionStorage,
|
|
20
20
|
isClientSessionActive,
|
|
21
21
|
} from './utils/session-utils.ts';
|
|
22
22
|
import { createStore } from './utils/store-utils.ts';
|
|
@@ -54,6 +54,7 @@ export function createAppConnectController(
|
|
|
54
54
|
isSessionConnected: false,
|
|
55
55
|
error: null,
|
|
56
56
|
expiresAt: null,
|
|
57
|
+
whoami: null,
|
|
57
58
|
});
|
|
58
59
|
const context: AppConnectContext = {
|
|
59
60
|
config,
|
|
@@ -62,7 +63,11 @@ export function createAppConnectController(
|
|
|
62
63
|
store,
|
|
63
64
|
};
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
const storedSession = getSessionFromSessionStorage(context);
|
|
67
|
+
store.setState({
|
|
68
|
+
expiresAt: storedSession?.expiresAt ?? null,
|
|
69
|
+
whoami: storedSession?.whoami ?? null,
|
|
70
|
+
});
|
|
66
71
|
|
|
67
72
|
let hasStarted = false;
|
|
68
73
|
|
|
@@ -90,6 +95,7 @@ export function createAppConnectController(
|
|
|
90
95
|
>((storeState) => ({
|
|
91
96
|
status: getDerivedStatus(storeState),
|
|
92
97
|
error: storeState.error,
|
|
98
|
+
whoami: storeState.whoami,
|
|
93
99
|
connectToHubSpot,
|
|
94
100
|
disconnectFromHubSpot,
|
|
95
101
|
}));
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
OAUTH_POPUP_CALLBACK_MESSAGE_TYPE,
|
|
6
6
|
} from '../../shared/constants.ts';
|
|
7
7
|
import { noopLogger } from '../../shared/logger.ts';
|
|
8
|
-
import {
|
|
8
|
+
import { SESSION_STORAGE_KEY } from './constants.ts';
|
|
9
9
|
import { initAppConnect } from './init.ts';
|
|
10
10
|
import { OAUTH_POPUP_WINDOW_NAME } from './oauth-popup.ts';
|
|
11
11
|
import type {
|
|
@@ -39,6 +39,7 @@ function createTestContext(): AppConnectContext {
|
|
|
39
39
|
isDisconnectInFlight: false,
|
|
40
40
|
error: null,
|
|
41
41
|
expiresAt: null,
|
|
42
|
+
whoami: null,
|
|
42
43
|
};
|
|
43
44
|
return {
|
|
44
45
|
config: { hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL },
|
|
@@ -124,14 +125,16 @@ describe('initAppConnect', () => {
|
|
|
124
125
|
installFakeWindow({
|
|
125
126
|
href: `https://app.example.com${OAUTH_CALLBACK_PATH}?code=auth-code&state=auth-state`,
|
|
126
127
|
});
|
|
127
|
-
const fetchSpy = vi
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
)
|
|
134
|
-
|
|
128
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
129
|
+
new Response(
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
expires_at: expiresAt,
|
|
132
|
+
return_path: '/dashboard',
|
|
133
|
+
whoami: {},
|
|
134
|
+
}),
|
|
135
|
+
{ status: 200 }
|
|
136
|
+
)
|
|
137
|
+
);
|
|
135
138
|
const context = createTestContext();
|
|
136
139
|
|
|
137
140
|
await initAppConnect(context);
|
|
@@ -148,9 +151,10 @@ describe('initAppConnect', () => {
|
|
|
148
151
|
expect((calledInit as RequestInit).credentials).toBe('include');
|
|
149
152
|
|
|
150
153
|
expect(context.store.getSnapshot().expiresAt).toBe(expiresAt);
|
|
151
|
-
|
|
152
|
-
|
|
154
|
+
const stored = JSON.parse(
|
|
155
|
+
context.sessionStorage.getItem(SESSION_STORAGE_KEY)!
|
|
153
156
|
);
|
|
157
|
+
expect(stored.expiresAt).toBe(expiresAt);
|
|
154
158
|
expect(window.location.pathname).toBe('/dashboard');
|
|
155
159
|
});
|
|
156
160
|
|
|
@@ -165,10 +169,13 @@ describe('initAppConnect', () => {
|
|
|
165
169
|
})
|
|
166
170
|
);
|
|
167
171
|
const context = createTestContext();
|
|
168
|
-
context.sessionStorage.setItem(
|
|
172
|
+
context.sessionStorage.setItem(
|
|
173
|
+
SESSION_STORAGE_KEY,
|
|
174
|
+
'{"expiresAt":999,"whoami":null}'
|
|
175
|
+
);
|
|
169
176
|
|
|
170
177
|
await expect(initAppConnect(context)).rejects.toThrow(/Failed to complete/);
|
|
171
|
-
expect(context.sessionStorage.getItem(
|
|
178
|
+
expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
|
172
179
|
});
|
|
173
180
|
|
|
174
181
|
it('relays code and state to the opener without calling auth/complete when in an OAuth popup', async () => {
|
|
@@ -208,7 +215,11 @@ describe('initAppConnect', () => {
|
|
|
208
215
|
});
|
|
209
216
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
210
217
|
new Response(
|
|
211
|
-
JSON.stringify({
|
|
218
|
+
JSON.stringify({
|
|
219
|
+
expires_at: expiresAt,
|
|
220
|
+
return_path: '/dashboard',
|
|
221
|
+
whoami: {},
|
|
222
|
+
}),
|
|
212
223
|
{ status: 200 }
|
|
213
224
|
)
|
|
214
225
|
);
|
|
@@ -232,7 +243,11 @@ describe('initAppConnect', () => {
|
|
|
232
243
|
});
|
|
233
244
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
234
245
|
new Response(
|
|
235
|
-
JSON.stringify({
|
|
246
|
+
JSON.stringify({
|
|
247
|
+
expires_at: expiresAt,
|
|
248
|
+
return_path: '/dashboard',
|
|
249
|
+
whoami: {},
|
|
250
|
+
}),
|
|
236
251
|
{ status: 200 }
|
|
237
252
|
)
|
|
238
253
|
);
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
relayOAuthCallbackToOpener,
|
|
10
10
|
} from './oauth-popup.ts';
|
|
11
11
|
import type { AppConnectContext } from './types.ts';
|
|
12
|
-
import {
|
|
12
|
+
import { storeSession } from './utils/session-utils.ts';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* On `controller.start()`:
|
|
@@ -49,7 +49,7 @@ async function consumeOAuthCallback(context: AppConnectContext): Promise<void> {
|
|
|
49
49
|
const body = await completeHubSpotOAuthSession(context, { code, state });
|
|
50
50
|
const { expires_at: expiresAt, return_path: returnPath } = body;
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
storeSession({ context, expiresAtMs: expiresAt, whoami: body.whoami });
|
|
53
53
|
|
|
54
54
|
const targetUrl = new URL(returnPath, window.location.origin);
|
|
55
55
|
history.replaceState(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import { noopLogger } from '../../shared/logger.ts';
|
|
4
|
-
import {
|
|
4
|
+
import { SESSION_STORAGE_KEY } from './constants.ts';
|
|
5
5
|
import { completeHubSpotOAuthSession } from './oauth-complete.ts';
|
|
6
6
|
import type {
|
|
7
7
|
AppConnectContext,
|
|
@@ -34,6 +34,7 @@ function createTestContext(): AppConnectContext {
|
|
|
34
34
|
isDisconnectInFlight: false,
|
|
35
35
|
error: null,
|
|
36
36
|
expiresAt: null,
|
|
37
|
+
whoami: null,
|
|
37
38
|
};
|
|
38
39
|
return {
|
|
39
40
|
config: { hubSpotConnectBaseUrl: HUBSPOT_CONNECT_BASE_URL },
|
|
@@ -93,7 +94,10 @@ describe('completeHubSpotOAuthSession', () => {
|
|
|
93
94
|
})
|
|
94
95
|
);
|
|
95
96
|
const context = createTestContext();
|
|
96
|
-
context.sessionStorage.setItem(
|
|
97
|
+
context.sessionStorage.setItem(
|
|
98
|
+
SESSION_STORAGE_KEY,
|
|
99
|
+
'{"expiresAt":999,"whoami":null}'
|
|
100
|
+
);
|
|
97
101
|
|
|
98
102
|
await expect(
|
|
99
103
|
completeHubSpotOAuthSession(context, {
|
|
@@ -101,6 +105,6 @@ describe('completeHubSpotOAuthSession', () => {
|
|
|
101
105
|
state: 'bad-state',
|
|
102
106
|
})
|
|
103
107
|
).rejects.toThrow(/Failed to complete/);
|
|
104
|
-
expect(context.sessionStorage.getItem(
|
|
108
|
+
expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
|
105
109
|
});
|
|
106
110
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthCompleteWhoami } from '../../shared/wire-types.ts';
|
|
1
2
|
import type { Logger } from '../index.ts';
|
|
2
3
|
import type { AppConnectBrowserConfig } from '../types.ts';
|
|
3
4
|
import type { Store } from './utils/store-utils.ts';
|
|
@@ -30,6 +31,8 @@ export interface AppConnectInternalState {
|
|
|
30
31
|
error: string | null;
|
|
31
32
|
/** Access-token expiry (Unix epoch milliseconds), or `null` if unknown. */
|
|
32
33
|
expiresAt: number | null;
|
|
34
|
+
/** Identity of the connected user and their HubSpot account, or `null`. */
|
|
35
|
+
whoami: AuthCompleteWhoami | null;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** Convenience alias for the typed store used by the controller. */
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import { noopLogger } from '../../../shared/logger.ts';
|
|
4
|
-
import {
|
|
4
|
+
import type { AuthCompleteWhoami } from '../../../shared/wire-types.ts';
|
|
5
|
+
import { SESSION_STORAGE_KEY } from '../constants.ts';
|
|
5
6
|
import type {
|
|
6
7
|
AppConnectContext,
|
|
7
8
|
AppConnectInternalState,
|
|
@@ -9,9 +10,10 @@ import type {
|
|
|
9
10
|
} from '../types.ts';
|
|
10
11
|
import {
|
|
11
12
|
clearSessionStorage,
|
|
12
|
-
|
|
13
|
+
getSessionFromSessionStorage,
|
|
13
14
|
isClientSessionActive,
|
|
14
15
|
storeExpiresAt,
|
|
16
|
+
storeSession,
|
|
15
17
|
} from './session-utils.ts';
|
|
16
18
|
import { createStore } from './store-utils.ts';
|
|
17
19
|
|
|
@@ -36,6 +38,7 @@ function createTestContext(): AppConnectContext {
|
|
|
36
38
|
isDisconnectInFlight: false,
|
|
37
39
|
error: null,
|
|
38
40
|
expiresAt: null,
|
|
41
|
+
whoami: null,
|
|
39
42
|
};
|
|
40
43
|
return {
|
|
41
44
|
config: { hubSpotConnectBaseUrl: '/functions/v1/hubspot-connect' },
|
|
@@ -45,6 +48,14 @@ function createTestContext(): AppConnectContext {
|
|
|
45
48
|
};
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
const MOCK_WHOAMI: AuthCompleteWhoami = {
|
|
52
|
+
user: { id: '99', email: 'demo@example.com', firstName: 'Demo' },
|
|
53
|
+
hub: {
|
|
54
|
+
id: 42,
|
|
55
|
+
uiDomain: 'app.hubspot.com',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
48
59
|
describe('session-utils', () => {
|
|
49
60
|
const FIXED_NOW = 1_700_000_000_000;
|
|
50
61
|
|
|
@@ -57,52 +68,92 @@ describe('session-utils', () => {
|
|
|
57
68
|
vi.useRealTimers();
|
|
58
69
|
});
|
|
59
70
|
|
|
71
|
+
describe('storeSession', () => {
|
|
72
|
+
it('writes expiresAt and whoami to both store and session storage', () => {
|
|
73
|
+
const context = createTestContext();
|
|
74
|
+
storeSession({
|
|
75
|
+
context,
|
|
76
|
+
expiresAtMs: FIXED_NOW + 60_000,
|
|
77
|
+
whoami: MOCK_WHOAMI,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(context.store.getSnapshot().expiresAt).toBe(FIXED_NOW + 60_000);
|
|
81
|
+
expect(context.store.getSnapshot().whoami).toEqual(MOCK_WHOAMI);
|
|
82
|
+
const stored = JSON.parse(
|
|
83
|
+
context.sessionStorage.getItem(SESSION_STORAGE_KEY)!
|
|
84
|
+
);
|
|
85
|
+
expect(stored.expiresAt).toBe(FIXED_NOW + 60_000);
|
|
86
|
+
expect(stored.whoami).toEqual(MOCK_WHOAMI);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
60
90
|
describe('storeExpiresAt', () => {
|
|
61
|
-
it('
|
|
91
|
+
it('updates expiresAt in store and storage while preserving existing whoami', () => {
|
|
62
92
|
const context = createTestContext();
|
|
93
|
+
context.store.setState({ whoami: MOCK_WHOAMI });
|
|
94
|
+
|
|
63
95
|
storeExpiresAt({ context, expiresAtMs: FIXED_NOW + 60_000 });
|
|
64
96
|
|
|
65
97
|
expect(context.store.getSnapshot().expiresAt).toBe(FIXED_NOW + 60_000);
|
|
66
|
-
expect(context.
|
|
67
|
-
|
|
98
|
+
expect(context.store.getSnapshot().whoami).toEqual(MOCK_WHOAMI);
|
|
99
|
+
const stored = JSON.parse(
|
|
100
|
+
context.sessionStorage.getItem(SESSION_STORAGE_KEY)!
|
|
68
101
|
);
|
|
102
|
+
expect(stored.expiresAt).toBe(FIXED_NOW + 60_000);
|
|
103
|
+
expect(stored.whoami).toEqual(MOCK_WHOAMI);
|
|
69
104
|
});
|
|
70
105
|
});
|
|
71
106
|
|
|
72
|
-
describe('
|
|
107
|
+
describe('getSessionFromSessionStorage', () => {
|
|
73
108
|
it('returns null when nothing is stored', () => {
|
|
74
109
|
const context = createTestContext();
|
|
75
|
-
expect(
|
|
110
|
+
expect(getSessionFromSessionStorage(context)).toBeNull();
|
|
76
111
|
});
|
|
77
112
|
|
|
78
|
-
it('returns the stored
|
|
113
|
+
it('returns the stored session when expiresAt is in the future', () => {
|
|
79
114
|
const context = createTestContext();
|
|
80
|
-
const
|
|
81
|
-
context.sessionStorage.setItem(
|
|
82
|
-
|
|
115
|
+
const session = { expiresAt: FIXED_NOW + 60_000, whoami: MOCK_WHOAMI };
|
|
116
|
+
context.sessionStorage.setItem(
|
|
117
|
+
SESSION_STORAGE_KEY,
|
|
118
|
+
JSON.stringify(session)
|
|
119
|
+
);
|
|
120
|
+
expect(getSessionFromSessionStorage(context)).toEqual(session);
|
|
83
121
|
});
|
|
84
122
|
|
|
85
|
-
it('clears and returns null on malformed
|
|
123
|
+
it('clears and returns null on malformed JSON', () => {
|
|
86
124
|
const context = createTestContext();
|
|
87
|
-
context.sessionStorage.setItem(
|
|
88
|
-
expect(
|
|
89
|
-
expect(context.sessionStorage.getItem(
|
|
125
|
+
context.sessionStorage.setItem(SESSION_STORAGE_KEY, 'not-json');
|
|
126
|
+
expect(getSessionFromSessionStorage(context)).toBeNull();
|
|
127
|
+
expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
|
90
128
|
});
|
|
91
129
|
|
|
92
|
-
it('clears and returns null
|
|
130
|
+
it('clears and returns null when expiresAt is not a number', () => {
|
|
93
131
|
const context = createTestContext();
|
|
94
|
-
context.sessionStorage.setItem(
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
context.sessionStorage.setItem(
|
|
133
|
+
SESSION_STORAGE_KEY,
|
|
134
|
+
JSON.stringify({ expiresAt: 'bad', whoami: null })
|
|
135
|
+
);
|
|
136
|
+
expect(getSessionFromSessionStorage(context)).toBeNull();
|
|
137
|
+
expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('clears and returns null when the session is already expired', () => {
|
|
141
|
+
const context = createTestContext();
|
|
142
|
+
context.sessionStorage.setItem(
|
|
143
|
+
SESSION_STORAGE_KEY,
|
|
144
|
+
JSON.stringify({ expiresAt: FIXED_NOW - 1, whoami: null })
|
|
145
|
+
);
|
|
146
|
+
expect(getSessionFromSessionStorage(context)).toBeNull();
|
|
147
|
+
expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
|
97
148
|
});
|
|
98
149
|
});
|
|
99
150
|
|
|
100
151
|
describe('clearSessionStorage', () => {
|
|
101
|
-
it('removes the
|
|
152
|
+
it('removes the session storage key', () => {
|
|
102
153
|
const context = createTestContext();
|
|
103
|
-
context.sessionStorage.setItem(
|
|
154
|
+
context.sessionStorage.setItem(SESSION_STORAGE_KEY, '{}');
|
|
104
155
|
clearSessionStorage(context);
|
|
105
|
-
expect(context.sessionStorage.getItem(
|
|
156
|
+
expect(context.sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
|
106
157
|
});
|
|
107
158
|
});
|
|
108
159
|
|