Skip to content

Commit 235ec06

Browse files
committed
feat(credentials): client-credentials service accounts — shared kind + Zoom and Box
Second service-account kind: stored clientId/clientSecret/orgId pairs that mint short-lived tokens at resolution (in-memory cache + single-flight coalescing; no cross-instance lock — providers allow concurrent tokens). Zoom Server-to-Server OAuth and Box Client Credentials Grant are the first minters; generic modal, contract fields, and all five dispatch seams wired
1 parent 297e013 commit 235ec06

18 files changed

Lines changed: 1448 additions & 14 deletions

File tree

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { and, desc, eq } from 'drizzle-orm'
77
import { withLeaderLock } from '@/lib/concurrency/leader-lock'
88
import { coalesceLocally } from '@/lib/concurrency/singleflight'
99
import { decryptSecret } from '@/lib/core/security/encryption'
10+
import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors'
11+
import {
12+
getClientCredentialAccountMinter,
13+
parseClientCredentialAccountSecretBlob,
14+
} from '@/lib/credentials/client-credential-accounts/server'
1015
import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
1116
import {
1217
parseTokenServiceAccountSecretBlob,
@@ -361,6 +366,74 @@ async function getTokenServiceAccountSecret(
361366
return parseTokenServiceAccountSecretBlob(decrypted, providerId)
362367
}
363368

369+
interface CachedClientCredentialToken {
370+
accessToken: string
371+
expiresAtMs: number
372+
}
373+
374+
/**
375+
* 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.
379+
*
380+
* The cache is intentionally process-local with no eviction on credential
381+
* update: a rotated secret can serve the previously minted token for at most
382+
* the remaining TTL (≤ ~55 min) on instances that cached it — acceptable
383+
* staleness, since these providers keep already-minted tokens valid after a
384+
* secret rotation anyway. No cross-instance lock is needed either: mints are
385+
* stateless and both providers allow multiple concurrently valid tokens, so
386+
* each instance minting its own token is correct.
387+
*/
388+
const clientCredentialTokenCache = new Map<string, CachedClientCredentialToken>()
389+
const CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS = 5 * 60 * 1000
390+
391+
/**
392+
* Resolves a client-credential service-account credential to a short-lived
393+
* access token: decrypts the stored client id/secret + org id and mints via
394+
* the provider's registered minter, read-through the per-instance cache.
395+
* Wrapped in `coalesceLocally` so concurrent block executions on one instance
396+
* share a single mint.
397+
*/
398+
async function resolveClientCredentialAccountToken(
399+
credentialId: string,
400+
providerId: string
401+
): Promise<ServiceAccountTokenResult> {
402+
return coalesceLocally(`ccsa:${credentialId}`, async () => {
403+
const cached = clientCredentialTokenCache.get(credentialId)
404+
if (cached && cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS) {
405+
return { accessToken: cached.accessToken }
406+
}
407+
408+
const [credentialRow] = await db
409+
.select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
410+
.from(credential)
411+
.where(eq(credential.id, credentialId))
412+
.limit(1)
413+
if (!credentialRow?.encryptedServiceAccountKey) {
414+
throw new Error('Client-credential service account secret not found')
415+
}
416+
417+
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
418+
const blob = parseClientCredentialAccountSecretBlob(decrypted, providerId)
419+
const minter = getClientCredentialAccountMinter(providerId)
420+
if (!minter) {
421+
throw new Error(`No minter registered for service-account provider ${providerId}`)
422+
}
423+
424+
const mint = await minter({
425+
clientId: blob.clientId,
426+
clientSecret: blob.clientSecret,
427+
orgId: blob.orgId,
428+
})
429+
clientCredentialTokenCache.set(credentialId, {
430+
accessToken: mint.accessToken,
431+
expiresAtMs: Date.now() + mint.expiresInSeconds * 1000,
432+
})
433+
return { accessToken: mint.accessToken }
434+
})
435+
}
436+
364437
interface ServiceAccountTokenOptions {
365438
scopes?: string[]
366439
impersonateEmail?: string
@@ -413,6 +486,9 @@ export async function resolveServiceAccountToken(
413486
const secret = await getTokenServiceAccountSecret(credentialId, providerId)
414487
return { accessToken: secret.apiToken, domain: secret.domain }
415488
}
489+
if (providerId && isClientCredentialAccountProviderId(providerId)) {
490+
return resolveClientCredentialAccountToken(credentialId, providerId)
491+
}
416492
const resolver =
417493
providerId && Object.hasOwn(SERVICE_ACCOUNT_TOKEN_RESOLVERS, providerId)
418494
? SERVICE_ACCOUNT_TOKEN_RESOLVERS[providerId]

apps/sim/app/api/credentials/[id]/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ export const PUT = withRouteHandler(
8686
botToken: body.botToken,
8787
apiToken: body.apiToken,
8888
domain: body.domain,
89+
clientId: body.clientId,
90+
clientSecret: body.clientSecret,
91+
orgId: body.orgId,
8992
request,
9093
})
9194
if (!result.success) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
'use client'
2+
3+
import { type ComponentType, useEffect, useState } from 'react'
4+
import {
5+
ChipModal,
6+
ChipModalBody,
7+
ChipModalError,
8+
ChipModalField,
9+
ChipModalFooter,
10+
ChipModalHeader,
11+
SecretInput,
12+
} from '@sim/emcn'
13+
import { createLogger } from '@sim/logger'
14+
import { isApiClientError } from '@/lib/api/client/errors'
15+
import type {
16+
ClientCredentialAccountDescriptor,
17+
ClientCredentialAccountField,
18+
} from '@/lib/credentials/client-credential-accounts/descriptors'
19+
import {
20+
useCreateWorkspaceCredential,
21+
useUpdateWorkspaceCredential,
22+
} from '@/hooks/queries/credentials'
23+
24+
const logger = createLogger('ClientCredentialAccountModal')
25+
26+
const FALLBACK_ERROR_MESSAGE = "We couldn't add this credential. Try again in a moment."
27+
28+
/**
29+
* Maps server `error.code` values from client-credential verification (a real
30+
* token mint against the provider) to user-facing messages, personalized with
31+
* the provider's field labels.
32+
*/
33+
function messageForClientCredentialError(
34+
err: unknown,
35+
descriptor: ClientCredentialAccountDescriptor
36+
): string {
37+
if (isApiClientError(err) && err.code) {
38+
const fieldLabels = descriptor.fields.map((field) => field.label).join(', ')
39+
switch (err.code) {
40+
case 'invalid_credentials':
41+
return `We couldn't authenticate with those credentials. Check that the ${fieldLabels} all belong to the same ${descriptor.serviceLabel} app and that the app is authorized.`
42+
case 'provider_unavailable':
43+
return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.`
44+
case 'duplicate_display_name':
45+
return 'A credential with that name already exists in this workspace.'
46+
default:
47+
return FALLBACK_ERROR_MESSAGE
48+
}
49+
}
50+
return FALLBACK_ERROR_MESSAGE
51+
}
52+
53+
function openDocs(url: string): void {
54+
window.open(url, '_blank', 'noopener,noreferrer')
55+
}
56+
57+
interface ClientCredentialAccountModalProps {
58+
open: boolean
59+
onOpenChange: (open: boolean) => void
60+
workspaceId: string
61+
descriptor: ClientCredentialAccountDescriptor
62+
serviceName: string
63+
serviceIcon: ComponentType<{ className?: string }>
64+
/** When set, reconnect (rotate the secrets on) this credential in place. */
65+
credentialId?: string
66+
initialDisplayName?: string
67+
initialDescription?: string
68+
}
69+
70+
/**
71+
* Generic connect modal for client-credentials service accounts (Zoom
72+
* Server-to-Server OAuth, Box CCG). Renders the client id, client secret, and
73+
* org-identifier fields declared by the provider's
74+
* {@link ClientCredentialAccountDescriptor} and submits through the same
75+
* create/update credential mutations as the other service-account modals.
76+
* The server verifies the triple by minting a real access token; failures are
77+
* mapped from the route's `error.code`.
78+
*/
79+
export function ClientCredentialAccountModal({
80+
open,
81+
onOpenChange,
82+
workspaceId,
83+
descriptor,
84+
serviceName,
85+
serviceIcon: ServiceIcon,
86+
credentialId,
87+
initialDisplayName,
88+
initialDescription,
89+
}: ClientCredentialAccountModalProps) {
90+
const [clientId, setClientId] = useState('')
91+
const [clientSecret, setClientSecret] = useState('')
92+
const [orgId, setOrgId] = useState('')
93+
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
94+
const [description, setDescription] = useState(initialDescription ?? '')
95+
const [error, setError] = useState<string | null>(null)
96+
97+
const createCredential = useCreateWorkspaceCredential()
98+
const updateCredential = useUpdateWorkspaceCredential()
99+
100+
useEffect(() => {
101+
if (open) return
102+
setClientId('')
103+
setClientSecret('')
104+
setOrgId('')
105+
setDisplayName(initialDisplayName ?? '')
106+
setDescription(initialDescription ?? '')
107+
setError(null)
108+
}, [open, initialDisplayName, initialDescription])
109+
110+
const clientIdField = descriptor.fields.find((field) => field.id === 'clientId')
111+
const clientSecretField = descriptor.fields.find((field) => field.id === 'clientSecret')
112+
const orgIdField = descriptor.fields.find((field) => field.id === 'orgId')
113+
114+
const trimmedClientId = clientId.trim()
115+
const trimmedClientSecret = clientSecret.trim()
116+
const trimmedOrgId = orgId.trim()
117+
const isPending = createCredential.isPending || updateCredential.isPending
118+
const isDisabled = !trimmedClientId || !trimmedClientSecret || !trimmedOrgId || isPending
119+
120+
const hintFor = (
121+
field: ClientCredentialAccountField | undefined,
122+
value: string
123+
): string | undefined => {
124+
if (!field?.hintPattern || !field.hintMessage || value.length === 0) return undefined
125+
return field.hintPattern.test(value) ? undefined : field.hintMessage
126+
}
127+
128+
const handleSubmit = async () => {
129+
setError(null)
130+
if (isDisabled) return
131+
try {
132+
const secretFields = {
133+
clientId: trimmedClientId,
134+
clientSecret: trimmedClientSecret,
135+
orgId: trimmedOrgId,
136+
}
137+
if (credentialId) {
138+
await updateCredential.mutateAsync({
139+
credentialId,
140+
...secretFields,
141+
displayName: displayName.trim() || undefined,
142+
description: description.trim() || undefined,
143+
})
144+
} else {
145+
await createCredential.mutateAsync({
146+
workspaceId,
147+
type: 'service_account',
148+
providerId: descriptor.providerId,
149+
...secretFields,
150+
displayName: displayName.trim() || undefined,
151+
description: description.trim() || undefined,
152+
})
153+
}
154+
onOpenChange(false)
155+
} catch (err: unknown) {
156+
setError(messageForClientCredentialError(err, descriptor))
157+
logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err)
158+
}
159+
}
160+
161+
return (
162+
<ChipModal
163+
open={open}
164+
onOpenChange={onOpenChange}
165+
srTitle={`Add ${serviceName} ${descriptor.connectNoun}`}
166+
>
167+
<ChipModalHeader icon={ServiceIcon} onClose={() => onOpenChange(false)}>
168+
Add {serviceName} {descriptor.connectNoun}
169+
</ChipModalHeader>
170+
<ChipModalBody>
171+
{clientIdField && (
172+
<ChipModalField
173+
type='input'
174+
title={clientIdField.label}
175+
value={clientId}
176+
onChange={(value) => {
177+
setClientId(value)
178+
if (error) setError(null)
179+
}}
180+
placeholder={clientIdField.placeholder}
181+
autoComplete='off'
182+
required
183+
error={hintFor(clientIdField, trimmedClientId)}
184+
/>
185+
)}
186+
187+
{clientSecretField && (
188+
<ChipModalField
189+
type='custom'
190+
title={clientSecretField.label}
191+
required
192+
hint={descriptor.helpText}
193+
>
194+
<SecretInput
195+
value={clientSecret}
196+
onChange={(value) => {
197+
setClientSecret(value)
198+
if (error) setError(null)
199+
}}
200+
placeholder={clientSecretField.placeholder}
201+
name={`${descriptor.providerId}_client_secret`}
202+
autoComplete='new-password'
203+
autoCorrect='off'
204+
autoCapitalize='off'
205+
data-lpignore='true'
206+
data-form-type='other'
207+
/>
208+
</ChipModalField>
209+
)}
210+
211+
{orgIdField && (
212+
<ChipModalField
213+
type='input'
214+
title={orgIdField.label}
215+
value={orgId}
216+
onChange={(value) => {
217+
setOrgId(value)
218+
if (error) setError(null)
219+
}}
220+
placeholder={orgIdField.placeholder}
221+
autoComplete='off'
222+
required
223+
error={hintFor(orgIdField, trimmedOrgId)}
224+
/>
225+
)}
226+
227+
<ChipModalField
228+
type='input'
229+
title='Display name'
230+
value={displayName}
231+
onChange={setDisplayName}
232+
placeholder={`Defaults to the ${descriptor.serviceLabel} account name`}
233+
autoComplete='off'
234+
/>
235+
236+
<ChipModalField
237+
type='textarea'
238+
title='Description'
239+
value={description}
240+
onChange={setDescription}
241+
placeholder='Optional description'
242+
maxLength={500}
243+
minHeight={80}
244+
/>
245+
246+
<ChipModalError>{error}</ChipModalError>
247+
</ChipModalBody>
248+
<ChipModalFooter
249+
onCancel={() => onOpenChange(false)}
250+
secondaryActions={[
251+
{
252+
label: 'Setup guide',
253+
onClick: () => openDocs(descriptor.docsUrl),
254+
},
255+
]}
256+
primaryAction={{
257+
label: isPending ? 'Adding...' : `Add ${descriptor.connectNoun}`,
258+
onClick: handleSubmit,
259+
disabled: isDisabled,
260+
}}
261+
/>
262+
</ChipModal>
263+
)
264+
}

0 commit comments

Comments
 (0)