import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { z } from 'zod'; import { decryptSecret, encryptSecret } from '../../lib/secrets'; import { HUBSPOT_REQUIRED_SCOPES } from './contracts'; const HUBSPOT_AUTHORIZE_URL = 'https://app.hubspot.com/oauth/authorize'; const HUBSPOT_TOKEN_URL = 'https://api.hubapi.com/oauth/v3/token'; const TOKEN_REFRESH_SKEW_MS = 60_000; const tokenResponseSchema = z.object({ access_token: z.string().min(1), refresh_token: z.string().min(1), expires_in: z.number().int().positive(), hub_id: z.union([z.string().min(1), z.number().int().nonnegative()]).transform(String), scopes: z.array(z.string()), }).passthrough(); export interface HubSpotOAuthConfig { clientId: string; clientSecret: string; redirectUri: string; } export interface HubSpotTokenResponse { accessToken: string; refreshToken: string; expiresInSeconds: number; portalId: string; scopes: string[]; } export class HubSpotOAuthError extends Error { constructor( message: string, readonly status?: number, ) { super(message); this.name = 'HubSpotOAuthError'; } } export function buildHubSpotAuthorizationUrl( config: Pick, state: string, ): string { const url = new URL(HUBSPOT_AUTHORIZE_URL); url.searchParams.set('client_id', config.clientId); url.searchParams.set('redirect_uri', config.redirectUri); url.searchParams.set('scope', HUBSPOT_REQUIRED_SCOPES.join(' ')); url.searchParams.set('state', state); return url.toString(); } export class HubSpotOAuthClient { constructor( private readonly config: HubSpotOAuthConfig, private readonly fetchImpl: typeof fetch = fetch, ) {} authorizationUrl(state: string): string { return buildHubSpotAuthorizationUrl(this.config, state); } exchangeAuthorizationCode(code: string, signal?: AbortSignal): Promise { return this.tokenRequest({ grant_type: 'authorization_code', client_id: this.config.clientId, client_secret: this.config.clientSecret, redirect_uri: this.config.redirectUri, code, }, signal); } refreshAccessToken(refreshToken: string, signal?: AbortSignal): Promise { return this.tokenRequest({ grant_type: 'refresh_token', client_id: this.config.clientId, client_secret: this.config.clientSecret, redirect_uri: this.config.redirectUri, refresh_token: refreshToken, }, signal); } private async tokenRequest( form: Record, signal?: AbortSignal, ): Promise { const response = await this.fetchImpl(HUBSPOT_TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form), signal, }); if (!response.ok) { throw new HubSpotOAuthError('HubSpot rejected the OAuth token request.', response.status); } const parsed = tokenResponseSchema.safeParse(await response.json()); if (!parsed.success) throw new HubSpotOAuthError('HubSpot returned an invalid OAuth token response.'); return { accessToken: parsed.data.access_token, refreshToken: parsed.data.refresh_token, expiresInSeconds: parsed.data.expires_in, portalId: parsed.data.hub_id, scopes: parsed.data.scopes, }; } } type TokenKind = 'access' | 'refresh'; function tokenPurpose(connectionId: string, kind: TokenKind): string { return `hubspot:${connectionId}:${kind}-token`; } export class HubSpotTokenVault { constructor(private readonly encryptionKey: string | undefined) {} encrypt(connectionId: string, kind: TokenKind, token: string): string { return encryptSecret(token, this.encryptionKey, tokenPurpose(connectionId, kind)); } decrypt(connectionId: string, kind: TokenKind, envelope: string): string { return decryptSecret(envelope, this.encryptionKey, tokenPurpose(connectionId, kind)); } } export interface LockedHubSpotCredential { id: string; status: 'active' | 'reauthorization_required' | 'disconnected' | 'error'; encryptedAccessToken: string; encryptedRefreshToken: string; accessTokenExpiresAt: Date; updateTokens(input: { encryptedAccessToken: string; encryptedRefreshToken: string; accessTokenExpiresAt: Date; grantedScopes: readonly string[]; refreshedAt: Date; }): Promise; } export interface HubSpotCredentialLockStore { /** The adapter must hold one row/advisory lock through the callback and update. */ withConnectionLock( connectionId: string, operation: (credential: LockedHubSpotCredential) => Promise, ): Promise; } export class HubSpotTokenManager { constructor( private readonly store: HubSpotCredentialLockStore, private readonly oauth: Pick, private readonly vault: HubSpotTokenVault, private readonly now: () => Date = () => new Date(), ) {} getAccessToken(connectionId: string, signal?: AbortSignal): Promise { return this.store.withConnectionLock(connectionId, async (credential) => { if (credential.status !== 'active') { throw new HubSpotOAuthError('The HubSpot connection is not active.'); } const now = this.now(); if (credential.accessTokenExpiresAt.getTime() > now.getTime() + TOKEN_REFRESH_SKEW_MS) { return this.vault.decrypt(connectionId, 'access', credential.encryptedAccessToken); } const refreshToken = this.vault.decrypt( connectionId, 'refresh', credential.encryptedRefreshToken, ); const refreshed = await this.oauth.refreshAccessToken(refreshToken, signal); const missingScope = HUBSPOT_REQUIRED_SCOPES.find((scope) => !refreshed.scopes.includes(scope)); if (missingScope) throw new HubSpotOAuthError(`HubSpot did not grant required scope ${missingScope}.`); const expiresAt = new Date(now.getTime() + refreshed.expiresInSeconds * 1_000); await credential.updateTokens({ encryptedAccessToken: this.vault.encrypt(connectionId, 'access', refreshed.accessToken), encryptedRefreshToken: this.vault.encrypt(connectionId, 'refresh', refreshed.refreshToken), accessTokenExpiresAt: expiresAt, grantedScopes: refreshed.scopes, refreshedAt: now, }); return refreshed.accessToken; }); } } export interface StoredHubSpotOAuthState { requestedByUserId: string; returnPath: string; } export interface HubSpotConnectionInstallStore { createOAuthState(input: { nonceHash: string; requestedByUserId: string; returnPath: string; expiresAt: Date; }): Promise; consumeOAuthState(nonceHash: string, now: Date): Promise; reserveConnectionId(portalId: string, proposedId: string): Promise; saveConnection(input: { id: string; portalId: string; encryptedAccessToken: string; encryptedRefreshToken: string; accessTokenExpiresAt: Date; grantedScopes: readonly string[]; connectedByUserId: string; installedAt: Date; }): Promise; } const OAUTH_STATE_TTL_MS = 10 * 60 * 1_000; const DEFAULT_RETURN_PATH = '/settings/integrations/hubspot'; export class HubSpotConnectionService { constructor( private readonly store: HubSpotConnectionInstallStore, private readonly oauth: Pick, private readonly vault: HubSpotTokenVault, private readonly now: () => Date = () => new Date(), ) {} async begin(requestedByUserId: string): Promise<{ authorizationUrl: string }> { const state = randomBytes(32).toString('base64url'); const now = this.now(); await this.store.createOAuthState({ nonceHash: hashOAuthState(state), requestedByUserId, returnPath: DEFAULT_RETURN_PATH, expiresAt: new Date(now.getTime() + OAUTH_STATE_TTL_MS), }); return { authorizationUrl: this.oauth.authorizationUrl(state) }; } async complete(code: string, state: string, signal?: AbortSignal): Promise<{ returnPath: string }> { const now = this.now(); const storedState = await this.store.consumeOAuthState(hashOAuthState(state), now); if (!storedState) throw new HubSpotOAuthError('The HubSpot OAuth state is invalid or expired.'); const tokens = await this.oauth.exchangeAuthorizationCode(code, signal); const missingScope = HUBSPOT_REQUIRED_SCOPES.find((scope) => !tokens.scopes.includes(scope)); if (missingScope) throw new HubSpotOAuthError(`HubSpot did not grant required scope ${missingScope}.`); const connectionId = await this.store.reserveConnectionId(tokens.portalId, randomUUID()); await this.store.saveConnection({ id: connectionId, portalId: tokens.portalId, encryptedAccessToken: this.vault.encrypt(connectionId, 'access', tokens.accessToken), encryptedRefreshToken: this.vault.encrypt(connectionId, 'refresh', tokens.refreshToken), accessTokenExpiresAt: new Date(now.getTime() + tokens.expiresInSeconds * 1_000), grantedScopes: tokens.scopes, connectedByUserId: storedState.requestedByUserId, installedAt: now, }); return { returnPath: storedState.returnPath }; } } export function hashOAuthState(state: string): string { return createHash('sha256').update(state, 'utf8').digest('hex'); }