Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 11 additions & 7 deletions .claude/rules/sim-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,25 @@ Every export of a `'use client'` module becomes a *client reference* on the serv
Server code runs in two runtimes with **different environments**. The app container loads the
full env from `SIM_ENV_SECRET_ID` (Secrets Manager). Trigger.dev workers — which execute
workflows, so every block handler and every tool call — get their env from the Trigger.dev
dashboard, and `trigger.config.ts` syncs only `DB_APP_NAME`. The repo cannot see what the
dashboard holds.
dashboard; `trigger.config.ts` additionally syncs `DB_APP_NAME`, `TRIGGER_DEV_ENABLED`, and the
`FUNCTION_EXECUTION_ENV` vars. The repo cannot see what the dashboard holds.

So before replacing a worker's HTTP call to our own API with an in-process call, ask what env
that work reads *on the app side*. Anything gated by a `require*Capability` helper is the sharp
case: those **throw** when the variable is absent (`requireOAuthClientCapability` →
`EnvCapabilityConfigurationError`), and the throw may be caught and reported as something
unrelated. OAuth token refresh is the known example — moving it into the worker turns every
expired credential into `Failed to refresh access token`, while a still-valid token hides the
bug entirely, so it surfaces hours later and only for whoever's token lapsed first.
unrelated — an in-worker OAuth refresh missing a provider's client pair reports every expired
credential as `Failed to refresh access token`, while a still-valid token hides the bug until it
lapses. The required step before such a conversion is verifying the dashboard env holds every
variable the moved code reads (for OAuth refresh: the `OAUTH_CLIENT_CAPABILITIES` key pairs in
`packages/deployment-config/src/env-capabilities.ts`).

An in-process conversion is safe when the same work already runs in that runtime (the agent
block has always called `executeProviderRequest` in-process, so router and evaluator joining it
is proven), or when the caller and the callee are both the app (a route calling a lib module, an
RSC prefetch reading the data layer). It is not safe on reasoning alone.
is proven; connector sync refreshing OAuth tokens in-worker is what proved credential-token
resolution could move in-process), or when the caller and the callee are both the app (a route
calling a lib module, an RSC prefetch reading the data layer). It is not safe on reasoning
alone — verify the env, then convert.

## Feature Organization

Expand Down
156 changes: 13 additions & 143 deletions apps/sim/app/api/auth/oauth/token/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import {
resolvePrincipalSubject,
type WorkflowExecutionDelegatedPrincipal,
} from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
Expand All @@ -14,20 +10,15 @@ import {
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { authenticateManagedOAuthDelegation } from '@/lib/credentials/application/managed-oauth-delegation'
import { getCredential, getOAuthToken } from '@/lib/oauth/credential-service'
import {
authenticateManagedOAuthDelegation,
InvalidManagedOAuthDelegationError,
} from '@/lib/credentials/application/managed-oauth-delegation'
import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token'
import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth'
import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
completeOAuthCredentialToken,
resolveCredentialAccessToken,
} from '@/lib/oauth/token-resolution'
import { captureServerEvent } from '@/lib/posthog/server'
import { getToolMetadata } from '@/tools/metadata'

export const dynamic = 'force-dynamic'

Expand Down Expand Up @@ -130,142 +121,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
}

const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null
if (resolved?.credentialType === 'managed_oauth' && resolved.credentialId) {
const managedOAuthDelegation = parsed.data.headers?.[MANAGED_OAUTH_DELEGATION_HEADER]
if (!managedOAuthDelegation) {
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED',
error: 'Managed credentials can only be used by an authenticated workflow execution',
},
{ status: 403 }
)
}

let managedOAuthPrincipal: WorkflowExecutionDelegatedPrincipal
try {
managedOAuthPrincipal = await authenticateManagedOAuthDelegation(
managedOAuthDelegation,
resolved.credentialId
)
} catch (error) {
if (!(error instanceof InvalidManagedOAuthDelegationError)) throw error
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_DELEGATION_INVALID',
error: error.message,
},
{ status: 401 }
)
}
if (!toolId) {
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_TOOL_REQUIRED',
error: 'A tool ID is required to use a managed credential',
},
{ status: 400 }
)
}

