Skip to content

Commit ca9fcb3

Browse files
feat(credentials): add named API keys to credential groups
1 parent fb2c3f3 commit ca9fcb3

59 files changed

Lines changed: 31138 additions & 104 deletions

File tree

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/platform/connected-accounts.mdx‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ For Search-enabled organizations, open **Settings → Integrations → People**.
5757

5858
Invitees can contribute accounts without joining your organization. The invitation grants access to their connection form; it does not grant access to your workspaces or workflows.
5959

60-
Use the invitation email in workflow lookups. For example, if you invite `alex@example.com`, **Find Organization Account** with that email and **Gmail** finds Alex's active Gmail contribution.
60+
Use the invitation email in workflow lookups. For example, if you invite `alex@example.com`, **Find Credential Group Account** with that email and **Gmail** finds Alex's active Gmail contribution.
6161

6262
#### How the email is associated with a Sim user
6363

@@ -83,17 +83,25 @@ Removing a workspace stops subsequent use of the pool. It does not recall provid
8383

8484
Use the **Credential** block's organization operations in an allowed workspace:
8585

86-
- **Find Organization Account** selects an OAuth account by invitation email and provider.
87-
- **List Organization Accounts** returns a page of OAuth accounts, optionally filtered by email and providers.
88-
- **Find Organization MCP Connection** selects a person's managed MCP connection by invitation email and MCP provider.
89-
- **List Organization MCP Connections** returns a page of managed MCP connections, optionally filtered by email and provider.
86+
- **Find Credential Group Account** selects an OAuth account by invitation email and provider.
87+
- **List Credential Group Accounts** returns a page of OAuth accounts, optionally filtered by email and providers.
88+
- **Find Credential Group MCP Connection** selects a person's managed MCP connection by invitation email and MCP provider.
89+
- **List Credential Group MCP Connections** returns a page of managed MCP connections, optionally filtered by email and provider.
9090

9191
The organization is determined by the workflow's workspace. You do not enter a credential group ID or organization ID in the block.
9292

9393
The outputs are account references, without tokens. Use an OAuth `credentialId` in the corresponding integration block's credential field. For managed MCP, `credentialId` identifies the person's connection; `mcpServerId` identifies shared configuration and cannot select that person's authorization by itself.
9494

