Skip to content

Commit b9ecb68

Browse files
committed
feat(zoho-desk): add Self Client service-account credential
Adds a second way to connect Zoho Desk, alongside the interactive OAuth flow: a Zoho Self Client, pasted as client id + client secret + organization id. Built on the existing client-credential-accounts framework rather than a new credential path, so it behaves like the Zoom Server-to-Server and Box CCG accounts already in the repo - a short-lived token minted on demand, no refresh token. Two Zoho behaviors the generic framework does not cover: - `scope` must be COMMA-separated on Zoho's token endpoint; a space-separated list is rejected as an invalid scope. The list comes from getCanonicalScopesForProvider('zoho-desk'), so the Self Client and the OAuth flow can never drift apart on scopes. - Zoho reports OAuth failures in the JSON body, frequently with HTTP 200 (e.g. {"error":"invalid_client"}), so the success body is inspected for an `error` field before the token is read - a status-only check would accept a failed mint. deriveZohoDeskBaseFromApiDomain moves out of auth.ts into the dependency-free host-allowlist module so the minter and the OAuth path share one derivation instead of duplicating it, and the mint response's api_domain now flows through to tools as `apiDomain` (the SA branch of the token route previously returned none, so SA calls would have assumed desk.zoho.com). Docs: hand-authored zoho-desk-service-account.mdx following the existing *-service-account.mdx pages, registered in meta.json and in the generator's keep-list so stale-page cleanup does not delete it. Known limitation, documented in the descriptor helpText and the docs page: webhook triggers still require an OAuth connection. Webhook provisioning resolves credentials through getCredentialOwner/refreshAccessTokenIfNeeded, which is OAuth-account-only for every provider in the repo - not a Zoho-specific gap. Unverified from documentation, needs a live Zoho org before merge: - the `ZohoDesk.` soid prefix. Zoho documents only the syntax {servicename}.{zsoid} with a single CRM example; no first-party doc states the Desk prefix. normalizeZohoDeskSoid passes through any value already containing a '.', so an operator can paste a corrected full soid without a code change. - whether zsoid is the same identifier as the Desk orgId header value. - whether the client-credentials endpoint accepts Desk.webhooks.CREATE/DELETE for a Self Client. - whether the mint response populates api_domain for Desk (documented for CRM); if absent the derivation falls back to the US Desk host.
1 parent 53136aa commit b9ecb68

15 files changed