const toolMetadata = getToolMetadata(toolId)
if (!toolMetadata?.oauth?.required) {
logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId })
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED',
error: 'This tool is not configured to use managed credentials',
},
{ status: 500 }
)
}
const requiredScopes =
toolMetadata.oauth.requiredScopes ??
getCanonicalScopesForProvider(toolMetadata.oauth.provider)
if (requiredScopes.length === 0) {
logger.error(`[${requestId}] Tool has no trusted OAuth scope policy`, {
toolId,
providerId: toolMetadata.oauth.provider,
})
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED',
error: 'This tool is not configured to use managed credentials',
},
{ status: 500 }
)
}

try {
const result = await resolveManagedOAuthCredentialToken.execute({
principal: managedOAuthPrincipal,
input: {
credentialId: resolved.credentialId,
expectedProviderId: toolMetadata.oauth.provider,
requiredScopes,
toolId,
},
request,
})

const managedOAuthSubject = resolvePrincipalSubject(managedOAuthPrincipal)
if (managedOAuthSubject?.kind === 'sim_user') {
captureServerEvent(
managedOAuthSubject.userId,
'credential_used',
{
credential_type: 'managed_oauth',
provider_id: toolMetadata.oauth.provider,
workspace_id: managedOAuthPrincipal.workspaceId,
},
{ groups: { workspace: managedOAuthPrincipal.workspaceId } }
)
}

return NextResponse.json(
{
accessToken: result.accessToken,
...(result.idToken ? { idToken: result.idToken } : {}),
},
{ status: 200 }
)
} catch (error) {
if (error instanceof ManagedOAuthCredentialError) {
logger.warn(`[${requestId}] Managed OAuth credential rejected`, {
credentialId: resolved.credentialId,
code: error.code,
})
return NextResponse.json(
{ code: error.code, error: error.message },
{ status: error.statusCode }
)
}

const orchestrationError = asOrchestrationError(error)
if (orchestrationError) {
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_UNAUTHORIZED',
error: orchestrationError.message,
},
{ status: statusForOrchestrationError(orchestrationError.code) }
)
}
throw error
}
}

