Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e9801fb
feat(credentials): add managed credential groups
TheodoreSpeaks Aug 14, 2026
bfbbac7
fix(audit): sync credential group mock
TheodoreSpeaks Aug 14, 2026
3d5302c
fix(credentials): serialize enrollment revocation
TheodoreSpeaks Aug 14, 2026
93fbc94
fix(credentials): isolate managed delegation
TheodoreSpeaks Aug 14, 2026
44b0b62
fix(credentials): serialize invitation lifecycle
TheodoreSpeaks Aug 14, 2026
4d550f3
fix(credentials): preserve enrollment lifecycle
TheodoreSpeaks Aug 14, 2026
38cd914
refactor(credentials): migrate groups to application boundary
TheodoreSpeaks Aug 14, 2026
110ea5d
fix(credentials): serialize enrollment readiness
TheodoreSpeaks Aug 14, 2026
cfe47cf
fix(credentials): preserve completed reconnect state
TheodoreSpeaks Aug 14, 2026
addc33a
fix(credentials): revalidate policy before grant persistence
TheodoreSpeaks Aug 14, 2026
5197609
fix(credentials): prioritize expired invitations
TheodoreSpeaks Aug 14, 2026
38bd2b3
fix(credentials): redirect unavailable oauth starts
TheodoreSpeaks Aug 14, 2026
0fe2186
fix(credentials): clarify managed oauth boundaries
TheodoreSpeaks Aug 14, 2026
61f50c1
fix(settings): complete feature flag test mocks
TheodoreSpeaks Aug 14, 2026
cea2312
fix(credentials): clarify enrollment actions and entitlement errors
TheodoreSpeaks Aug 15, 2026
f7e498f
fix(credentials): preserve entitlement failure reasons
TheodoreSpeaks Aug 15, 2026
3700b3a
fix(credentials): refine managed oauth flow
TheodoreSpeaks Aug 15, 2026
c884334
fix(lint): use optional chain for pagination
TheodoreSpeaks Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default
# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams
# FORKING_ENABLED= # Workspace forks
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only

# Instance organization (Optional). Most enterprise features read their settings from the
Expand Down
38 changes: 37 additions & 1 deletion apps/sim/app/api/auth/oauth/credentials/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
* @vitest-environment node
*/

import { hybridAuthMockFns, permissionsMock, workflowsUtilsMock } from '@sim/testing'
import {
dbChainMockFns,
hybridAuthMockFns,
permissionsMock,
resetDbChainMock,
workflowsUtilsMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

Expand All @@ -26,6 +32,7 @@ describe('OAuth Credentials API Route', () => {

beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})

it('should handle unauthenticated user', async () => {
Expand Down Expand Up @@ -90,4 +97,33 @@ describe('OAuth Credentials API Route', () => {
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(0)
})

it('does not expose a managed credential requested by exact ID', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'managed-credential-1',
workspaceId: 'workspace-1',
type: 'managed_oauth',
displayName: 'Managed Gmail',
providerId: 'google-email',
accountId: null,
updatedAt: new Date('2026-01-01T00:00:00Z'),
accountProviderId: null,
accountScope: null,
accountUpdatedAt: null,
},
])

const response = await GET(
createMockRequestWithQuery('GET', '?credentialId=managed-credential-1')
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ credentials: [] })
})
})
188 changes: 187 additions & 1 deletion apps/sim/app/api/auth/oauth/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ import {
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({
const {
mockAuthenticateManagedOAuthDelegation,
mockAuthorizeCredentialUse,
mockGetToolMetadata,
mockResolveManagedOAuthCredentialToken,
mockResolveServiceAccountToken,
} = vi.hoisted(() => ({
mockAuthenticateManagedOAuthDelegation: vi.fn(),
mockAuthorizeCredentialUse: vi.fn(),
mockGetToolMetadata: vi.fn(),
mockResolveManagedOAuthCredentialToken: vi.fn(),
mockResolveServiceAccountToken: vi.fn(),
}))

Expand All @@ -27,6 +36,17 @@ vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUseForAuth: mockAuthorizeCredentialUse,
}))

vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({
authenticateManagedOAuthDelegation: mockAuthenticateManagedOAuthDelegation,
InvalidManagedOAuthDelegationError: class InvalidManagedOAuthDelegationError extends Error {},
}))

vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({
resolveManagedOAuthCredentialToken: { execute: mockResolveManagedOAuthCredentialToken },
}))

vi.mock('@/tools/metadata', () => ({ getToolMetadata: mockGetToolMetadata }))

import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { GET, POST } from '@/app/api/auth/oauth/token/route'

Expand Down Expand Up @@ -108,6 +128,38 @@ describe('OAuth Token API Routes', () => {
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
})

it('does not authenticate managed delegation for an ordinary OAuth credential', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'internal_jwt',
requesterUserId: 'workflow-owner-id',
credentialOwnerUserId: 'workflow-owner-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
accessToken: 'fresh-token',
refreshed: false,
})

const response = await POST(
createMockRequest(
'POST',
{ credentialId: 'credential-id', workflowId: 'workflow-id' },
{ 'x-sim-managed-oauth-delegation': 'Bearer stale-delegation' }
)
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({ accessToken: 'fresh-token' })
expect(mockAuthenticateManagedOAuthDelegation).not.toHaveBeenCalled()
})

it('should handle missing credentialId', async () => {
const req = createMockRequest('POST', {})

Expand Down Expand Up @@ -332,6 +384,140 @@ describe('OAuth Token API Routes', () => {
)
})

describe('managed OAuth path', () => {
const managedCredential = {
accountId: '',
credentialId: 'managed-credential-id',
credentialType: 'managed_oauth',
providerId: 'google-email',
workspaceId: 'workspace-id',
usedCredentialTable: true,
}

beforeEach(() => {
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce(managedCredential)
mockGetToolMetadata.mockReturnValue({
oauth: {
required: true,
provider: 'google-email',
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
},
})
})

it('fails closed when workflow delegation is missing', async () => {
const response = await POST(
createMockRequest('POST', {
credentialId: 'managed-credential-id',
toolId: 'gmail_read',
})
)

expect(response.status).toBe(403)
await expect(response.json()).resolves.toMatchObject({
code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED',
})
expect(mockResolveManagedOAuthCredentialToken).not.toHaveBeenCalled()
})

it('resolves a manually supplied managed credential ID with scoped delegation', async () => {
const principal = {
kind: 'delegated' as const,
serviceId: 'executor' as const,
subjectUserId: 'user-id',
workspaceId: 'workspace-id',
delegationId: 'delegation-id',
audience: 'sim:managed-oauth-credentials',
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
resourceScope: { credentialId: 'managed-credential-id' },
delegationContext: {
kind: 'workflow_execution' as const,
workflowId: 'workflow-id',
},
}
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
accessToken: 'managed-access-token',
refreshed: false,
})

const response = await POST(
createMockRequest(
'POST',
{ credentialId: 'managed-credential-id', toolId: 'gmail_read' },
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
)
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ accessToken: 'managed-access-token' })
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
principal,
input: {
credentialId: 'managed-credential-id',
expectedProviderId: 'google-email',
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
toolId: 'gmail_read',
},
request: expect.any(NextRequest),
})
})

it('uses the trusted provider scope policy when a Slack tool omits narrower scopes', async () => {
mockGetToolMetadata.mockReturnValueOnce({
oauth: {
required: true,
provider: 'slack',
},
})
const principal = {
kind: 'delegated' as const,
serviceId: 'executor' as const,
subjectUserId: 'user-id',
workspaceId: 'workspace-id',
delegationId: 'delegation-id',
audience: 'sim:managed-oauth-credentials',
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
resourceScope: { credentialId: 'managed-credential-id' },
delegationContext: {
kind: 'workflow_execution' as const,
workflowId: 'workflow-id',
},
}
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
accessToken: 'managed-slack-token',
refreshed: false,
})

const response = await POST(
createMockRequest(
'POST',
{ credentialId: 'managed-credential-id', toolId: 'slack_message' },
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
)
)

expect(response.status).toBe(200)
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
principal,
input: {
credentialId: 'managed-credential-id',
expectedProviderId: 'slack',
requiredScopes: expect.arrayContaining([
'channels:read',
'channels:history',
'chat:write',
]),
toolId: 'slack_message',
},
request: expect.any(NextRequest),
})
})
})

describe('credentialAccountUserId + providerId path', () => {
it('should reject unauthenticated requests', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
Expand Down
Loading
Loading