Skip to content

Commit b819002

Browse files
committed
feat(credentials): Salesforce client-credentials + Pipedrive API-token service accounts
Salesforce: third client-credentials minter — SSRF-allowlisted My Domain token endpoint (production/sandbox/developer partitioned domains), fixed conservative TTL (Salesforce never returns expires_in; JWT exp clamp when parseable), live instance_url threaded through the existing instanceUrl context plumbing so all 40 tools work unchanged. Pipedrive: token-paste provider with explicit authStyle threading — descriptor declares x-api-token, resolver/token route/executor carry the signal, and one shared getPipedriveAuthHeaders helper drives all 18 tools plus both selector proxy routes (OAuth Bearer behavior untouched). Zoom: user-scoped tool descriptions note that server-to-server tokens do not support the 'me' keyword
1 parent 235ec06 commit b819002

44 files changed

Lines changed: 1342 additions & 257 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"linear-service-account",
1313
"monday-service-account",
1414
"notion-service-account",
15+
"pipedrive-service-account",
1516
"shopify-service-account",
1617
"trello-service-account",
1718
"wealthbox-service-account",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
title: Pipedrive API Tokens
3+
description: Connect Pipedrive to Sim with a personal API token instead of an OAuth login
4+
---
5+
6+
import { Callout } from 'fumadocs-ui/components/callout'
7+
import { Step, Steps } from 'fumadocs-ui/components/steps'
8+
import { FAQ } from '@/components/ui/faq'
9+
10+
Pipedrive personal API tokens let your workflows authenticate to Pipedrive without a person's OAuth login. The token is issued per user per company, doesn't expire on its own, and stays valid until it's regenerated or the user is deactivated — no OAuth consent to renew.
11+
12+
The token carries the full data access of the Pipedrive user it belongs to, in that one company. For production workflows, create it from a dedicated Pipedrive user so a teammate leaving or regenerating their token doesn't break your automations.
13+
14+
## Prerequisites
15+
16+
Any Pipedrive user with API access enabled can copy their token. If the API page is hidden, a Pipedrive admin may have disabled API access for non-admin users — an admin can enable it from the company's user settings.
17+
18+
<Callout type="warn">
19+
Each user has exactly **one** active API token per company. Regenerating it immediately invalidates the old value everywhere it's used, with no grace period. If the token is shared with other integrations, coordinate before regenerating.
20+
</Callout>
21+
22+
## Finding the API Token
23+
24+
<Steps>
25+
<Step>
26+
In Pipedrive, click your account name in the top right, then **Company settings****Personal preferences****API** — or go directly to `https://app.pipedrive.com/settings/api`
27+
</Step>
28+
<Step>
29+
Copy **Your personal API token** (generate one if the field is empty)
30+
</Step>
31+
</Steps>
32+
33+
<Callout type="warn">
34+
The API token grants everything its user can see and do in Pipedrive. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest.
35+
</Callout>
36+
37+
## Adding the API Token to Sim
38+
39+
<Steps>
40+
<Step>
41+
Open your workspace **Settings** and go to the **Integrations** tab
42+
</Step>
43+
<Step>
44+
Search for "Pipedrive Service Account" and click it, then click **Add to Sim** and choose **Add API token**
45+
</Step>
46+
<Step>
47+
Paste the **API token**, and optionally set a display name and description
48+
</Step>
49+
<Step>
50+
Click **Add API token**. Sim verifies the token against Pipedrive (`GET /v1/users/me`) and names the credential after the Pipedrive user and company — if verification fails, you'll see a specific error explaining what went wrong.
51+
</Step>
52+
</Steps>
53+
54+
## Using the API Token in Workflows
55+
56+
Add a Pipedrive block to your workflow. In the credential dropdown, your Pipedrive API token appears alongside any OAuth credentials. Select it and configure the block as you normally would.
57+
58+
Sim sends the token in Pipedrive's `x-api-token` header (the documented scheme for personal API tokens), so every Pipedrive tool works unchanged.
59+
60+
<Callout type="info">
61+
Pipedrive gives API-token traffic lower burst rate limits than OAuth (roughly a quarter of the OAuth allowance, by plan), and every company shares one daily API budget across all users and both auth methods. Heavy workflow schedules can eat into the budget your other Pipedrive integrations use.
62+
</Callout>
63+
64+
## Rotating the Token
65+
66+
Pipedrive tokens don't expire on a schedule. To rotate one, regenerate it on the same **Personal preferences****API** page, then paste the new value into the credential in Sim right away — the old token stops working the moment a new one is generated.
67+
68+
<FAQ items={[
69+
{ question: "Why an API token instead of OAuth?", answer: "The token doesn't depend on any user staying logged in or re-consenting, and it never expires on its own. Issued from a dedicated Pipedrive user, it's a stable credential for automated workflows." },
70+
{ question: "What can the token access?", answer: "Everything the Pipedrive user it belongs to can access, in that one company. There are no scopes to narrow it — pick the issuing user accordingly." },
71+
{ question: "My token validates but a mailbox or projects block fails — why?", answer: "API tokens ignore scopes, but features like Mailbox and Projects still depend on your Pipedrive plan. A valid token doesn't unlock endpoints your plan doesn't include." },
72+
{ question: "What happens if the user who owns the token is deactivated?", answer: "The token stops working immediately. Issue a new token from an active user (ideally a dedicated integration user) and update the credential in Sim." },
73+
{ question: "Why are my workflows hitting rate limits more than with OAuth?", answer: "Pipedrive gives API-token requests about a quarter of the OAuth burst allowance, and the company-wide daily API budget is shared across all users and integrations. Space out heavy schedules or switch busy workflows to OAuth credentials." },
74+
]} />

