Skip to content

Commit cb67145

Browse files
authored
fix(search): surface Google service account authorization errors (#7893)
* fix(search): surface Google service account authorization errors * fix(search): preserve unrecognized Google token failures * fix(search): handle delegated token errors during setup * fix(search): clarify service account delegation guidance
1 parent bfa86a5 commit cb67145

7 files changed

Lines changed: 409 additions & 15 deletions

File tree

apps/docs/content/docs/search/gmail.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ Enter the Client ID and these exact scopes, separated by a comma:
8484
https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/admin.directory.user.readonly
8585
```
8686

87+
For a key shared with Drive and Calendar, use the [combined six-scope list](/search/google-drive#one-service-account-for-drive-gmail-and-calendar).
88+
8789
Select **Authorize**, then **View details** to confirm both scopes were saved. If the same client also indexes Drive or Calendar, retain those services' required scopes. These Gmail crawl scopes do not allow sending or modifying mail.
8890

8991
If your organization requires multi-party approval, another super administrator must approve the request. Delegation can take up to 24 hours to propagate. See Google's [delegation guide](https://knowledge.workspace.google.com/admin/apps/control-api-access-with-domain-wide-delegation).

apps/docs/content/docs/search/google-calendar.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ Enter the Client ID and these exact comma-separated **OAuth scopes**:
8686
https://www.googleapis.com/auth/calendar.events.readonly,https://www.googleapis.com/auth/admin.directory.user.readonly
8787
```
8888

89+
For a key shared with Drive and Gmail, use the [combined six-scope list](/search/google-drive#one-service-account-for-drive-gmail-and-calendar).
90+
8991
Select **Authorize** and verify both scopes under **View details**. If you reuse a Drive or Gmail service account, retain its existing delegated scopes and add any missing Calendar scopes. An existing Drive authorization alone does not grant Calendar access. Delegation can take up to 24 hours to propagate; organizations requiring multi-party approval need another super administrator to approve the change. See [Google's delegation guide](https://knowledge.workspace.google.com/admin/apps/control-api-access-with-domain-wide-delegation).
9092

9193
The **Directory administrator email** must be an active Workspace administrator with permission to read users. A super administrator has this permission; a custom administrator role can supply it. This identity lists the directory. Sim obtains a separate read-only Calendar token for each selected user; it does not read everyone's events as the administrator.

apps/docs/content/docs/search/google-drive.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ Invite teammates through **Settings → Members → Invite**, using their Google
121121
</Step>
122122
</Steps>
123123

124+
## One service account for Drive, Gmail, and Calendar
125+
126+
You can reuse one JSON key for all three central connectors. Authorize this combined list on the same numeric **Client ID**, and enable **Google Drive API**, **Gmail API**, **Google Calendar API**, and **Admin SDK API** in its Cloud project:
127+
128+
```text
129+
https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/calendar.events.readonly,https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.group.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly
130+
```
131+
132+
Under **View details**, verify all six scopes are saved. Gmail and Calendar succeeding does not verify Drive's group and domain permissions. Each connector still needs its own source configuration in Sim.
133+
124134
## Source options
125135

126136
An admin opens **Settings → Sources → Google Drive** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a connection's **Settings** tab to edit its filters, then select **Save**. **Documents** shows indexed files and **Sync history** shows recent runs.
@@ -147,6 +157,7 @@ Search schedules syncs hourly. Central crawls revisit the selected users' files
147157

148158
| Problem | Next step |
149159
| --- | --- |
160+
| Google rejects authorization (`unauthorized_client`) | In **Manage Domain Wide Delegation**, verify the numeric **Client ID** matches `client_id` in the JSON key uploaded to Sim and all required scopes appear under **View details**. Check pending approval and allow time for recent changes to propagate. Changing the OAuth consent screen alone does not authorize delegation. |
150161
| Directory access failed | Check all four delegated scopes and the **Directory administrator email** user's administrator privileges. A normal Google OAuth credential cannot supply this central Search path. |
151162
| Missing files in a central crawl | Check **Users**, folder and file-type filters, and whether selected active Workspace users can download the file and read its permissions. Opening a file alone does not prove either. Check Sync history for errors. Files reachable only by excluded or inactive accounts are not crawled; files with unverified permissions stay hidden. |
152163
| User not found or inactive | Use a primary email in the same Google Workspace customer. Aliases, external or guest accounts, suspended users, and archived users cannot be selected for crawling. |

apps/sim/lib/knowledge/application/connectors.test.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ vi.mock('@/lib/credentials/application/organization-credentials', () => ({
107107
}))
108108

109109
vi.mock('@/lib/oauth/credential-service', () => ({
110+
ServiceAccountTokenError: class extends Error {
111+
constructor(
112+
readonly statusCode: number,
113+
readonly errorDescription: string,
114+
readonly errorCode?: string
115+
) {
116+
super(errorDescription)
117+
}
118+
},
110119
resolveCredentialTokenBundle: mocks.resolveTokenBundle,
111120
resolveOAuthAccountId: vi.fn(async () => null),
112121
getServiceAccountToken: vi.fn(),
@@ -145,6 +154,7 @@ vi.mock('@/connectors/registry.server', () => ({
145154
'https://www.googleapis.com/auth/admin.directory.group.readonly',
146155
'https://www.googleapis.com/auth/admin.directory.domain.readonly',
147156
],
157+
serviceAccountDelegationScopes: ['https://www.googleapis.com/auth/drive.readonly'],
148158
serviceAccountSubjectFieldId: 'adminEmail',
149159
},
150160
validateConfig: mocks.validateConnectorConfig,
@@ -167,11 +177,20 @@ import {
167177
updateKnowledgeConnectorDocuments,
168178
validateConnectorSourceConfig,
169179
} from '@/lib/knowledge/application/connectors'
180+
import type { ConnectorAccessToken } from '@/lib/knowledge/connectors/access-token'
170181
import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_SEARCH_LENGTH } from '@/lib/knowledge/constants'
182+
import { classifyKnowledgeFailure } from '@/lib/knowledge/orchestration/shared'
183+
import {
184+
getServiceAccountToken,
185+
resolveOAuthAccountId,
186+
ServiceAccountTokenError,
187+
} from '@/lib/oauth/credential-service'
171188
import * as githubInstallation from '@/lib/oauth/github-installation'
172189
import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions'
173190
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
174191
import { confluenceConnectorMeta } from '@/connectors/confluence/meta'
192+
import { gmailConnectorMeta } from '@/connectors/gmail/meta'
193+
import { googleCalendarConnectorMeta } from '@/connectors/google-calendar/meta'
175194
import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta'
176195

177196
const crossWorkspaceContext = {
@@ -1624,6 +1643,240 @@ describe('organization connector credential authorization', () => {
16241643
expect(mocks.resolveTokenBundle).not.toHaveBeenCalled()
16251644
})
16261645

1646+
it.each([googleDriveConnectorMeta, gmailConnectorMeta, googleCalendarConnectorMeta])(
1647+
'projects $name token rejections as safe setup errors',
1648+
async ({ auth }) => {
1649+
mocks.resolveTokenBundle.mockRejectedValueOnce(
1650+
new ServiceAccountTokenError(401, 'private provider payload', 'unauthorized_client')
1651+
)
1652+
const error = await resolveConnectorCredentialAccessToken({ ...input, auth }).catch(
1653+
(error: unknown) => error
1654+
)
1655+
expect(error).toBeInstanceOf(OrchestrationError)
1656+
expect(internalOrchestrationErrorPolicy.project(error)).toMatchObject({
1657+
status: 400,
1658+
body: { error: expect.stringContaining('(unauthorized_client)') },
1659+
})
1660+
expect((error as Error).message).toContain('numeric client ID')
1661+
expect((error as Error).message).toContain(
1662+
"exact domain-wide delegation scopes in this connector's service-account setup section"
1663+
)
1664+
expect((error as Error).message).not.toContain('private provider payload')
1665+
expect(mocks.authorizeOrganizationCredentialUse).toHaveBeenCalledOnce()
1666+
}
1667+
)
1668+
1669+
it.each([
1670+
[400, 'invalid_grant', 'JSON key'],
1671+
[
1672+
400,
1673+
'invalid_scope',
1674+
"exact domain-wide delegation scopes in this connector's service-account setup section",
1675+
],
1676+
[403, 'access_denied', 'API access policies'],
1677+
])('classifies Google %s %s without exposing provider text', async (status, code, guidance) => {
1678+
mocks.resolveTokenBundle.mockRejectedValueOnce(
1679+
new ServiceAccountTokenError(status, 'private provider payload', code)
1680+
)
1681+
const error = await resolveConnectorCredentialAccessToken(input).catch(
1682+
(error: unknown) => error
1683+
)
1684+
expect(error).toMatchObject({ code: 'validation', message: expect.stringContaining(guidance) })
1685+
expect((error as Error).message).not.toContain('private provider payload')
1686+
})
1687+
1688+
it.each([googleDriveConnectorMeta, gmailConnectorMeta, googleCalendarConnectorMeta])(
1689+
'maps $name delegated token failures after directory authorization succeeds',
1690+
async ({ auth }) => {
1691+
vi.mocked(resolveOAuthAccountId).mockResolvedValueOnce({
1692+
accountId: '',
1693+
usedCredentialTable: true,
1694+
credentialId: credential.id,
1695+
credentialType: 'service_account',
1696+
providerId: credential.providerId,
1697+
})
1698+
vi.mocked(getServiceAccountToken).mockRejectedValueOnce(
1699+
new ServiceAccountTokenError(401, 'private delegated response', 'unauthorized_client')
1700+
)
1701+
const resolved = await resolveConnectorCredentialAccessToken({ ...input, auth })
1702+
expect(resolved?.accessToken).toBe('organization-token')
1703+
expect(getServiceAccountToken).not.toHaveBeenCalled()
1704+
if (!resolved?.getDelegatedAccessToken) throw new Error('Expected delegated token resolver')
1705+
await expect(resolved.getDelegatedAccessToken('member@example.com')).rejects.toMatchObject({
1706+
code: 'validation',
1707+
message: expect.stringContaining('(unauthorized_client)'),
1708+
})
1709+
expect(getServiceAccountToken).toHaveBeenCalledWith(
1710+
credential.id,
1711+
auth.mode === 'oauth' ? auth.serviceAccountDelegationScopes : undefined,
1712+
'member@example.com'
1713+
)
1714+
}
1715+
)
1716+
1717+
it('preserves successful delegated token reads and unexpected failures', async () => {
1718+
vi.mocked(resolveOAuthAccountId).mockResolvedValueOnce({
1719+
accountId: '',
1720+
usedCredentialTable: true,
1721+
credentialId: credential.id,
1722+
credentialType: 'service_account',
1723+
providerId: credential.providerId,
1724+
})
1725+
const resolved = await resolveConnectorCredentialAccessToken(input)
1726+
if (!resolved?.getDelegatedAccessToken) throw new Error('Expected delegated token resolver')
1727+
vi.mocked(getServiceAccountToken).mockResolvedValueOnce('delegated-token')
1728+
await expect(resolved.getDelegatedAccessToken('member@example.com')).resolves.toBe(
1729+
'delegated-token'
1730+
)
1731+
for (const error of [
1732+
new ServiceAccountTokenError(401, 'private delegated response', 'unknown-code'),
1733+
new ServiceAccountTokenError(503, 'private delegated response', 'unauthorized_client'),
1734+
new TypeError('Network request failed'),
1735+
]) {
1736+
vi.mocked(getServiceAccountToken).mockRejectedValueOnce(error)
1737+
await expect(resolved.getDelegatedAccessToken('member@example.com')).rejects.toBe(error)
1738+
}
1739+
})
1740+
1741+
it('passes safe delegated errors to configuration validation', async () => {
1742+
vi.mocked(resolveOAuthAccountId).mockResolvedValueOnce({
1743+
accountId: '',
1744+
usedCredentialTable: true,
1745+
credentialId: credential.id,
1746+
credentialType: 'service_account',
1747+
providerId: credential.providerId,
1748+
})
1749+
vi.mocked(getServiceAccountToken).mockRejectedValueOnce(
1750+
new ServiceAccountTokenError(401, 'private delegated response', 'unauthorized_client')
1751+
)
1752+
mocks.validateConnectorConfig.mockImplementationOnce(
1753+
async (_token: string, _config: unknown, context: ConnectorAccessToken) => {
1754+
if (!context.getDelegatedAccessToken) throw new Error('Expected delegated token resolver')
1755+
try {
1756+
await context.getDelegatedAccessToken('member@example.com')
1757+
return { valid: true }
1758+
} catch (error) {
1759+
if (!(error instanceof Error)) throw error
1760+
return { valid: false, error: error.message }
1761+
}
1762+
}
1763+
)
1764+
const rejection = await validateConnectorSourceConfig({
1765+
principal,
1766+
organizationId: 'org',
1767+
actingUserId: principal.userId,
1768+
requestId: 'request',
1769+
sourceConfig: input.sourceConfig,
1770+
connector: {
1771+
connectorType: 'google_drive',
1772+
credentialId: credential.id,
1773+
encryptedApiKey: null,
1774+
accessMode: 'admin',
1775+
} as Parameters<typeof validateConnectorSourceConfig>[0]['connector'],
1776+
})
1777+
expect(rejection).toMatchObject({
1778+
errorCode: 'validation',
1779+
message: expect.stringContaining('(unauthorized_client)'),
1780+
})
1781+
expect(rejection?.message).not.toContain('private delegated response')
1782+
})
1783+
1784+
it.each([400, 401, 403])(
1785+
'preserves unrecognized Google %s responses instead of assuming a configuration error',
1786+
async (status) => {
1787+
for (const code of [undefined, 'unknown-private-code', 'server_error']) {
1788+
const error = new ServiceAccountTokenError(status, 'private provider payload', code)
1789+
mocks.resolveTokenBundle.mockRejectedValueOnce(error)
1790+
await expect(resolveConnectorCredentialAccessToken(input)).rejects.toBe(error)
1791+
expect(internalOrchestrationErrorPolicy.project(error)).toBeNull()
1792+
}
1793+
}
1794+
)
1795+
1796+
it.each([429, 500, 503])(
1797+
'preserves Google %s failures instead of blaming configuration',
1798+
async (status) => {
1799+
const error = new ServiceAccountTokenError(status, 'private provider payload', 'server_error')
1800+
mocks.resolveTokenBundle.mockRejectedValueOnce(error)
1801+
await expect(resolveConnectorCredentialAccessToken(input)).rejects.toBe(error)
1802+
expect(internalOrchestrationErrorPolicy.project(error)).toBeNull()
1803+
}
1804+
)
1805+
1806+
it('preserves unexpected token failures as internal errors', async () => {
1807+
const error = new TypeError('private network failure')
1808+
mocks.resolveTokenBundle.mockRejectedValueOnce(error)
1809+
await expect(resolveConnectorCredentialAccessToken(input)).rejects.toBe(error)
1810+
expect(internalOrchestrationErrorPolicy.project(error)).toBeNull()
1811+
})
1812+
1813+
it('keeps authorization errors actionable through connector creation orchestration', async () => {
1814+
queueTableRows(member, [{ role: 'admin' }])
1815+
queueTableRows(member, [{ role: 'admin' }])
1816+
mocks.resolveKnowledgeBase.mockResolvedValue({
1817+
organizationId: 'org',
1818+
knowledgeBaseId: 'org-index',
1819+
knowledgeBase: { id: 'org-index', name: 'Search', isSearchIndex: true },
1820+
})
1821+
mocks.resolveTokenBundle.mockRejectedValueOnce(
1822+
new ServiceAccountTokenError(401, 'private provider payload', 'unauthorized_client')
1823+
)
1824+
mocks.createConnector.mockImplementationOnce(
1825+
async (createInput: { resolveAccessToken(id: string): Promise<unknown> }) => {
1826+
try {
1827+
await createInput.resolveAccessToken(credential.id)
1828+
throw new Error('Unexpected successful token exchange')
1829+
} catch (error) {
1830+
return classifyKnowledgeFailure(error, 'request', 'Create connector')
1831+
}
1832+
}
1833+
)
1834+
const error = await createKnowledgeConnector
1835+
.execute({
1836+
principal,
1837+
input: {
1838+
knowledgeBaseId: 'org-index',
1839+
assertedOrganizationId: 'org',
1840+
connectorType: 'google_drive',
1841+
credentialId: credential.id,
1842+
accessMode: 'admin',
1843+
sourceConfig: input.sourceConfig,
1844+
syncIntervalMinutes: 60,
1845+
},
1846+
})
1847+
.catch((error: unknown) => error)
1848+
expect(internalOrchestrationErrorPolicy.project(error)).toMatchObject({
1849+
status: 400,
1850+
body: { error: expect.stringContaining('(unauthorized_client)') },
1851+
})
1852+
expect(mocks.recordAudit).not.toHaveBeenCalled()
1853+
})
1854+
1855+
it('returns an actionable error before saving a configuration edit', async () => {
1856+
mocks.resolveTokenBundle.mockRejectedValueOnce(
1857+
new ServiceAccountTokenError(401, 'private provider payload', 'unauthorized_client')
1858+
)
1859+
await expect(
1860+
validateConnectorSourceConfig({
1861+
principal,
1862+
organizationId: 'org',
1863+
actingUserId: principal.userId,
1864+
requestId: 'request',
1865+
sourceConfig: input.sourceConfig,
1866+
connector: {
1867+
connectorType: 'google_drive',
1868+
credentialId: credential.id,
1869+
encryptedApiKey: null,
1870+
accessMode: 'admin',
1871+
} as Parameters<typeof validateConnectorSourceConfig>[0]['connector'],
1872+
})
1873+
).rejects.toMatchObject({
1874+
code: 'validation',
1875+
message: expect.stringContaining('(unauthorized_client)'),
1876+
})
1877+
expect(mocks.validateConnectorConfig).not.toHaveBeenCalled()
1878+
})
1879+
16271880
it('does not mint a token after the credential creator leaves the organization', async () => {
16281881
mocks.resolveTokenIdentity.mockResolvedValueOnce(null)
16291882
await expect(resolveConnectorCredentialAccessToken(input)).resolves.toBeNull()

0 commit comments

Comments
 (0)