From 2ed9ff6cec4796d115ca6b59d2d1f59fe24f2470 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 18 Oct 2026 21:09:10 -0700 Subject: [PATCH 1/2] feat(greenhouse): connect Harvest v3 with a customer-owned API credential - Save a Greenhouse Harvest V3 (OAuth) client ID and secret as a reusable credential; Sim mints access tokens with the client credentials grant - The Greenhouse block offers saved credentials alongside partner OAuth, so it works on deployments without partner OAuth credentials - Document the Dev Center setup --- .../content/docs/integrations/greenhouse.mdx | 6 +- apps/sim/blocks/blocks/greenhouse.ts | 1 + .../client-credential-accounts/descriptors.ts | 22 +++++ .../minters/greenhouse.ts | 89 +++++++++++++++++++ .../client-credential-accounts/server.ts | 3 + .../integrations/credential-display.test.ts | 1 + apps/sim/lib/oauth/oauth.ts | 1 + apps/sim/lib/oauth/token-resolution.test.ts | 1 + .../service-account-providers.generated.ts | 1 + 9 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/greenhouse.ts diff --git a/apps/docs/content/docs/integrations/greenhouse.mdx b/apps/docs/content/docs/integrations/greenhouse.mdx index 93d259a2f84..b92c247c45d 100644 --- a/apps/docs/content/docs/integrations/greenhouse.mdx +++ b/apps/docs/content/docs/integrations/greenhouse.mdx @@ -15,11 +15,13 @@ Use [Greenhouse](https://www.greenhouse.com/) to manage recruiting through Harve ### Connect a Greenhouse account -The current block uses [Greenhouse partner OAuth](https://harvestdocs.greenhouse.io/docs/harvest-partner-oauth). Deployment administrators must obtain partner-issued OAuth credentials, register the deployment's Greenhouse OAuth callback URL, and have Greenhouse enable the scopes requested by Sim. This requires Greenhouse partner onboarding; a legacy Harvest API key cannot authenticate the new block. Select a Greenhouse account in the block and complete consent with a Site Admin, which Greenhouse requires for list endpoints. A connection that has not refreshed within the provider's 14-day refresh-token lifetime must reconnect. +Save your own Greenhouse API credential, then select it in the block. In Greenhouse, open **Dev Center > API Credentials**, create a **Harvest V3 (OAuth)** credential, and copy its client ID and client secret. On the credential's settings, enable the Harvest scopes for the operations your workflows use; the scopes listed below cover every operation. Sim obtains access tokens with the [client credentials grant](https://harvestdocs.greenhouse.io/docs/authentication) and gets a new one when it expires. Requests act as the integration service user Greenhouse creates with the credential. You do not need a redirect URI or a Sim-owned OAuth application, and a legacy Harvest API key cannot authenticate the new block. + +Deployments that have [Greenhouse partner OAuth](https://harvestdocs.greenhouse.io/docs/harvest-partner-oauth) credentials can also connect an account through consent. Complete consent with a Site Admin, which Greenhouse requires for list endpoints. A partner connection that has not refreshed within the provider's 14-day refresh-token lifetime must reconnect. ### Upgrade existing workflows -The original API-key block remains as **Greenhouse (Legacy)** so existing workflows keep their saved configuration. New blocks use Harvest v3. Upgrade an existing block explicitly, connect an OAuth account, choose its operation again, and update input and output references before running it. The legacy integration is subject to Greenhouse's v1 availability; preserving a saved block does not extend provider support. +The original API-key block remains as **Greenhouse (Legacy)** so existing workflows keep their saved configuration. New blocks use Harvest v3. Upgrade an existing block explicitly, select a Greenhouse credential, choose its operation again, and update input and output references before running it. The legacy integration is subject to Greenhouse's v1 availability; preserving a saved block does not extend provider support. Harvest v3 returns applications, attachments, education, and employment through separate resources. Use their list operations and candidate or application filters to assemble a complete record. Single-record reads use the provider's `ids` filter. Do not assume the old embedded response structure survives the upgrade. diff --git a/apps/sim/blocks/blocks/greenhouse.ts b/apps/sim/blocks/blocks/greenhouse.ts index 582e93139f1..cf4ba4c1fd3 100644 --- a/apps/sim/blocks/blocks/greenhouse.ts +++ b/apps/sim/blocks/blocks/greenhouse.ts @@ -695,6 +695,7 @@ export const GreenhouseV2Block: BlockConfig = { type: 'oauth-input', canonicalParamId: 'oauthCredential', serviceId: 'greenhouse', + credentialKind: 'any', requiredScopes: getScopesForService('greenhouse'), placeholder: 'Select Greenhouse account', required: true, diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index ada84857fdf..cbd87d2a0b7 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -119,6 +119,7 @@ export const FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID = 'fivetran-service-account' a export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const export const ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-epm-service-account' as const export const ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-fusion-service-account' as const +export const GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID = 'greenhouse-service-account' as const /** Permissions requested when connecting a Manage Vanta application. */ export const VANTA_PERMISSION_OPTIONS = [ @@ -141,6 +142,7 @@ export type ClientCredentialAccountProviderId = | typeof FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID | typeof ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID | typeof ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID + | typeof GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -400,6 +402,26 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/ramp', }, + [GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Greenhouse', + connectNoun: 'API credential', + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + secret: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/greenhouse', + }, [VANTA_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: VANTA_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Vanta', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/greenhouse.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/greenhouse.ts new file mode 100644 index 00000000000..e2bedde4728 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/greenhouse.ts @@ -0,0 +1,89 @@ +import { toRecord } from '@sim/utils/object' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { + fetchProvider, + requireClientSecret, + TokenServiceAccountValidationError, + throwForProviderResponse, +} from '@/lib/credentials/token-service-accounts/errors' +import { GREENHOUSE_TOKEN_URL } from '@/lib/oauth/greenhouse' + +const STEP = 'greenhouse_token_mint' +const MAX_GREENHOUSE_TOKEN_RESPONSE_BYTES = 64 * 1024 + +/** + * Obtains a Harvest v3 access token for a customer's own Greenhouse API credential through the + * client-credentials grant. The token acts as the credential's integration service user with the + * scopes configured on the credential in Greenhouse, so the grant requests none. Greenhouse + * documents no identity endpoint for this grant; a successful mint is the connect-time check. + * + * @see https://harvestdocs.greenhouse.io/docs/authentication + */ +export async function mintGreenhouseServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const clientSecret = requireClientSecret(fields.clientSecret, STEP, 'Greenhouse') + const response = await fetchProvider( + GREENHOUSE_TOKEN_URL, + { + method: 'POST', + redirect: 'error', + signal: options?.signal, + headers: { + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${fields.clientId}:${clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ grant_type: 'client_credentials' }).toString(), + }, + STEP + ) + await throwForProviderResponse(response, STEP) + + let token: Record + try { + token = toRecord( + await readResponseJsonWithLimit(response, { + maxBytes: MAX_GREENHOUSE_TOKEN_RESPONSE_BYTES, + label: 'Greenhouse token response', + signal: options?.signal, + }) + ) + } catch { + options?.signal?.throwIfAborted() + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: 'provider returned an invalid or oversized response', + }) + } + + if ( + typeof token.access_token !== 'string' || + !token.access_token.trim() || + typeof token.expires_in !== 'number' || + !Number.isSafeInteger(token.expires_in) || + token.expires_in <= 0 + ) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: 'token response missing a valid access token or lifetime', + }) + } + + return { + accessToken: token.access_token, + expiresInSeconds: token.expires_in, + identity: { + displayName: 'Greenhouse API credential', + principal: null, + auditMetadata: { greenhouseClientId: fields.clientId }, + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index 6a9a971809c..045c10d91e5 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -3,6 +3,7 @@ import { CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, type ClientCredentialAccountProviderId, FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID, + GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID, getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -17,6 +18,7 @@ import { } from '@/lib/credentials/client-credential-accounts/descriptors' import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' import { mintFivetranServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/fivetran' +import { mintGreenhouseServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/greenhouse' import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite' import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm' import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' @@ -148,6 +150,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< [FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID]: mintFivetranServiceAccountToken, [ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleFusionServiceAccountToken, [ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleEpmServiceAccountToken, + [GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID]: mintGreenhouseServiceAccountToken, } export function getClientCredentialAccountMinter( diff --git a/apps/sim/lib/integrations/credential-display.test.ts b/apps/sim/lib/integrations/credential-display.test.ts index e8a1930bd3f..df14619b23b 100644 --- a/apps/sim/lib/integrations/credential-display.test.ts +++ b/apps/sim/lib/integrations/credential-display.test.ts @@ -57,6 +57,7 @@ const EXPECTED_COVERAGE: Record = { 'google-tasks', 'google-vault', ], + 'greenhouse-service-account': ['greenhouse'], 'harmonic-service-account': [], 'hubspot-service-account': ['hubspot'], 'linear-service-account': ['linear'], diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index efe27b5ffa4..c816de8c0c2 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -134,6 +134,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Greenhouse', description: 'Manage recruiting candidates, applications, jobs, and hiring activity.', providerId: 'greenhouse', + serviceAccountProviderId: 'greenhouse-service-account', icon: GreenhouseIcon, baseProviderIcon: GreenhouseIcon, scopes: [ diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 21b27fa68e8..75542631bab 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -502,6 +502,7 @@ describe('ordinary tool credential binding', () => { ['oracle-fusion', 'oracle-fusion-service-account'], ['vanta', 'vanta-service-account'], ['ramp', 'ramp-service-account'], + ['greenhouse', 'greenhouse-service-account'], ['google-email', 'google-service-account'], ['jira', 'atlassian-service-account'], ['confluence', 'atlassian-service-account'], diff --git a/packages/deployment-config/src/service-account-providers.generated.ts b/packages/deployment-config/src/service-account-providers.generated.ts index 7a1e6d44773..5cbd4af5e01 100644 --- a/packages/deployment-config/src/service-account-providers.generated.ts +++ b/packages/deployment-config/src/service-account-providers.generated.ts @@ -23,6 +23,7 @@ export const SERVICE_ACCOUNT_PROVIDER_BY_OAUTH_SERVICE_ID = { 'google-sheets': 'google-service-account', 'google-tasks': 'google-service-account', 'google-vault': 'google-service-account', + greenhouse: 'greenhouse-service-account', hubspot: 'hubspot-service-account', jira: 'atlassian-service-account', linear: 'linear-service-account', From 6a427ed079202d6fb11f6a6e59d552ce22102c99 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 18 Oct 2026 21:19:46 -0700 Subject: [PATCH 2/2] test(greenhouse): cover the client credential token exchange --- .../minters/greenhouse.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/greenhouse.test.ts diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/greenhouse.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/greenhouse.test.ts new file mode 100644 index 00000000000..d67254a9d3c --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/greenhouse.test.ts @@ -0,0 +1,98 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintGreenhouseServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/greenhouse' + +const FIELDS = { clientId: 'greenhouse-client', clientSecret: 'greenhouse-secret', orgId: '' } + +const mockFetch = vi.fn() + +describe('Greenhouse client credential exchange', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it('exchanges the credential with a scope-free client credentials grant', async () => { + const requests: Request[] = [] + mockFetch.mockImplementation(async (input, init) => { + const request = new Request(input, init) + requests.push(request) + expect(request.url).toBe('https://auth.greenhouse.io/token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + const [scheme, encodedCredentials = ''] = (request.headers.get('authorization') ?? '').split( + ' ' + ) + expect(scheme).toBe('Basic') + expect(Buffer.from(encodedCredentials, 'base64').toString('utf8')).toBe( + 'greenhouse-client:greenhouse-secret' + ) + expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + expect([...new URLSearchParams(await request.text())]).toEqual([ + ['grant_type', 'client_credentials'], + ]) + return jsonResponse({ + access_token: 'greenhouse-access', + token_type: 'Bearer', + expires_in: 3600, + }) + }) + + const result = await mintGreenhouseServiceAccountToken(FIELDS) + + expect(requests).toHaveLength(1) + expect(result).toMatchObject({ accessToken: 'greenhouse-access', expiresInSeconds: 3600 }) + expect(result.identity).toMatchObject({ + principal: null, + auditMetadata: { greenhouseClientId: 'greenhouse-client' }, + }) + expect(JSON.stringify(result.identity)).not.toContain(FIELDS.clientSecret) + expect(JSON.stringify(result.identity)).not.toContain('greenhouse-access') + }) + + it.each([ + [401, 'invalid_credentials'], + [403, 'invalid_credentials'], + [429, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'denied' }, { status })) + + await expect(mintGreenhouseServiceAccountToken(FIELDS)).rejects.toMatchObject({ code, status }) + }) + + it.each([ + ['no access token', { token_type: 'Bearer', expires_in: 3600 }], + ['a blank access token', { access_token: ' ', expires_in: 3600 }], + ['no lifetime', { access_token: 'greenhouse-access' }], + ['a non-positive lifetime', { access_token: 'greenhouse-access', expires_in: 0 }], + ['a fractional lifetime', { access_token: 'greenhouse-access', expires_in: 1.5 }], + ])('refuses a token response with %s', async (_label, body) => { + mockFetch.mockResolvedValue(jsonResponse(body)) + + await expect(mintGreenhouseServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) + + it('refuses an oversized token response', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ access_token: 'x'.repeat(70 * 1024), expires_in: 3600 }), { + headers: { 'Content-Type': 'application/json' }, + }) + ) + + await expect(mintGreenhouseServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) + + it('never sends a request without a client secret', async () => { + await expect( + mintGreenhouseServiceAccountToken({ ...FIELDS, clientSecret: undefined }) + ).rejects.toMatchObject({ code: 'invalid_credentials', status: 400 }) + expect(mockFetch).not.toHaveBeenCalled() + }) +})