apps/sim/app/api/auth/oauth/token/route.test.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ import {
1212
import { NextRequest } from 'next/server'
1313
import { beforeEach, describe, expect, it, vi } from 'vitest'
1414

15-
const { mockAuthorizeCredentialUse } = vi.hoisted(() => ({
15+
const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({
1616
mockAuthorizeCredentialUse: vi.fn(),
17+
mockResolveServiceAccountToken: vi.fn(),
1718
}))
1819

19-
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
20+
vi.mock('@/app/api/auth/oauth/utils', () => ({
21+
...authOAuthUtilsMock,
22+
resolveServiceAccountToken: mockResolveServiceAccountToken,
23+
}))
2024

2125
vi.mock('@/lib/auth/credential-access', () => ({
2226
authorizeCredentialUse: mockAuthorizeCredentialUse,
@@ -195,6 +199,67 @@ describe('OAuth Token API Routes', () => {
195199
expect(data).toHaveProperty('error', 'Failed to refresh access token')
196200
})
197201

202+
describe('service account path', () => {
203+
it('should thread authStyle from the resolver into the response', async () => {
204+
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
205+
accountId: '',
206+
credentialId: 'sa-credential-id',
207+
credentialType: 'service_account',
208+
providerId: 'pipedrive-service-account',
209+
workspaceId: 'workspace-id',
210+
usedCredentialTable: true,
211+
})
212+
mockAuthorizeCredentialUse.mockResolvedValueOnce({
213+
ok: true,
214+
authType: 'session',
215+
requesterUserId: 'test-user-id',
216+
workspaceId: 'workspace-id',
217+
})
218+
mockResolveServiceAccountToken.mockResolvedValueOnce({
219+
accessToken: 'pasted-api-token',
220+
authStyle: 'x-api-token',
221+
})
222+
223+
const req = createMockRequest('POST', { credentialId: 'sa-credential-id' })
224+
225+
const response = await POST(req)
226+
const data = await response.json()
227+
228+
expect(response.status).toBe(200)
229+
expect(data).toHaveProperty('accessToken', 'pasted-api-token')
230+
expect(data).toHaveProperty('authStyle', 'x-api-token')
231+
})
232+
233+
it('should omit authStyle for Bearer token-paste providers', async () => {
234+
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
235+
accountId: '',
236+
credentialId: 'sa-credential-id',
237+
credentialType: 'service_account',
238+
providerId: 'hubspot-service-account',
239+
workspaceId: 'workspace-id',
240+
usedCredentialTable: true,
241+
})
242+
mockAuthorizeCredentialUse.mockResolvedValueOnce({
243+
ok: true,
244+
authType: 'session',
245+
requesterUserId: 'test-user-id',
246+
workspaceId: 'workspace-id',
247+
})
248+
mockResolveServiceAccountToken.mockResolvedValueOnce({
249+
accessToken: 'pat-token',
250+
})
251+
252+
const req = createMockRequest('POST', { credentialId: 'sa-credential-id' })
253+
254+
const response = await POST(req)
255+
const data = await response.json()
256+
257+
expect(response.status).toBe(200)
258+
expect(data).toHaveProperty('accessToken', 'pat-token')
259+
expect(data).not.toHaveProperty('authStyle')
260+
})
261+
})
262+
198263
describe('credentialAccountUserId + providerId path', () => {
199264
it('should reject unauthenticated requests', async () => {
200265
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
180180
accessToken: result.accessToken,
181181
cloudId: result.cloudId,
182182
domain: result.domain,
183+
instanceUrl: result.instanceUrl,
184+
authStyle: result.authStyle,
183185
},
184186
{ status: 200 }
185187
)

apps/sim/app/api/auth/oauth/utils.ts

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
getClientCredentialAccountMinter,
1313
parseClientCredentialAccountSecretBlob,
1414
} from '@/lib/credentials/client-credential-accounts/server'
15-
import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
15+
import {
16+
getTokenServiceAccountDescriptor,
17+
isTokenServiceAccountProviderId,
18+
} from '@/lib/credentials/token-service-accounts/descriptors'
1619
import {
1720
parseTokenServiceAccountSecretBlob,
1821
type TokenServiceAccountSecretBlob,
@@ -341,6 +344,14 @@ export interface ServiceAccountTokenResult {
341344
cloudId?: string
342345
/** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */
343346
domain?: string
347+
/** Salesforce only — the org's instance URL the token must be used against. */
348+
instanceUrl?: string
349+
/**
350+
* Set when the token must be sent in an `x-api-token` header instead of
351+
* `Authorization: Bearer` (e.g. Pipedrive personal API tokens). Absent means
352+
* Bearer; OAuth credentials never carry it.
353+
*/
354+
authStyle?: 'x-api-token'
344355
}
345356

346357
/**
@@ -369,13 +380,17 @@ async function getTokenServiceAccountSecret(
369380
interface CachedClientCredentialToken {
370381
accessToken: string
371382
expiresAtMs: number
383+
/** Salesforce only — the instance URL returned alongside the minted token. */
384+
instanceUrl?: string
372385
}
373386

374387
/**
375388
* Per-instance cache of minted client-credential access tokens (Zoom S2S,
376-
* Box CCG), keyed by credential id. Entries are served while more than
377-
* {@link CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS} of validity remains, so a hot
378-
* credential mints roughly once per token TTL (~1h) per instance.
389+
* Box CCG, Salesforce client-credentials), keyed by credential id. Entries are
390+
* served while more than {@link CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS} of
391+
* validity remains, so a hot credential mints roughly once per token TTL
392+
* (~1h for Zoom/Box; Salesforce reports a conservative 10-minute TTL because
393+
* its responses never carry an expiry) per instance.
379394
*
380395
* The cache is intentionally process-local with no eviction on credential
381396
* update: a rotated secret can serve the previously minted token for at most
@@ -402,7 +417,7 @@ async function resolveClientCredentialAccountToken(
402417
return coalesceLocally(`ccsa:${credentialId}`, async () => {
403418
const cached = clientCredentialTokenCache.get(credentialId)
404419
if (cached && cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS) {
405-
return { accessToken: cached.accessToken }
420+
return { accessToken: cached.accessToken, instanceUrl: cached.instanceUrl }
406421
}
407422

408423
const [credentialRow] = await db
@@ -429,8 +444,9 @@ async function resolveClientCredentialAccountToken(
429444
clientCredentialTokenCache.set(credentialId, {
430445
accessToken: mint.accessToken,
431446
expiresAtMs: Date.now() + mint.expiresInSeconds * 1000,
447+
instanceUrl: mint.instanceUrl,
432448
})
433-
return { accessToken: mint.accessToken }
449+
return { accessToken: mint.accessToken, instanceUrl: mint.instanceUrl }
434450
})
435451
}
436452

@@ -484,7 +500,12 @@ export async function resolveServiceAccountToken(
484500
): Promise<ServiceAccountTokenResult> {
485501
if (providerId && isTokenServiceAccountProviderId(providerId)) {
486502
const secret = await getTokenServiceAccountSecret(credentialId, providerId)
487-
return { accessToken: secret.apiToken, domain: secret.domain }
503+
const descriptorAuthStyle = getTokenServiceAccountDescriptor(providerId)?.authStyle
504+
return {
505+
accessToken: secret.apiToken,
506+
domain: secret.domain,
507+
...(descriptorAuthStyle === 'x-api-token' ? { authStyle: descriptorAuthStyle } : {}),
508+
}
488509
}
489510
if (providerId && isClientCredentialAccountProviderId(providerId)) {
490511
return resolveClientCredentialAccountToken(credentialId, providerId)
@@ -720,35 +741,34 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
720741
}
721742

722743
/**
723-
* Refreshes an OAuth token if needed based on credential information.
724-
* Also handles service account credentials by generating a JWT-based token.
725-
* @param credentialId The ID of the credential to check and potentially refresh
726-
* @param userId The user ID who owns the credential (for security verification)
727-
* @param requestId Request ID for log correlation
728-
* @param scopes Optional scopes for service account token generation
729-
* @returns The valid access token or null if refresh fails
744+
* Resolves a credential to its access token plus provider metadata
745+
* (`cloudId`/`domain`/`instanceUrl`/`authStyle`). Behaves exactly like
746+
* {@link refreshAccessTokenIfNeeded} but returns the full
747+
* {@link ServiceAccountTokenResult} so callers that build provider requests
748+
* directly (e.g. selector routes) can honor non-Bearer auth styles such as
749+
* Pipedrive's `x-api-token`. OAuth credentials resolve with `accessToken`
750+
* only.
730751
*/
731-
export async function refreshAccessTokenIfNeeded(
752+
export async function resolveCredentialAccessToken(
732753
credentialId: string,
733754
userId: string,
734755
requestId: string,
735756
scopes?: string[],
736757
impersonateEmail?: string
737-
): Promise<string | null> {
758+
): Promise<ServiceAccountTokenResult | null> {
738759
const resolved = await resolveOAuthAccountId(credentialId)
739760
if (!resolved) {
740761
return null
741762
}
742763

743764
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
744765
logger.info(`[${requestId}] Using service account token for credential`)
745-
const { accessToken } = await resolveServiceAccountToken(
766+
return resolveServiceAccountToken(
746767
resolved.credentialId,
747768
resolved.providerId,
748769
scopes,
749770
impersonateEmail
750771
)
751-
return accessToken
752772
}
753773

754774
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
@@ -794,13 +814,13 @@ export async function refreshAccessTokenIfNeeded(
794814
requestId,
795815
userId: credential.userId,
796816
})
797-
if (fresh) return fresh
817+
if (fresh) return { accessToken: fresh }
798818

799819
// If refresh was only triggered proactively (Microsoft refresh-token aging),
800820
// the still-valid access token is a fine fallback.
801821
if (!accessTokenNeedsRefresh && accessToken) {
802822
logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`)
803-
return accessToken
823+
return { accessToken }
804824
}
805825
return null
806826
}
@@ -811,7 +831,34 @@ export async function refreshAccessTokenIfNeeded(
811831
}
812832

813833
logger.info(`[${requestId}] Access token is valid for credential`)
814-
return accessToken
834+
return { accessToken }
835+
}
836+
837+
/**
838+
* Refreshes an OAuth token if needed based on credential information.
839+
* Also handles service account credentials by generating a JWT-based token.
840+
* Thin string wrapper over {@link resolveCredentialAccessToken}.
841+
* @param credentialId The ID of the credential to check and potentially refresh
842+
* @param userId The user ID who owns the credential (for security verification)
843+
* @param requestId Request ID for log correlation
844+
* @param scopes Optional scopes for service account token generation
845+
* @returns The valid access token or null if refresh fails
846+
*/
847+
export async function refreshAccessTokenIfNeeded(
848+
credentialId: string,
849+
userId: string,
850+
requestId: string,
851+
scopes?: string[],
852+
impersonateEmail?: string
853+
): Promise<string | null> {
854+
const result = await resolveCredentialAccessToken(
855+
credentialId,
856+
userId,
857+
requestId,
858+
scopes,
859+
impersonateEmail
860+
)
861+
return result?.accessToken ?? null
815862
}
816863

817864
/**

apps/sim/app/api/tools/pipedrive/get-files/route.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { generateRequestId } from '@/lib/core/utils/request'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
14+
import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils'
1415

1516
export const dynamic = 'force-dynamic'
1617

@@ -54,7 +55,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5455
const parsed = await parseRequest(pipedriveGetFilesContract, request, {})
5556
if (!parsed.success) return parsed.response
5657

57-
const { accessToken, sort, limit, start, downloadFiles } = parsed.data.body
58+
const { accessToken, authStyle, sort, limit, start, downloadFiles } = parsed.data.body
59+
const authHeaders = getPipedriveAuthHeaders({ accessToken, authStyle })
5860

5961
const baseUrl = 'https://api.pipedrive.com/v1/files'
6062
const queryParams = new URLSearchParams()
@@ -75,10 +77,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7577

7678
const response = await secureFetchWithPinnedIP(apiUrl, urlValidation.resolvedIP!, {
7779
method: 'GET',
78-
headers: {
79-
Authorization: `Bearer ${accessToken}`,
80-
Accept: 'application/json',
81-
},
80+
headers: authHeaders,
8281
})
8382

8483
const data = (await response.json()) as PipedriveApiResponse
@@ -114,7 +113,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
114113
fileUrlValidation.resolvedIP!,
115114
{
116115
method: 'GET',
117-
headers: { Authorization: `Bearer ${accessToken}` },
116+
headers: authHeaders,
118117
}
119118
)
120119

0 commit comments

Comments
 (0)