Lines changed: 745 additions & 33 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@
261261
"zendesk",
262262
"zep",
263263
"zerobounce",
264+
"zoho-desk-service-account",
264265
"zoho_desk",
265266
"zoom",
266267
"zoom-service-account",
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
---
2+
title: Zoho Desk Self Clients
3+
description: Set up a Zoho Self Client so your workflows can call Zoho Desk without a personal 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+
A Zoho **Self Client** is an OAuth client that has no redirect URL and no end user. Instead of sending someone through a consent screen, your workflows authenticate to Zoho Desk with the client's own ID and secret, and Sim mints a short-lived access token on demand — no user consent to expire, and no personal login that breaks when someone leaves the team.
11+
12+
This is the recommended way to use Zoho Desk in production workflows: the credential belongs to your organization rather than a person, the granted scopes are explicit, and tokens are minted fresh whenever a workflow runs.
13+
14+
## Prerequisites
15+
16+
You need a Zoho account with access to the [Zoho API Console](https://api-console.zoho.com) for the same organization your Zoho Desk portal belongs to, and the Zoho Desk **organization ID** for that portal.
17+
18+
<Callout type="warn">
19+
Sim's Zoho Desk integration authenticates against the US accounts server (`accounts.zoho.com`). Organizations hosted in another Zoho data center (EU, IN, AU, JP, CA, SA, CN, UK) cannot mint tokens through this flow. Once a token is minted, API calls are routed to the Desk host for the data center Zoho reports, so data residency is honored for the calls themselves.
20+
</Callout>
21+
22+
<Callout type="warn">
23+
Zoho Desk **webhooks are a Professional-edition and above feature**. The Zoho Desk trigger in Sim provisions a webhook subscription, so it does not work on Free or Standard plans. The trigger also requires a personal **OAuth** connection rather than a Self Client — see [Triggers](#triggers-still-need-oauth) below.
24+
</Callout>
25+
26+
## Setting Up the Self Client
27+
28+
### 1. Create the Self Client
29+
30+
<Steps>
31+
<Step>
32+
Sign in at [api-console.zoho.com](https://api-console.zoho.com) with the Zoho account that owns the Desk portal
33+
</Step>
34+
<Step>
35+
Click **Add Client**, choose **Self Client**, and click **Create** — then **OK** on the confirmation
36+
37+
{/* TODO(screenshot): Zoho API Console Add Client dialog with Self Client selected */}
38+
</Step>
39+
<Step>
40+
Open the new client and switch to the **Client Secret** tab. Copy the **Client ID** and **Client Secret** — these are two of the three values you'll paste into Sim
41+
42+
{/* TODO(screenshot): Self Client Client Secret tab showing Client ID and Client Secret */}
43+
</Step>
44+
</Steps>
45+
46+
<Callout type="info">
47+
You do **not** need the **Generate Code** tab. That tab produces a one-time authorization code for the code-exchange flow; the client-credentials flow Sim uses needs only the client ID, the client secret, and your organization ID.
48+
</Callout>
49+
50+
### 2. Find Your Organization ID
51+
52+
<Steps>
53+
<Step>
54+
Open Zoho Desk and go to **Setup** (the gear icon) → **Developer Space****API**
55+
</Step>
56+
<Step>
57+
Copy the numeric **Organization ID** (also called the Org ID or portal ID) shown there — for example `600123456`
58+
59+
{/* TODO(screenshot): Zoho Desk Setup > Developer Space > API showing the Organization ID */}
60+
</Step>
61+
</Steps>
62+
63+
<Callout type="warn">
64+
This must be the organization ID of the Desk portal you want the workflows to act on. If your Zoho account has more than one Desk portal, using the wrong ID makes Zoho either reject the token request or issue a token scoped to the wrong portal.
65+
</Callout>
66+
67+
### 3. Scopes
68+
69+
Sim requests exactly the scopes its Zoho Desk tools and trigger exercise:
70+
71+
```
72+
Desk.tickets.READ
73+
Desk.tickets.UPDATE
74+
Desk.contacts.READ
75+
Desk.basic.READ
76+
Desk.webhooks.CREATE
77+
Desk.webhooks.DELETE
78+
aaaserver.profile.READ
79+
```
80+
81+
Sim sends this list on every token request, so there is nothing to pre-configure on the Self Client itself. If Zoho rejects the request with an invalid-scope error, the Self Client's owner does not have access to one of the Desk modules above in that organization.
82+
83+
A scope that is granted but insufficient surfaces at run time as a `4xx` from the Zoho Desk API naming the scope problem.
84+
85+
### 4. Protect the Client Secret
86+
87+
The client secret is bearer material for your Zoho Desk organization, limited only by the scopes above. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts it at rest.
88+
89+
<Callout type="info">
90+
Regenerating or revoking the Self Client in the Zoho API Console invalidates the stored pair immediately. If you rotate it, update the credential in Sim right away.
91+
</Callout>
92+
93+
## Adding the Self Client to Sim
94+
95+
<Steps>
96+
<Step>
97+
Open **Integrations** from your workspace sidebar
98+
</Step>
99+
<Step>
100+
Search for "Zoho Desk" and open it, then click **Add to Sim** and choose **Add Self Client**
101+
102+
{/* TODO(screenshot): Zoho Desk integration page with the Add Self Client connect option */}
103+
</Step>
104+
<Step>
105+
In the **Add Zoho Desk Self Client** dialog, paste the **Client ID**, the **Client secret**, and the numeric **Organization ID**, and optionally set a display name and description
106+
107+
{/* TODO(screenshot): Add Zoho Desk Self Client dialog with all three fields filled in */}
108+
</Step>
109+
<Step>
110+
Click **Add Self Client**. Sim verifies the credentials by minting a real access token from Zoho — if it fails, the error tells you whether Zoho rejected the credentials or couldn't be reached.
111+
</Step>
112+
</Steps>
113+
114+
## Using the Self Client in Workflows
115+
116+
Add a Zoho Desk block to your workflow. In the credential dropdown, your Self Client appears alongside any OAuth credentials. Select it and configure the block as you normally would.
117+
118+
{/* TODO(screenshot): Zoho Desk block in a workflow with the Self Client selected as the credential */}
119+
120+
The block calls the Zoho Desk REST API with a freshly minted access token — the same requests as the OAuth flow, so every Zoho Desk tool works, subject to the scopes above.
121+
122+
### Triggers still need OAuth
123+
124+
The Zoho Desk **trigger** provisions and tears down its own webhook subscription against your Desk organization, and that provisioning path currently runs only against a personal OAuth connection. Connect a Zoho Desk account through OAuth for triggers, and use the Self Client for the blocks that read and update tickets.
125+
126+
## Token Behavior
127+
128+
Access tokens minted from a Self Client live for one hour and there is **no refresh token** — Sim mints a new token whenever one is needed, and caches the current one until it is close to expiry. Two events invalidate the stored credential:
129+
130+
- **Revoking or deleting the Self Client** in the Zoho API Console — no new tokens can be minted
131+
- **Regenerating the client secret** — the stored pair stops working; paste the new secret into the credential in Sim
132+
133+
<FAQ items={[
134+
{ question: "Why a Self Client instead of OAuth?", answer: "A Self Client authenticates as your Zoho organization, not as a person — nothing expires when someone leaves or their login lapses. Sim mints short-lived tokens from the stored client ID and secret whenever a workflow runs." },
135+
{ question: "Where do I find the Organization ID?", answer: "In Zoho Desk, go to Setup (gear icon) → Developer Space → API. The numeric Organization ID shown there is the value to paste. It is the same ID that Zoho Desk API calls send in the orgId header." },
136+
{ question: "Zoho rejects my credentials with invalid_client — why?", answer: "Either the client ID or secret was mistyped, or the client you created is not a Self Client. Only Self Clients support the client-credentials grant — in the Zoho API Console, Add Client → Self Client. Copy both values from the client's Client Secret tab." },
137+
{ question: "Zoho returns missing_org_info or rejects the organization — why?", answer: "Zoho could not resolve a Desk organization from the ID you pasted. Re-copy the numeric Organization ID from Setup → Developer Space → API in the Desk portal you want to use. If your Zoho account has multiple Desk portals, make sure it is the ID of the right one." },
138+
{ question: "Can I use a Self Client with a non-US Zoho account?", answer: "Not currently. Sim's Zoho Desk integration authenticates against the US accounts server, accounts.zoho.com. An organization in another Zoho data center must authenticate against its own accounts server, which this flow does not support." },
139+
{ question: "Why doesn't my Zoho Desk trigger work with the Self Client?", answer: "The trigger provisions a webhook subscription in your Desk organization, and that path runs against a personal OAuth connection only. Connect Zoho Desk through OAuth for triggers. Separately, Zoho Desk webhooks require a Professional-edition plan or above — they are unavailable on Free and Standard." },
140+
{ question: "How do I rotate the credentials?", answer: "Regenerate the client secret on the Self Client's Client Secret tab in the Zoho API Console, then update the credential in Sim with the new secret. The old secret stops working as soon as it's regenerated, so update Sim promptly." },
141+
]} />

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
186186
cloudId: result.cloudId,
187187
domain: result.domain,
188188
instanceUrl: result.instanceUrl,
189+
apiDomain: result.apiDomain,
189190
authStyle: result.authStyle,
190191
},
191192
{ status: 200 }

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,11 @@ export interface ServiceAccountTokenResult {
354354
domain?: string
355355
/** Salesforce only — the org's instance URL the token must be used against. */
356356
instanceUrl?: string
357+
/**
358+
* Zoho Desk only — the data-center-scoped Desk REST base the token must be
359+
* used against, forwarded to tools as their `apiDomain` param.
360+
*/
361+
apiDomain?: string
357362
/**
358363
* Set when the token must be sent in an `x-api-token` header instead of
359364
* `Authorization: Bearer` (e.g. Pipedrive personal API tokens). Absent means
@@ -397,6 +402,8 @@ interface CachedClientCredentialToken {
397402
secretFingerprint: string
398403
/** Salesforce only — the instance URL returned alongside the minted token. */
399404
instanceUrl?: string
405+
/** Zoho Desk only — the Desk REST base derived from the token's api_domain. */
406+
apiDomain?: string
400407
}
401408

402409
interface FailedClientCredentialMint {
@@ -488,7 +495,11 @@ async function resolveClientCredentialAccountToken(
488495
cached.secretFingerprint === secretFingerprint &&
489496
cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS
490497
) {
491-
return { accessToken: cached.accessToken, instanceUrl: cached.instanceUrl }
498+
return {
499+
accessToken: cached.accessToken,
500+
instanceUrl: cached.instanceUrl,
501+
apiDomain: cached.apiDomain,
502+
}
492503
}
493504

494505
const failed = clientCredentialMintFailureCache.get(credentialId)
@@ -522,8 +533,13 @@ async function resolveClientCredentialAccountToken(
522533
expiresAtMs: Date.now() + mint.expiresInSeconds * 1000,
523534
secretFingerprint,
524535
instanceUrl: mint.instanceUrl,
536+
apiDomain: mint.apiDomain,
525537
})
526-
return { accessToken: mint.accessToken, instanceUrl: mint.instanceUrl }
538+
return {
539+
accessToken: mint.accessToken,
540+
instanceUrl: mint.instanceUrl,
541+
apiDomain: mint.apiDomain,
542+
}
527543
} catch (error) {
528544
clientCredentialMintFailureCache.set(credentialId, {
529545
error,

apps/sim/lib/api/contracts/oauth-connections.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ const oauthTokenResponseSchema = z.object({
8686
accessToken: z.string(),
8787
idToken: z.string().optional(),
8888
instanceUrl: z.string().optional(),
89+
/** Zoho Desk — the data-center-scoped Desk REST base for this credential. */
90+
apiDomain: z.string().optional(),
8991
cloudId: z.string().optional(),
9092
domain: z.string().optional(),
9193
authStyle: z.enum(['x-api-token']).optional(),

apps/sim/lib/auth/auth.ts

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ import { joinInstanceOrganization } from '@/lib/organizations/instance-org'
109109
import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server'
110110
import { disableUserResources } from '@/lib/workflows/lifecycle'
111111
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
112-
import { isZohoHost } from '@/tools/zoho_desk/host-allowlist'
112+
import { deriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlist'
113113

114114
const logger = createLogger('Auth')
115115

@@ -160,31 +160,6 @@ function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, provi
160160
}
161161
}
162162

163-
/**
164-
* Derive the Zoho Desk REST base URL from the token response `api_domain`
165-
* (e.g. `https://www.zohoapis.eu` -> `https://desk.zoho.eu`). Zoho returns the
166-
* data-center-scoped `api_domain` on the `www.zohoapis.*` host, but the Desk
167-
* REST API lives on `desk.zoho.*` in the same data center. Persisting the
168-
* derived Desk base (instead of assuming `desk.zoho.com`) honors data residency.
169-
*/
170-
function deriveZohoDeskBaseFromApiDomain(apiDomain?: string): string {
171-
const fallback = 'https://desk.zoho.com'
172-
if (!apiDomain) return fallback
173-
try {
174-
const host = new URL(apiDomain).host.toLowerCase()
175-
// Gate on the strict Zoho apex allowlist before trusting the host: a loose
176-
// `desk.zoho.*` pattern would accept a lookalike like `desk.zoho.com.attacker.com`
177-
// and persist it as the credential's REST base, later leaking the OAuth token.
178-
if (!isZohoHost(host)) return fallback
179-
// Map the data-center TLD from the (now trusted) host onto the Desk REST host
180-
// in the same data center - works for both www.zohoapis.<tld> and desk.zoho.<tld>.
181-
const match = host.match(/zoho(?:apis)?\.([a-z.]+)$/)
182-
return match?.[1] ? `https://desk.zoho.${match[1]}` : fallback
183-
} catch {
184-
return fallback
185-
}
186-
}
187-
188163
const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
189164
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
190165
)

apps/sim/lib/credentials/client-credential-accounts/descriptors.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ export interface ClientCredentialAccountDescriptor {
5454
export const ZOOM_SERVICE_ACCOUNT_PROVIDER_ID = 'zoom-service-account' as const
5555
export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const
5656
export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const
57+
export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const
5758

5859
export type ClientCredentialAccountProviderId =
5960
| typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID
6061
| typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID
6162
| typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID
63+
| typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID
6264

6365
/**
6466
* Allowed My Domain host shapes: one org label (optionally with a
@@ -85,6 +87,35 @@ export function normalizeSalesforceMyDomainHost(rawHost: string): string {
8587
.toLowerCase()
8688
}
8789

90+
/**
91+
* Zoho's `soid` token parameter is documented only as the syntax
92+
* `{servicename}.{zsoid}` with a single Zoho CRM example
93+
* (`ZohoCRM.600*****434`). Two things are NOT confirmed by Zoho's own docs:
94+
*
95+
* 1. that the Desk service name is literally `ZohoDesk` (inferred from the
96+
* documented syntax and corroborated only by community posts), and
97+
* 2. that `zsoid` is the same value as the Desk `orgId` sent in the `orgId`
98+
* request header, rather than a distinct Zoho ServiceOrg id.
99+
*
100+
* Both need live verification against a real Zoho Desk org before this flow is
101+
* relied on. The normalization is therefore deliberately permissive: a value
102+
* that already carries a `{servicename}.` prefix (any dot) is passed through
103+
* untouched, so an operator who learns the correct prefix or id can paste the
104+
* full `soid` and bypass the inference entirely. A bare id is prefixed with
105+
* `ZohoDesk.`.
106+
*
107+
* Shared by the connect modal's format hint and the server-side minter so both
108+
* judge the same normalized value.
109+
*/
110+
export function normalizeZohoDeskSoid(rawOrgId: string): string {
111+
const trimmed = rawOrgId.trim()
112+
if (!trimmed || trimmed.includes('.')) return trimmed
113+
return `ZohoDesk.${trimmed}`
114+
}
115+
116+
/** A normalized `soid`: a service-name prefix plus a numeric Zoho org id. */
117+
export const ZOHO_DESK_SOID_REGEX = /^[A-Za-z]+\.\d+$/
118+
88119
export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
89120
ClientCredentialAccountProviderId,
90121
ClientCredentialAccountDescriptor
@@ -179,6 +210,38 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
179210
helpText:
180211
'The Connected App must have "Enable Client Credentials Flow" checked with a "Run As" integration user set under Edit Policies — every call executes with that user\'s permissions, and deactivating or freezing the user stops all runs.',
181212
},
213+
[ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: {
214+
providerId: ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID,
215+
serviceLabel: 'Zoho Desk',
216+
connectNoun: 'Self Client',
217+
fields: [
218+
{
219+
id: 'clientId',
220+
label: 'Client ID',
221+
placeholder: "Client ID from the Self Client's Client Secret tab",
222+
secret: false,
223+
},
224+
{
225+
id: 'clientSecret',
226+
label: 'Client secret',
227+
placeholder: 'Paste the client secret',
228+
secret: true,
229+
},
230+
{
231+
id: 'orgId',
232+
label: 'Organization ID',
233+
placeholder: '600123456',
234+
secret: false,
235+
hintPattern: ZOHO_DESK_SOID_REGEX,
236+
hintNormalize: normalizeZohoDeskSoid,
237+
hintMessage:
238+
'Paste the numeric Zoho Desk organization ID from Setup → Developer Space → API, or the full ZohoDesk.<orgId> value.',
239+
},
240+
],
241+
docsUrl: 'https://docs.sim.ai/integrations/zoho-desk-service-account',
242+
helpText:
243+
'Create the Self Client in the Zoho API Console, add the Zoho Desk scopes, and use the organization ID from Setup → Developer Space → API. Only Zoho accounts in the US data center (accounts.zoho.com) are supported, and Zoho Desk triggers still require an OAuth connection.',
244+
},
182245
}
183246

184247
/**

0 commit comments

Comments
 (0)