Skip to content

Commit 3e9204a

Browse files
feat(credential-groups): collect API keys from invited people
Credential Groups could only collect managed OAuth grants, so services whose per-person credential is an API key — meeting recorders, AWS — had no way in. An option can now ask each invited person for a key instead of a sign-in. Storage is one new credential type and one new column. `credential.encryptedApiKey` holds a versioned envelope of the provider's declared fields, so a service needing more than one value (AWS: access key id, secret, region) needs no separate shape. It is sealed with `encryptSecret`, never `encryptApiKey`: the resolved-secret trace registry decrypts with `decryptSecret`, and `encryptApiKey` silently stores plaintext when its key is unset. Consumption is a new `get_api_key` block operation rather than a credential-id socket, so every existing API-key block works unchanged. It authorizes through the same resource policy as managed OAuth — actors may use their own credential, other enrollments need a workflow access grant — then registers one catalog entry per secret field with the run's trace registry before returning. A run without a registry fails the block rather than emitting a secret nothing can redact. Fields declared `secret: false` (a region) are stored but never catalogued: substituting a short recurring string would corrupt unrelated log output. Secret fields are held to the 8-character floor below which the matcher deliberately never redacts. Providers are AWS, Fireflies, Grain and Granola — each one a credential an ordinary user can create for themselves. Gong was excluded on that test: its access keys are workspace-scoped and admin-only, so they are the company's credential, not a person's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JcW9yNMBT7PkaB2LYJgEaM
1 parent 9684e79 commit 3e9204a

45 files changed

Lines changed: 2620 additions & 85 deletions

Some content is hidden

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

apps/sim/app/api/auth/[...all]/route.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,17 @@ vi.mock('@/lib/credential-groups/oauth-state', () => ({
3737
}))
3838