9595
See the [Credential block reference](/workflows/blocks/credential#organization-accounts) for inputs, outputs, pagination, and connection-event triggers.
9696

97+
## Named API keys
98+
99+
In the Credential Group's **Integrations** tab, select **Add API key**. Enter a name and an optional description explaining where invitees can get the key. The administrator defines the request; each invited person enters their own secret value in the connection form. A name such as `EXA_API_KEY` is permitted but does not create an environment variable.
100+
101+
Allow **API keys** for the workspaces that should use these contributions. In a workflow, **List Credential Group API Keys** returns submitted key IDs and metadata. Pass a selected ID into **Get Credential Group API Key**, then reference its `apiKey` output directly in the downstream block. See the [named API key example](/workflows/blocks/credential#named-api-keys).
102+
103+
People can replace or disconnect their key from their connection form. Renaming a request preserves the submissions and IDs. Removing a request deletes every submitted key for that request. Names and connection status are visible in management views; saved values are not returned to those views.
104+
97105
## Reconnect or stop sharing
98106

99107
People can open **Settings → Account → Connected accounts** to view accounts they contributed, including contributions to organizations they have not joined. **Reconnect** starts authorization again. **Disconnect** stops the organization from using that account in subsequent calls.

‎apps/docs/content/docs/workflows/blocks/credential.mdx‎

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Credential
3-
description: Select workspace OAuth credentials or find organization OAuth and MCP account references for downstream blocks.
3+
description: Select OAuth credentials, find Credential Group accounts, and use contributed API keys in downstream blocks.
44
---
55

66
import { Callout } from 'fumadocs-ui/components/callout'
@@ -14,12 +14,12 @@ import {
1414
} from '@/components/workflow-preview'
1515
import { FAQ } from '@/components/ui/faq'
1616

17-
The **Credential block** passes account references to downstream blocks without exposing tokens. **Select Credential** and **List Credentials** use workspace OAuth credentials. When [organization connected accounts](/platform/connected-accounts) is enabled and shared with the workflow's workspace, the organization operations find or list contributed OAuth accounts and managed MCP connections.
17+
The **Credential block** selects accounts and API keys for downstream blocks. **Select Credential** and **List Credentials** use workspace OAuth credentials. When [organization connected accounts](/platform/connected-accounts) is enabled and shared with the workflow's workspace, the Credential Group operations find or list contributed OAuth accounts, managed MCP connections, and named API keys.
1818

1919
<BlockPreview type="credential" />
2020

2121
<Callout>
22-
The Credential block outputs credential **ID references**, not secrets. Downstream blocks receive the ID and resolve the actual OAuth token securely during their own execution.
22+
OAuth and MCP operations return credential **ID references**. **List Credential Group API Keys** returns metadata and IDs. **Get Credential Group API Key** returns a usable `apiKey` value and registers it with secret provenance before releasing the output.
2323
</Callout>
2424

2525
## Configuration
@@ -30,10 +30,12 @@ The **Credential block** passes account references to downstream blocks without
3030
|---|---|
3131
| **Select Credential** | Pick one OAuth credential and output its reference — use this to wire a single credential into downstream blocks |
3232
| **List Credentials** | Return all OAuth credentials in the workspace as an array — use this with a ForEach loop |
33-
| **Find Organization Account** | Find exactly one active OAuth contribution by invitation email and provider |
34-
| **List Organization Accounts** | Return a page of active OAuth contributions, optionally filtered by email and providers |
35-
| **Find Organization MCP Connection** | Find exactly one active managed MCP connection by invitation email and MCP provider |
36-
| **List Organization MCP Connections** | Return a page of active managed MCP connections, optionally filtered by email and provider |
33+
| **Find Credential Group Account** | Find exactly one active OAuth contribution by invitation email and provider |
34+
| **List Credential Group Accounts** | Return a page of active OAuth contributions, optionally filtered by email and providers |
35+
| **Find Credential Group MCP Connection** | Find exactly one active managed MCP connection by invitation email and MCP provider |
36+
| **List Credential Group MCP Connections** | Return a page of active managed MCP connections, optionally filtered by email and provider |
37+
| **List Credential Group API Keys** | Return submitted key IDs and metadata, optionally filtered by key name and invitation email |
38+
| **Get Credential Group API Key** | Resolve the submitted key selected by an explicit credential ID |
3739

3840
### Credential (Select operation)
3941

@@ -85,7 +87,7 @@ Every authorized workflow in an allowed workspace can discover active contributi
8587

8688
### Discover accounts by provider
8789

88-
1. Choose **List Organization Accounts**.
90+
1. Choose **List Credential Group Accounts**.
8991
2. Select a provider such as **Gmail** in **Providers**. Leave it empty to list all allowed providers.
9092
3. Leave **Email** blank. You do not need to know an account's email to discover it.
9193
4. Read **emails** for the provider account addresses, or **credentials** for the corresponding account references.
@@ -97,10 +99,12 @@ Multiple accounts are returned separately, including accounts contributed by the
9799

98100
| Operation | Required fields | Optional fields |
99101
| --- | --- | --- |
100-
| **Find Organization Account** | Email, Provider | — |
101-
| **List Organization Accounts** | — | Email, Providers, Limit, Cursor |
102-
| **Find Organization MCP Connection** | Email, MCP provider | — |
103-
| **List Organization MCP Connections** | — | Email, MCP provider, Limit, Cursor |
102+
| **Find Credential Group Account** | Email, Provider | — |
103+
| **List Credential Group Accounts** | — | Email, Providers, Limit, Cursor |
104+
| **Find Credential Group MCP Connection** | Email, MCP provider | — |
105+
| **List Credential Group MCP Connections** | — | Email, MCP provider, Limit, Cursor |
106+
| **List Credential Group API Keys** | — | Key name, Email, Limit, Cursor |
107+
| **Get Credential Group API Key** | API Key Credential ID | — |
104108

105109
For list operations, **Limit** accepts 1–100 and defaults to 100. **Cursor** accepts the previous page's `nextCursor`.
106110

@@ -110,17 +114,17 @@ Find operations fail unless there is exactly one active matching connection. Lis
110114

111115
### OAuth outputs
112116

113-
**Find Organization Account** returns `credentialId`, `displayName`, `providerId`, and the invitation `email`. Pass `credentialId` into the corresponding integration block's credential field in advanced mode.
117+
**Find Credential Group Account** returns `credentialId`, `displayName`, `providerId`, and the invitation `email`. Pass `credentialId` into the corresponding integration block's credential field in advanced mode.
114118

115-
**List Organization Accounts** returns these account references in `credentials`, with an additional `accountEmail` field containing the email verified by the OAuth provider. The existing `email` field remains the person's invitation address, which can differ from their provider account address. An optional **Email** input continues to filter by that exact invitation address.
119+
**List Credential Group Accounts** returns these account references in `credentials`, with an additional `accountEmail` field containing the email verified by the OAuth provider. The existing `email` field remains the person's invitation address, which can differ from their provider account address. An optional **Email** input continues to filter by that exact invitation address.
116120

117121
The list also returns `emails`, `count`, `hasMore`, and `nextCursor`. `emails` contains the provider account addresses on this page in the same order as `credentials`; it preserves separate accounts even when addresses repeat. `count` is the number of accounts returned on this page. Feed `credentials` into a ForEach loop and use `<loop.currentItem.credentialId>` inside the loop. To process additional pages, pass `nextCursor` into another call with the same filters while `hasMore` is true; the block does not fetch all pages automatically.
118122

119-
For example, name a Credential block **account**, choose **Find Organization Account**, set **Email** to `alex@example.com`, and select **Gmail**. Reference `<account.credentialId>` in a Gmail block to act using Alex's contribution.
123+
For example, name a Credential block **account**, choose **Find Credential Group Account**, set **Email** to `alex@example.com`, and select **Gmail**. Reference `<account.credentialId>` in a Gmail block to act using Alex's contribution.
120124

121125
### Managed MCP outputs
122126

123-
**Find Organization MCP Connection** returns:
127+
**Find Credential Group MCP Connection** returns:
124128

125129
| Output | Type | Description |
126130
| --- | --- | --- |
@@ -131,12 +135,27 @@ For example, name a Credential block **account**, choose **Find Organization Acc
131135
| `mcpServerName` | `string` | Configured MCP server name |
132136
| `toolNames` | `json` | Tool names available to this connection |
133137

134-
**List Organization MCP Connections** returns these objects in `mcpConnections`, plus `count`, `hasMore`, and `nextCursor`. Pagination works the same way as for organization OAuth accounts; `nextCursor` is `null` on the last page.
138+
**List Credential Group MCP Connections** returns these objects in `mcpConnections`, plus `count`, `hasMore`, and `nextCursor`. Pagination works the same way as for organization OAuth accounts; `nextCursor` is `null` on the last page.
135139

136140
<Callout>
137141
For a managed MCP account, use the returned **`credentialId`** to select the person's connection in the MCP Tool block. **`mcpServerId`** identifies the shared provider configuration; it does not identify a person's authorization. No OAuth token or client secret is returned by the Credential block.
138142
</Callout>
139143

144+
### Named API keys
145+
146+
An organization admin defines each key request with a name and an optional description. Each invited person supplies their own value. Names such as `Exa API key` and `EXA_API_KEY` are both accepted; the name is a label, not an environment variable. Renaming a request preserves its IDs and existing submissions.
147+
148+
1. Use **List Credential Group API Keys**, optionally filtering by **Key name** and **Email**. Names match case-insensitively; an unknown name fails instead of listing unrelated keys.
149+
2. Select a returned `credentialId`, or iterate over `apiKeys` with a ForEach loop.
150+
3. In another Credential block named **GetKey**, choose **Get Credential Group API Key**. Set **API Key Credential ID** to the selected ID, such as `<loop.currentItem.credentialId>`.
151+
4. Use `<GetKey.apiKey>` in an integration's API-key field, or `Bearer <GetKey.apiKey>` in an HTTP Authorization header. No environment variable needs to be created.
152+
153+
The list returns `apiKeys`, `count`, `hasMore`, and `nextCursor`. Each entry contains `credentialId`, `optionId`, `name`, and the invitation `email`; it contains no key value. Retrieval returns those fields plus `apiKey`. Workspace access, group status, and the contributor's current connection are checked again on retrieval. The executing user is used for authorization, not to select a key automatically.
154+
155+
API key values are encrypted at rest and registered with secret provenance at retrieval. Retrieval fails if provenance cannot be registered. Values must be 8–4096 characters without surrounding whitespace. Removing a key request removes all submissions for that request; workflows using their IDs then fail.
156+
157+
The previous **Find/List Organization Account** and **Find/List Organization MCP Connection** labels now say **Credential Group**. Their stored operation IDs and existing output fields are unchanged.
158+
140159
## Connection-event triggers
141160

142161
Switch the Credential block to trigger mode to start a workflow when an account connects or a connection form is submitted. Select an **Event** and deploy the workflow in an allowed workspace.
@@ -147,7 +166,7 @@ Switch the Credential block to trigger mode to start a workflow when an account
147166
| **Credential Reconnected** | A person reconnects an existing contribution |
148167
| **Account Connections Submitted** | A person submits the connection form |
149168

150-
Events include `event`, `timestamp`, `email`, `enrollmentId`, `enrollmentStatus`, `credentialGroupId`, and `credentialGroupName`. Added and reconnected events also include account details such as `credentialId`, `provider`, and `displayName`; `mcpServerId` identifies shared configuration for an MCP connection and is `null` for an OAuth account.
169+
Events include `event`, `timestamp`, `email`, `enrollmentId`, `enrollmentStatus`, `credentialGroupId`, and `credentialGroupName`. Adding or replacing an API key also emits the corresponding event with `provider: "api_key"` and metadata only. Added and reconnected events include account details such as `credentialId`, `provider`, and `displayName`; `mcpServerId` identifies shared configuration for an MCP connection and is `null` for an OAuth account.
151170

152171
Each deployed workflow that selects the event in an allowed workspace can receive it. Removing workspace access stops subsequent event delivery. Legacy **Credential Group** blocks must be replaced with the Credential block; they are not automatically converted.
153172

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/** @vitest-environment node */
2+
import { NextRequest, NextResponse } from 'next/server'
3+
import { beforeEach, expect, it, vi } from 'vitest'
4+
5+
const mocks = vi.hoisted(() => ({
6+
authenticate: vi.fn(),
7+
save: vi.fn(),
8+
remove: vi.fn(),
9+
rateLimit: vi.fn(),
10+
}))
11+
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
12+
authenticateCredentialGroupEnrollment: mocks.authenticate,
13+
}))
14+
vi.mock('@/lib/core/rate-limiter', () => ({
15+
enforceUserRateLimit: mocks.rateLimit,
16+
RateLimiter: class {},
17+
}))
18+
vi.mock('@/lib/credential-groups/application/public-enrollment', async () => {
19+
const { credentialGroupEnrollmentOperations } = await import(
20+
'@/lib/credential-groups/application/enrollment-operations'
21+
)
22+
return {
23+
savePublicCredentialGroupApiKey: {
24+
operation: credentialGroupEnrollmentOperations.saveApiKey,
25+
execute: mocks.save,
26+
},
27+
deletePublicCredentialGroupApiKey: {
28+
operation: credentialGroupEnrollmentOperations.deleteApiKey,
29+
execute: mocks.remove,
30+
},
31+
}
32+
})
33+
34+
import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments'
35+
import { DELETE, PUT } from '@/app/api/credential-groups/enroll/[token]/api-keys/[optionId]/route'
36+
37+
const optionId = '00000000-0000-4000-8000-000000000001'
38+
const params = { params: Promise.resolve({ token: 'invite-token', optionId }) }
39+
const principal = {
40+
kind: 'credential_group_enrollment',
41+
userId: 'person-1',
42+
organizationId: 'org-1',
43+
enrollmentId: 'enrollment-1',
44+
credentialGroupId: 'group-1',
45+
email: 'person@example.com',
46+
invitationTokenHash: 'hash',
47+
}
48+
function request(body: string, method = 'PUT') {
49+
return new NextRequest(
50+
`http://localhost/api/credential-groups/enroll/invite-token/api-keys/${optionId}`,
51+
{
52+
method,
53+
headers: { 'content-type': 'application/json' },
54+
...(method === 'PUT' ? { body } : {}),
55+
}
56+
)
57+
}
58+
beforeEach(() => {
59+
vi.resetAllMocks()
60+
mocks.authenticate.mockResolvedValue(principal)
61+
mocks.save.mockResolvedValue({ connected: true })
62+
mocks.remove.mockResolvedValue({ connected: false })
63+
mocks.rateLimit.mockResolvedValue(null)
64+
})
65+
it('authenticates before parsing the submitted key', async () => {
66+
mocks.authenticate.mockResolvedValue(null)
67+
const response = await PUT(request('not-json'), params)
68+
expect(response.status).toBe(401)
69+
expect(mocks.save).not.toHaveBeenCalled()
70+
expect(mocks.rateLimit).not.toHaveBeenCalled()
71+
})
72+
it('rate-limits the verified contributor before parsing', async () => {
73+
mocks.rateLimit.mockResolvedValue(NextResponse.json({ error: 'Rate limited' }, { status: 429 }))
74+
expect((await PUT(request('not-json'), params)).status).toBe(429)
75+
expect(mocks.rateLimit).toHaveBeenCalledWith(
76+
'credential-group-api-key',
77+
'person-1',
78+
expect.anything()
79+
)
80+
expect(mocks.save).not.toHaveBeenCalled()
81+
})
82+
it('returns only connection status and disables caching', async () => {
83+
const response = await PUT(request(JSON.stringify({ value: 'fixture-secret' })), params)
84+
expect(response.status).toBe(200)
85+
expect(await response.json()).toEqual({ connected: true })
86+
expect(response.headers.get('cache-control')).toBe('private, no-store')
87+
expect(mocks.save).toHaveBeenCalledWith({
88+
principal,
89+
input: { optionId, value: 'fixture-secret' },
90+
request: expect.any(NextRequest),
91+
})
92+
})
93+
it.each([
94+
{ value: 'short' },
95+
{ value: 'fixture-secret', userId: 'other-person' },
96+
{ value: 'fixture-secret', unredacted: true },
97+
])('rejects invalid values and ownership overrides %j', async (body) => {
98+
expect((await PUT(request(JSON.stringify(body)), params)).status).toBe(400)
99+
expect(mocks.save).not.toHaveBeenCalled()
100+
})
101+
it('projects an invitation identity mismatch without exposing the submitted value', async () => {
102+
mocks.save.mockRejectedValue(
103+
new CredentialGroupEnrollmentError('Sign in with the invited email', 400)
104+
)
105+
const response = await PUT(request(JSON.stringify({ value: 'fixture-secret' })), params)
106+
expect(response.status).toBe(400)
107+
expect(await response.text()).not.toContain('fixture-secret')
108+
})
109+
it('disconnects only through the authenticated enrollment use case', async () => {
110+
const response = await DELETE(request('', 'DELETE'), params)
111+
expect(await response.json()).toEqual({ connected: false })
112+
expect(mocks.remove).toHaveBeenCalledWith({
113+
principal,
114+
input: { optionId },
115+
request: expect.any(NextRequest),
116+
})
117+
})

0 commit comments

Comments
 (0)