Skip to content

Commit a7276db

Browse files
committed
feat(credentials): DocuSign JWT Grant credentials next to OAuth
A saved DocuSign credential that authenticates with JWT Grant (an integration key, an impersonated user ID, the app's RSA private key, and an environment), offered as a second option beside the existing OAuth connection, which is unchanged. The minter signs an RS256 assertion (iss = integration key, sub = user, aud = the environment's authentication host without a scheme, scope "signature impersonation"), exchanges it at that host's /oauth/token, then reads /oauth/userinfo on the same host to bind the pinned account (or the user's default) and returns its eSignature REST base. Both hosts come only from the stored environment; a base_uri outside that environment's docusign.net shape is never trusted with the token. GUIDs and the key are checked before any request, and tokens are re-minted about 15 minutes before they expire, as DocuSign advises. DocuSign tools take the account base as an authoritative credential param, so a caller can never supply it, and DocuSignClient skips its deployment-host userinfo lookup when one is bound. OAuth tokens resolve exactly as before. Save-time failures map to consent_required (the connect modal shows the exact consent URL for the key and environment) and account_not_found (a pinned account the user does not belong to), alongside the shared invalid_credentials and provider_unavailable codes.
1 parent ac6f684 commit a7276db

30 files changed

Lines changed: 1039 additions & 17 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
title: DocuSign JWT Grant
3+
description: Connect DocuSign to Sim with an integration key and RSA keypair that acts as one DocuSign user, instead of a personal OAuth connection
4+
---
5+
6+
import { Callout } from '@/components/ui/callout'
7+
import { Step, Steps } from '@/components/ui/steps'
8+
9+
Connect DocuSign with JWT Grant when a workflow should keep working without anyone signing in again. Sim signs a short-lived assertion with your app's RSA private key, exchanges it for an access token that acts as one DocuSign user, and repeats that every hour. DocuSign issues no refresh token for JWT Grant, so nothing expires as long as the key and the user's consent stay in place.
10+
11+
Every envelope, template, and download runs as the impersonated user, with that user's DocuSign permissions. Use a dedicated user when you can.
12+
13+
## Prerequisites
14+
15+
- A DocuSign account in the environment you want: a free developer account (demo) or a production account
16+
- Admin access to **Apps and Keys** in that account, to create the integration key
17+
18+
## Create the integration key [#creating-the-integration-key]
19+
20+
<Steps>
21+
<Step>
22+
In DocuSign, open the **Apps and Keys** page and add an app. Give it a name (e.g. `Sim workflows`) and click **ADD**
23+
</Step>
24+
<Step>
25+
Copy the integration key DocuSign assigns (a GUID)
26+
</Step>
27+
<Step>
28+
Under **RSA key pair**, click **GENERATE RSA**. Copy the private key before you close the dialog, including its `BEGIN` and `END` lines. DocuSign masks it afterwards and can't show it again
29+
</Step>
30+
<Step>
31+
Under **Redirect URI**, click **ADD URI** and enter the address you open Sim at (for example `https://sim.ai`). DocuSign requires a redirect URI for the consent step; Sim never uses what it receives there
32+
</Step>
33+
<Step>
34+
Leave **User Application** set to **Yes** (the default) and click **SAVE**
35+
</Step>
36+
</Steps>
37+
38+
<Callout type="warn">
39+
The private key lets anyone holding it act as any user who has consented to this integration key. Sim encrypts it at rest and never shows it again. Treat it like a password.
40+
</Callout>
41+
42+
## Find the user ID [#finding-the-user-id]
43+
44+
The **Apps and Keys** page shows your **User ID** and **API Account ID**, both GUIDs. To act as a different user, use that user's ID instead. Use the user ID, not an email address.
45+
46+
The account ID is optional. Sim uses the user's default account unless you enter one, so you only need it when the user belongs to more than one account.
47+
48+
## Add the credential to Sim [#adding-the-credential-to-sim]
49+
50+
<Steps>
51+
<Step>
52+
Open **Integrations** from your workspace sidebar
53+
</Step>
54+
<Step>
55+
Search for "DocuSign" and open it, then click **Add to Sim** and choose **Add JWT Grant app**
56+
</Step>
57+
<Step>
58+
Choose the **Environment** the integration key was created in: **Developer (demo)** or **Production**. A key that hasn't gone live only works in the developer environment
59+
</Step>
60+
<Step>
61+
Paste the integration key, the impersonated user ID, the RSA private key, and optionally the account ID
62+
</Step>
63+
<Step>
64+
Click **Add JWT Grant app**. Sim requests a token and reads the user's accounts once to check everything matches
65+
</Step>
66+
</Steps>
67+
68+
## Grant consent [#granting-consent]
69+
70+
DocuSign won't issue tokens for a user until that user (or an organization admin) consents to the integration key. The first time you add the credential, Sim shows an error with the exact consent link for your integration key and environment. Open the link signed in as the impersonated user and click **Accept**. DocuSign then redirects to the redirect URI you added; the page it lands on doesn't matter, so close it and add the credential again.
71+
72+
You only need to consent once per user and integration key, unless the user revokes it.
73+
74+
## Use the credential in workflows [#using-the-credential-in-workflows]
75+
76+
Add a DocuSign block and select the saved credential in the account dropdown. Every DocuSign operation works with it the same way as with a connected account. Sim calls the eSignature REST API for the account it found when it last requested a token, in the environment stored on the credential.
77+
78+
Knowledge base syncs from DocuSign look up the account through your deployment's DocuSign OAuth host and always use the user's default account. Use a JWT Grant credential there only when its environment matches the deployment's, and connect a personal account when you need a non-default account.
79+
80+
## Fix connection errors
81+
82+
| Error | What to check |
83+
| ----- | ------------- |
84+
| Needs the impersonated user's consent | Open the consent link from the error signed in as that user, accept, and add the credential again. Consent can also be revoked later, which stops runs until it is granted again |
85+
| Couldn't authenticate | The integration key, user ID, and private key must belong to the same app, in the environment you chose. A key created in the developer environment isn't valid in production until it goes live. A private key from a deleted keypair no longer works |
86+
| User isn't a member of the account | The account ID you entered isn't one the user belongs to. Check it, or leave it blank |
87+
88+
To rotate the key, generate a new RSA keypair on the integration key, reconnect the credential in Sim with the new private key, test a workflow, then delete the old keypair. DocuSign allows up to five keypairs per integration key. See [DocuSign's JWT Grant documentation](https://developers.docusign.com/platform/auth/jwt/jwt-get-token/).

‎apps/docs/content/docs/integrations/docusign.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ import { ReferenceDetails } from "@/components/ui/reference-details"
1111

1212
{/* MANUAL-CONTENT-START:intro */}
1313
Use [DocuSign](https://www.docusign.com) to send envelopes for e-signature, create them from templates, inspect recipients and signing status, and download completed documents. Pending envelopes can be voided with a reason.
14+
15+
Connect a DocuSign account through OAuth, or an integration key that acts as one user through [JWT Grant](/integrations/docusign-service-account).
1416
{/* MANUAL-CONTENT-END */}
1517

18+
Connection guides: [JWT Grant](/integrations/docusign-service-account).
19+
1620
## OAuth scopes [#oauth-scopes]
1721

1822
Sim requests these scopes when someone connects a DocuSign account. For a self-hosted deployment, register your own app with the provider using the settings below. See [Integrations and OAuth](/platform/self-hosting/integrations-oauth) for setup.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"devin",
6767
"discord",
6868
"docusign",
69+
"docusign-service-account",
6970
"downdetector",
7071
"dropbox",
7172
"dropcontact",

‎apps/docs/content/integration-navigation.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@
3838
"integration": "databricks",
3939
"title": "Databricks connections"
4040
},
41+
"docusign-service-account": {
42+
"integration": "docusign",
43+
"title": "JWT Grant"
44+
},
4145
"google-service-account": {
4246
"title": "Google service accounts"
4347
},

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import {
3535

3636
const logger = createLogger('ClientCredentialAccountModal')
3737

38+
type FieldValues = Partial<Record<ClientCredentialAccountFieldId, string>>
39+
3840
const FALLBACK_ERROR_MESSAGE = "We couldn't save this credential. Try again in a moment."
3941

4042
/**
@@ -46,6 +48,7 @@ function messageForClientCredentialError(
4648
err: unknown,
4749
descriptor: ClientCredentialAccountDescriptor,
4850
requiredFields: ClientCredentialAccountField[],
51+
values: FieldValues,
4952
fallbackMessage = FALLBACK_ERROR_MESSAGE
5053
): string {
5154
if (isApiClientError(err) && err.code) {
@@ -66,6 +69,18 @@ function messageForClientCredentialError(
6669
}
6770
case 'provider_unavailable':
6871
return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.`
72+
case 'consent_required': {
73+
const redirectUri = window.location.origin
74+
const consentUrl = descriptor.consentUrl?.(values, redirectUri)
75+
return consentUrl
76+
? `${descriptor.serviceLabel} needs the impersonated user's consent first. Add ${redirectUri} as a redirect URI on the integration key, open this link signed in as that user, accept, then try again: ${consentUrl}`
77+
: `${descriptor.serviceLabel} needs the impersonated user's consent first. See the setup guide.`
78+
}
79+
case 'account_not_found': {
80+
const accountFieldLabel =
81+
descriptor.fields.find((field) => field.id === 'orgId')?.label ?? 'account'
82+
return `That user isn't a member of the ${accountFieldLabel} you entered. Check it, or leave it blank to use the user's default account.`
83+
}
6984
case 'duplicate_display_name':
7085
return 'A credential with that name already exists in this workspace.'
7186
default:
@@ -75,8 +90,6 @@ function messageForClientCredentialError(
7590
return fallbackMessage
7691
}
7792

78-
type FieldValues = Partial<Record<ClientCredentialAccountFieldId, string>>
79-
8093
function openDocs(url: string): void {
8194
window.open(url, '_blank', 'noopener,noreferrer')
8295
}
@@ -242,6 +255,7 @@ function ClientCredentialAccountModalForm({
242255
err,
243256
descriptor,
244257
required,
258+
values,
245259
onSubmitCredentials ? getErrorMessage(err, FALLBACK_ERROR_MESSAGE) : undefined
246260
)
247261
)

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

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ export interface ClientCredentialAccountDescriptor {
105105
defaultAuthMethod?: string
106106
/** Sim setup guide, docked bottom-left of the connect modal. */
107107
docsUrl: string
108+
/**
109+
* Builds the provider's one-time consent URL from the values in the connect
110+
* modal, for providers whose grant fails with `consent_required` until a user
111+
* approves the app (DocuSign JWT Grant). `redirectUri` is the Sim origin the
112+
* modal is open on. Returns `undefined` when the values can't form one.
113+
*/
114+
consentUrl?: (
115+
values: Partial<Record<ClientCredentialAccountFieldId, string>>,
116+
redirectUri: string
117+
) => string | undefined
108118
/**
109119
* Optional one-line caveat, rendered under the `orgId` field of the connect modal. A descriptor
110120
* without an `orgId` field puts its guidance in a field `hint` instead.
@@ -125,6 +135,7 @@ export const ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-fusion-service-
125135
export const GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID = 'greenhouse-service-account' as const
126136
export const JAMA_SERVICE_ACCOUNT_PROVIDER_ID = 'jama-service-account' as const
127137
export const HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID = 'helpscout-service-account' as const
138+
export const DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID = 'docusign-service-account' as const
128139

129140
/** Permissions requested when connecting a Manage Vanta application. */
130141
export const VANTA_PERMISSION_OPTIONS = [
@@ -150,6 +161,7 @@ export type ClientCredentialAccountProviderId =
150161
| typeof GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID
151162
| typeof HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID
152163
| typeof JAMA_SERVICE_ACCOUNT_PROVIDER_ID
164+
| typeof DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID
153165

154166
/**
155167
* Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and
@@ -385,6 +397,63 @@ export function resolveZohoDeskDataCenter(rawDataCenter?: string): {
385397
return { id, ...ZOHO_DESK_DATA_CENTERS[id] }
386398
}
387399

400+
/**
401+
* DocuSign's two account systems. Each environment has its own authentication
402+
* service and its own eSignature REST hosts, and a token from one is never
403+
* accepted by the other, so the credential stores which one it belongs to and
404+
* nothing a caller types can change it.
405+
* @see https://developers.docusign.com/platform/auth/jwt/jwt-get-token/
406+
*/
407+
export const DOCUSIGN_ENVIRONMENTS = {
408+
demo: { authHost: 'account-d.docusign.com', label: 'Developer (demo)' },
409+
production: { authHost: 'account.docusign.com', label: 'Production' },
410+
} as const satisfies Record<string, { authHost: string; label: string }>
411+
412+
export type DocuSignEnvironment = keyof typeof DOCUSIGN_ENVIRONMENTS
413+
414+
/** Resolves a stored environment value, or `undefined` when it is not exactly one of the two. */
415+
export function resolveDocuSignEnvironment(
416+
value: string | undefined
417+
): DocuSignEnvironment | undefined {
418+
const trimmed = value?.trim()
419+
return trimmed && Object.hasOwn(DOCUSIGN_ENVIRONMENTS, trimmed)
420+
? (trimmed as DocuSignEnvironment)
421+
: undefined
422+
}
423+
424+
/** DocuSign integration keys, user IDs, and account IDs are all GUIDs. */
425+
export const DOCUSIGN_GUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
426+
427+
/** An unencrypted PEM RSA private key, in the PKCS#1 form DocuSign generates or PKCS#8. */
428+
const DOCUSIGN_PRIVATE_KEY_REGEX =
429+
/^-----BEGIN (RSA )?PRIVATE KEY-----[\s\S]+-----END (RSA )?PRIVATE KEY-----$/
430+
431+
/** Scopes a JWT Grant credential requests; `impersonation` is what JWT consent covers. */
432+
export const DOCUSIGN_JWT_SCOPES = 'signature impersonation'
433+
434+
/**
435+
* The one-time consent URL the impersonated user opens to let the integration
436+
* key act as them. DocuSign requires a `redirect_uri` registered on the key;
437+
* the JWT flow ignores the `code` it receives.
438+
* @see https://developers.docusign.com/platform/auth/jwt/jwt-get-token/
439+
*/
440+
function buildDocuSignConsentUrl(
441+
integrationKey: string,
442+
environment: string | undefined,
443+
redirectUri: string
444+
): string | undefined {
445+
const resolved = resolveDocuSignEnvironment(environment)
446+
const key = integrationKey.trim()
447+
if (!resolved || !DOCUSIGN_GUID_REGEX.test(key)) return undefined
448+
const params = new URLSearchParams({
449+
response_type: 'code',
450+
scope: DOCUSIGN_JWT_SCOPES,
451+
client_id: key,
452+
redirect_uri: redirectUri,
453+
})
454+
return `https://${DOCUSIGN_ENVIRONMENTS[resolved].authHost}/oauth/auth?${params.toString()}`
455+
}
456+
388457
export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
389458
ClientCredentialAccountProviderId,
390459
ClientCredentialAccountDescriptor
@@ -417,6 +486,69 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
417486
helpText:
418487
'Create API credentials in your Jama profile. Your account needs REST API access and a Named Creator license.',
419488
},
489+
[DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID]: {
490+
providerId: DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID,
491+
serviceLabel: 'DocuSign',
492+
connectNoun: 'JWT Grant app',
493+
fields: [
494+
{
495+
id: 'dataCenter',
496+
label: 'Environment',
497+
placeholder: 'Select an environment',
498+
secret: false,
499+
options: (Object.keys(DOCUSIGN_ENVIRONMENTS) as DocuSignEnvironment[]).map((id) => ({
500+
value: id,
501+
label: DOCUSIGN_ENVIRONMENTS[id].label,
502+
})),
503+
hint: 'Where the integration key was created. Keys start in the developer environment until they go live.',
504+
},
505+
{
506+
id: 'clientId',
507+
label: 'Integration key',
508+
placeholder: '00000000-0000-0000-0000-000000000000',
509+
secret: false,
510+
hintPattern: DOCUSIGN_GUID_REGEX,
511+
hintNormalize: (value) => value.trim(),
512+
hintMessage: 'Expected the integration key GUID from Apps and Keys.',
513+
},
514+
{
515+
id: 'username',
516+
label: 'Impersonated user ID',
517+
placeholder: '00000000-0000-0000-0000-000000000000',
518+
secret: false,
519+
hintPattern: DOCUSIGN_GUID_REGEX,
520+
hintNormalize: (value) => value.trim(),
521+
hintMessage: 'Expected the User ID GUID from Apps and Keys, not an email address.',
522+
hint: 'Every call runs as this user, with their DocuSign permissions.',
523+
},
524+
{
525+
id: 'privateKey',
526+
label: 'RSA private key',
527+
placeholder: '-----BEGIN RSA PRIVATE KEY-----',
528+
secret: true,
529+
multiline: true,
530+
hintPattern: DOCUSIGN_PRIVATE_KEY_REGEX,
531+
hintNormalize: (value) => value.trim(),
532+
hintMessage:
533+
'Expected the full PEM private key from the RSA keypair, including its BEGIN and END lines.',
534+
},
535+
{
536+
id: 'orgId',
537+
label: 'Account ID',
538+
placeholder: "Defaults to the user's default account",
539+
secret: false,
540+
optional: true,
541+
hintPattern: DOCUSIGN_GUID_REGEX,
542+
hintNormalize: (value) => value.trim(),
543+
hintMessage: 'Expected the API Account ID GUID from Apps and Keys.',
544+
},
545+
],
546+
docsUrl: 'https://docs.sim.ai/integrations/docusign-service-account',
547+
helpText:
548+
"Only needed when the user belongs to more than one account and the one to use isn't their default.",
549+
consentUrl: (values, redirectUri) =>
550+
buildDocuSignConsentUrl(values.clientId ?? '', values.dataCenter, redirectUri),
551+
},
420552
[RAMP_SERVICE_ACCOUNT_PROVIDER_ID]: {
421553
providerId: RAMP_SERVICE_ACCOUNT_PROVIDER_ID,
422554
serviceLabel: 'Ramp',

0 commit comments

Comments
 (0)