3939
vi.mock('@/lib/credential-groups/providers', () => ({
40-
CREDENTIAL_GROUP_PROVIDER_IDS: ['gmail', 'google-calendar', 'confluence', 'jira', 'slack'],
40+
CREDENTIAL_GROUP_PROVIDER_IDS: [
41+
'gmail',
42+
'google-calendar',
43+
'confluence',
44+
'jira',
45+
'slack',
46+
'fireflies',
47+
],
4148
CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS: ['gmail', 'google-calendar', 'confluence', 'jira'],
49+
CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS: ['fireflies'],
50+
isCredentialGroupApiKeyProvider: (provider: string) => provider === 'fireflies',
4251
getCredentialGroupStandardOAuthProviderFromProviderId: (providerId: string) => {
4352
const providers: Record<string, string> = {
4453
'google-email': 'gmail',
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
import { submitCredentialGroupApiKeyContract } from '@/lib/api/contracts/credential-groups'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { CredentialGroupApiKeyVerificationError } from '@/lib/credential-groups/api-key-providers/types'
8+
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
9+
import { submitPublicCredentialGroupApiKey } from '@/lib/credential-groups/application/public-enrollment'
10+
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
11+
import {
12+
enforceCredentialGroupEnrollmentOAuthRateLimit,
13+
enforcePublicCredentialGroupIpRateLimit,
14+
} from '@/lib/credential-groups/rate-limit'
15+
import { ManagedApiKeyFormatError } from '@/lib/credentials/managed-api-key'
16+
17+
export const dynamic = 'force-dynamic'
18+
export const runtime = 'nodejs'
19+
20+
const UNAVAILABLE = 'This invitation is invalid, expired, or has been revoked.'
21+
22+
/**
23+
* Accepts one API key from an invited person.
24+
*
25+
* A JSON route rather than the redirect-based flow its OAuth sibling uses: the submitting
26+
* form is a client component that renders the rejection inline against the field, so the
27+
* answer has to come back in the response instead of a query parameter.
28+
*/
29+
export const POST = withRouteHandler(
30+
async (
31+
request: NextRequest,
32+
context: { params: Promise<{ token: string; optionId: string }> }
33+
) => {
34+
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'api-key-submit')
35+
if (limited) return limited
36+
37+
const parsed = await parseRequest(submitCredentialGroupApiKeyContract, request, context)
38+
if (!parsed.success) return parsed.response
39+
const { token, optionId } = parsed.data.params
40+
41+
const principal = await authenticateCredentialGroupEnrollment(token)
42+
if (!principal) return NextResponse.json({ error: UNAVAILABLE }, { status: 404 })
43+
44+
const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit(
45+
principal.enrollmentId
46+
)
47+
if (enrollmentLimited) return enrollmentLimited
48+
49+
try {
50+
const result = await submitPublicCredentialGroupApiKey.execute({
51+
principal,
52+
input: { invitationToken: token, optionId, fields: parsed.data.body.fields },
53+
request,
54+
})
55+
return NextResponse.json(result)
56+
} catch (error) {
57+
// The verifier's message names what the provider said and is written for the person
58+
// holding the invitation, so it is surfaced verbatim rather than flattened to a 500.
59+
if (error instanceof CredentialGroupApiKeyVerificationError) {
60+
return NextResponse.json({ error: error.message }, { status: 400 })
61+
}
62+
if (error instanceof ManagedApiKeyFormatError) {
63+
return NextResponse.json({ error: error.message }, { status: 400 })
64+
}
65+
if (error instanceof CredentialGroupOAuthError) {
66+
return NextResponse.json({ error: error.message }, { status: error.statusCode })
67+
}
68+
const orchestration = asOrchestrationError(error)
69+
if (orchestration?.code === 'not_found') {
70+
return NextResponse.json({ error: UNAVAILABLE }, { status: 404 })
71+
}
72+
if (orchestration?.code === 'validation') {
73+
return NextResponse.json({ error: orchestration.message }, { status: 400 })
74+
}
75+
throw error
76+
}
77+
}
78+
)
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import {
5+
Chip,
6+
ChipModal,
7+
ChipModalBody,
8+
ChipModalError,
9+
ChipModalField,
10+
ChipModalFooter,
11+
ChipModalHeader,
12+
} from '@sim/emcn'
13+
import { getErrorMessage } from '@sim/utils/errors'
14+
import { useRouter } from 'next/navigation'
15+
import {
16+
type CredentialGroupApiKeyProvider,
17+
getCredentialGroupApiKeyFields,
18+
getCredentialGroupApiKeyLocation,
19+
getCredentialGroupProviderPresentation,
20+
} from '@/lib/credential-groups/providers'
21+
import { useSubmitCredentialGroupApiKey } from '@/hooks/queries/credential-group-enrollment'
22+
23+
interface ApiKeyConnectModalProps {
24+
token: string
25+
optionId: string
26+
/**
27+
* The provider id rather than its resolved presentation: this renders from a server
28+
* component, and an icon is a function, which cannot cross that boundary. Everything the
29+
* modal needs is derived here on the client from this one serializable value.
30+
*/
31+
provider: CredentialGroupApiKeyProvider
32+
connected: boolean
33+
}
34+
35+
/**
36+
* Collects the values one API-key option needs.
37+
*
38+
* The row itself offers a plain Connect action so an API-key account reads the same as an
39+
* OAuth one; the difference — that this service hands you a key instead of a sign-in — belongs
40+
* inside the modal, next to the link explaining where to find it.
41+
*/
42+
export function ApiKeyConnectModal({
43+
token,
44+
optionId,
45+
provider,
46+
connected,
47+
}: ApiKeyConnectModalProps) {
48+
const { name: serviceName, icon: Icon } = getCredentialGroupProviderPresentation(provider)
49+
const fields = getCredentialGroupApiKeyFields(provider)
50+
const keyLocation = getCredentialGroupApiKeyLocation(provider)
51+
const router = useRouter()
52+
const submit = useSubmitCredentialGroupApiKey(token, optionId)
53+
const [open, setOpen] = useState(false)
54+
const [values, setValues] = useState<Record<string, string>>({})
55+
const [error, setError] = useState<string | null>(null)
56+
57+
const handleOpenChange = (next: boolean) => {
58+
if (submit.isPending) return
59+
setOpen(next)
60+
if (!next) {
61+
setValues({})
62+
setError(null)
63+
}
64+
}
65+
66+
const handleSubmit = async () => {
67+
const missing = fields.find((field) => !(values[field.id] ?? '').trim())
68+
if (missing) {
69+
setError(`${missing.label} is required.`)
70+
return
71+
}
72+
setError(null)
73+
try {
74+
await submit.mutateAsync({
75+
fields: Object.fromEntries(fields.map((field) => [field.id, values[field.id].trim()])),
76+
})
77+
handleOpenChange(false)
78+
router.refresh()
79+
} catch (err) {
80+
setError(getErrorMessage(err, 'Those credentials could not be verified. Please try again.'))
81+
}
82+
}
83+
84+
return (
85+
<>
86+
<Chip onClick={() => setOpen(true)}>{connected ? 'Reconnect' : 'Connect'}</Chip>
87+
<ChipModal
88+
open={open}
89+
onOpenChange={handleOpenChange}
90+
dismissDisabled={submit.isPending}
91+
srTitle={`Connect ${serviceName}`}
92+
size='md'
93+
>
94+
<ChipModalHeader
95+
icon={Icon}
96+
onClose={() => handleOpenChange(false)}
97+
closeDisabled={submit.isPending}
98+
>
99+
Connect {serviceName}
100+
</ChipModalHeader>
101+
<ChipModalBody>
102+
<p className='text-pretty px-2 text-[var(--text-muted)] text-small leading-relaxed'>
103+
{keyLocation.steps}
104+
{keyLocation.url && (
105+
<>
106+
{' '}
107+
<a
108+
href={keyLocation.url}
109+
target='_blank'
110+
rel='noreferrer'
111+
className='underline underline-offset-2 hover:text-[var(--text-body)]'
112+
>
113+
Open {serviceName}
114+
</a>
115+
</>
116+
)}
117+
</p>
118+
{fields.map((field) => (
119+
<ChipModalField
120+
key={field.id}
121+
type='input'
122+
inputType={field.secret ? 'password' : 'text'}
123+
title={field.label}
124+
value={values[field.id] ?? ''}
125+
onChange={(value: string) =>
126+
setValues((current) => ({ ...current, [field.id]: value }))
127+
}
128+
placeholder={field.placeholder}
129+
autoComplete='off'
130+
disabled={submit.isPending}
131+
required
132+
/>
133+
))}
134+
<ChipModalError>{error}</ChipModalError>
135+
</ChipModalBody>
136+
<ChipModalFooter
137+
onCancel={() => handleOpenChange(false)}
138+
cancelDisabled={submit.isPending}
139+
primaryAction={{
140+
label: submit.isPending ? 'Checking…' : 'Connect',
141+
onClick: () => void handleSubmit(),
142+
disabled: submit.isPending,
143+
}}
144+
/>
145+
</ChipModal>
146+
</>
147+
)
148+
}

apps/sim/app/credential-groups/enroll/[token]/page.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@ import { headers } from 'next/headers'
55
import { asOrchestrationError } from '@/lib/core/orchestration/types'
66
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
77
import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment'
8-
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
8+
import {
9+
getCredentialGroupProviderPresentation,
10+
isCredentialGroupApiKeyProvider,
11+
} from '@/lib/credential-groups/providers'
912
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
1013
import { SupportFooter } from '@/app/(auth)/components'
1114
import { LogoShell } from '@/app/(landing)/components'
15+
import { ApiKeyConnectModal } from '@/app/credential-groups/enroll/[token]/api-key-connect-modal'
1216
import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link'
1317
import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast'
1418
import {
@@ -116,7 +120,7 @@ export default async function CredentialGroupEnrollmentPage({
116120
: undefined
117121
const notification = connectedOptionId
118122
? {
119-
message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`,
123+
message: `${connectedOption ? getCredentialGroupProviderPresentation(connectedOption.provider).name : 'Account'} connected successfully.`,
120124
variant: 'success' as const,
121125
}
122126
: oauthMessage
@@ -144,8 +148,31 @@ export default async function CredentialGroupEnrollmentPage({
144148
<SettingsSection label='Accounts'>
145149
<div className={RESOURCE_LIST_STACK}>
146150
{activeOptions.map((option) => {
147-
const ProviderIcon = getCredentialGroupProviderService(option.provider).icon
151+
const ProviderIcon = getCredentialGroupProviderPresentation(option.provider).icon
148152
const connection = option.connections[0]
153+
if (isCredentialGroupApiKeyProvider(option.provider)) {
154+
const provider = option.provider
155+
return (
156+
<SettingsResourceRow
157+
key={option.id}
158+
icon={<ProviderIcon />}
159+
title={option.label}
160+
description={
161+
connection
162+
? `Connected${connection.email ? ` as ${connection.email}` : ''}`
163+
: 'Not connected'
164+
}
165+
trailing={
166+
<ApiKeyConnectModal
167+
token={token}
168+
optionId={option.id}
169+
provider={provider}
170+
connected={Boolean(connection)}
171+
/>
172+
}
173+
/>
174+
)
175+
}
149176
return (
150177
<SettingsResourceRow
151178
key={option.id}

0 commit comments

Comments
 (0)