const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
const result = await resolveCredentialToken(auth, {
const managedOAuthDelegation = parsed.data.headers?.[MANAGED_OAUTH_DELEGATION_HEADER]
const result = await resolveCredentialAccessToken({
requestId,
credentialId,
workflowId: workflowId ?? undefined,
toolId,
scopes,
impersonateEmail,
callerUserId,
auditRequest: request,
resolvedCredential: resolved,
authenticate: () => checkSessionOrInternalAuth(request, { requireWorkflowId: false }),
resolveManagedPrincipal: managedOAuthDelegation
? (managedCredentialId: string) =>
authenticateManagedOAuthDelegation(managedOAuthDelegation, managedCredentialId)
: undefined,
})

if (!result.ok) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)

vi.mock('@/executor/utils/credential-token', () => ({
fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
resolveExecutorCredentialToken: vi.fn().mockResolvedValue({ accessToken: 'mock-access-token' }),
}))

vi.mock('@/lib/credentials/access', () => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/executor/handlers/router/router-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
vi.mock('@/lib/core/security/encryption', () => encryptionMock)

vi.mock('@/executor/utils/credential-token', () => ({
fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
resolveExecutorCredentialToken: vi.fn().mockResolvedValue({ accessToken: 'mock-access-token' }),
}))

vi.mock('@/lib/credentials/access', () => ({
Expand Down
146 changes: 146 additions & 0 deletions apps/sim/executor/utils/credential-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutorDelegationOrigin } from '@/executor/types'

const { mockBindExecutorManagedOAuthDelegation, mockResolveCredentialAccessToken } = vi.hoisted(
() => ({
mockBindExecutorManagedOAuthDelegation: vi.fn(),
mockResolveCredentialAccessToken: vi.fn(),
})
)

vi.mock('@/lib/oauth/token-resolution', () => ({
resolveCredentialAccessToken: mockResolveCredentialAccessToken,
}))

vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({
bindExecutorManagedOAuthDelegation: mockBindExecutorManagedOAuthDelegation,
}))

import { resolveExecutorCredentialToken } from '@/executor/utils/credential-token'

const ORIGIN: ExecutorDelegationOrigin = {
subjectUserId: 'user-1',
workflowId: 'wf-1',
executionId: 'exec-1',
currentWorkflow: { workflowId: 'wf-1' },
} as ExecutorDelegationOrigin

describe('resolveExecutorCredentialToken', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveCredentialAccessToken.mockResolvedValue({
ok: true,
token: { accessToken: 'fresh' },
})
})

it('dispatches with an internal-JWT auth result for the executing user', async () => {
await resolveExecutorCredentialToken({
requestId: 'req-1',
credentialId: 'cred-1',
userId: 'user-1',
workflowId: 'wf-1',
toolId: 'gmail_read',
})

const input = mockResolveCredentialAccessToken.mock.calls[0][0]
expect(input).toMatchObject({
requestId: 'req-1',
credentialId: 'cred-1',
workflowId: 'wf-1',
toolId: 'gmail_read',
})
await expect(input.authenticate()).toEqual({
success: true,
userId: 'user-1',
authType: 'internal_jwt',
})
expect(input.resolveManagedPrincipal).toBeUndefined()
})

it('asserts the caller only when the run enforces credential access', async () => {
await resolveExecutorCredentialToken({
requestId: 'req-1',
credentialId: 'cred-1',
userId: 'user-1',
})
expect(mockResolveCredentialAccessToken.mock.calls[0][0].callerUserId).toBeUndefined()

await resolveExecutorCredentialToken({
requestId: 'req-2',
credentialId: 'cred-1',
userId: 'user-1',
enforceCredentialAccess: true,
})
expect(mockResolveCredentialAccessToken.mock.calls[1][0].callerUserId).toBe('user-1')
})

it('wires the managed delegation binder only when the run carries an origin', async () => {
mockBindExecutorManagedOAuthDelegation.mockResolvedValue({ kind: 'delegated' })

await resolveExecutorCredentialToken({
requestId: 'req-1',
credentialId: 'cred-1',
userId: 'user-1',
executorDelegationOrigin: ORIGIN,
})

const input = mockResolveCredentialAccessToken.mock.calls[0][0]
expect(input.resolveManagedPrincipal).toBeTypeOf('function')
await input.resolveManagedPrincipal('managed-1')
expect(mockBindExecutorManagedOAuthDelegation).toHaveBeenCalledWith(ORIGIN, 'managed-1')
})

it('fails before dispatch when the origin lacks current workflow authority', async () => {
await expect(
resolveExecutorCredentialToken({
requestId: 'req-1',
credentialId: 'cred-1',
userId: 'user-1',
executorDelegationOrigin: { ...ORIGIN, currentWorkflow: undefined },
})
).rejects.toThrow('Managed credential delegation is missing current workflow authority')
expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled()
})

it('throws the executeTool error contract with the tool label on failure', async () => {
mockResolveCredentialAccessToken.mockResolvedValue({
ok: false,
status: 401,
error: 'Failed to refresh access token',
})

await expect(
resolveExecutorCredentialToken({
requestId: 'req-1',
credentialId: 'cred-1',
userId: 'user-1',
toolLabel: 'Gmail Read',
})
).rejects.toThrow('Failed to obtain credential for Gmail Read: Failed to refresh access token')
})

it('returns the full token payload untouched', async () => {
const token = {
accessToken: 'fresh',
idToken: 'id-1',
instanceUrl: 'https://contoso.crm.dynamics.com',
apiDomain: 'desk.zoho.com',
cloudId: 'cloud-1',
domain: 'example.atlassian.net',
authStyle: 'x-api-token',
}
mockResolveCredentialAccessToken.mockResolvedValue({ ok: true, token })

await expect(
resolveExecutorCredentialToken({
requestId: 'req-1',
credentialId: 'cred-1',
userId: 'user-1',
})
).resolves.toEqual(token)
})
})
Loading
Loading