|
1 | 1 | import { createLogger } from '@sim/logger' |
2 | | -import { generateInternalToken } from '@/lib/auth/internal' |
3 | | -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' |
| 2 | +import { AuthType } from '@/lib/auth/hybrid' |
| 3 | +import { bindExecutorManagedOAuthDelegation } from '@/lib/credentials/application/managed-oauth-delegation' |
| 4 | +import { |
| 5 | + type CredentialTokenPayload, |
| 6 | + resolveCredentialAccessToken, |
| 7 | +} from '@/lib/oauth/token-resolution' |
| 8 | +import type { ExecutorDelegationOrigin } from '@/executor/types' |
4 | 9 |
|
5 | 10 | const logger = createLogger('ExecutorCredentialToken') |
6 | 11 |
|
7 | | -/** |
8 | | - * Fetches a credential's access token from the app rather than resolving it here. |
9 | | - * |
10 | | - * Refreshing an OAuth token needs the provider's client id and secret, read through |
11 | | - * `requireOAuthClientCapability`, which THROWS when they are absent. Only the app |
12 | | - * container loads those (from `SIM_ENV_SECRET_ID`); workflow execution runs in a |
13 | | - * Trigger.dev worker whose environment does not carry them. Resolving in-process there |
14 | | - * turns every credential whose access token has expired into a refresh failure, and a |
15 | | - * still-valid token hides it until the token lapses. |
16 | | - * |
17 | | - * See `.claude/rules/sim-architecture.md`, "The app/worker runtime boundary". |
18 | | - * |
19 | | - * The route authorizes the credential itself, so this never widens access. |
20 | | - */ |
21 | | -export async function fetchCredentialAccessToken(params: { |
| 12 | +export interface ResolveExecutorCredentialTokenParams { |
22 | 13 | requestId: string |
23 | 14 | credentialId: string |
24 | | - userId: string |
| 15 | + userId?: string |
25 | 16 | workflowId?: string |
26 | | -}): Promise<string> { |
27 | | - const { requestId, credentialId, userId, workflowId } = params |
| 17 | + /** Tool consuming the token; required by the managed-OAuth scope policy. */ |
| 18 | + toolId?: string |
| 19 | + /** Display label for the thrown failure ("Failed to obtain credential for X: ..."). */ |
| 20 | + toolLabel?: string |
| 21 | + scopes?: string[] |
| 22 | + impersonateEmail?: string |
| 23 | + /** Asserts the acting user alongside the credential lookup, mirroring the HTTP surface. */ |
| 24 | + enforceCredentialAccess?: boolean |
| 25 | + /** Proves managed-credential delegations in-process when the run carries one. */ |
| 26 | + executorDelegationOrigin?: ExecutorDelegationOrigin |
| 27 | +} |
28 | 28 |
|
29 | | - const url = new URL('/api/auth/oauth/token', getInternalApiBaseUrl()) |
30 | | - if (workflowId) url.searchParams.set('workflowId', workflowId) |
| 29 | +/** |
| 30 | + * Resolves a credential's access token in-process for server-side workflow |
| 31 | + * execution. Goes through the same authorized application dispatch as |
| 32 | + * `POST /api/auth/oauth/token` (`resolveCredentialAccessToken`), replacing the |
| 33 | + * HTTP hop the executor used to make to its own route — both runtimes hold the |
| 34 | + * OAuth client config the refresh branch needs, so authorization, refresh, and |
| 35 | + * audit run identically without the round trip. |
| 36 | + */ |
| 37 | +export async function resolveExecutorCredentialToken( |
| 38 | + params: ResolveExecutorCredentialTokenParams |
| 39 | +): Promise<CredentialTokenPayload> { |
| 40 | + const { requestId, credentialId, userId, workflowId, toolId, executorDelegationOrigin } = params |
31 | 41 |
|
32 | | - const headers: Record<string, string> = { 'Content-Type': 'application/json' } |
33 | | - try { |
34 | | - headers.Authorization = `Bearer ${await generateInternalToken(userId)}` |
35 | | - } catch (_e) { |
36 | | - // Swallow mint errors; the request then fails authentication and reports upstream. |
| 42 | + if (executorDelegationOrigin && !executorDelegationOrigin.currentWorkflow) { |
| 43 | + throw new Error('Managed credential delegation is missing current workflow authority') |
37 | 44 | } |
38 | 45 |
|
39 | | - // boundary-raw-fetch: same-origin token route, authenticated by the internal JWT minted above |
40 | | - const response = await fetch(url.toString(), { |
41 | | - method: 'POST', |
42 | | - headers, |
43 | | - body: JSON.stringify({ credentialId, ...(workflowId ? { workflowId } : {}) }), |
| 46 | + const result = await resolveCredentialAccessToken({ |
| 47 | + requestId, |
| 48 | + credentialId, |
| 49 | + workflowId, |
| 50 | + toolId, |
| 51 | + scopes: params.scopes, |
| 52 | + impersonateEmail: params.impersonateEmail, |
| 53 | + callerUserId: userId && params.enforceCredentialAccess ? userId : undefined, |
| 54 | + authenticate: () => ({ |
| 55 | + success: true, |
| 56 | + ...(userId ? { userId } : {}), |
| 57 | + authType: AuthType.INTERNAL_JWT, |
| 58 | + }), |
| 59 | + ...(executorDelegationOrigin |
| 60 | + ? { |
| 61 | + resolveManagedPrincipal: (managedCredentialId: string) => |
| 62 | + bindExecutorManagedOAuthDelegation(executorDelegationOrigin, managedCredentialId), |
| 63 | + } |
| 64 | + : {}), |
44 | 65 | }) |
45 | 66 |
|
46 | | - if (!response.ok) { |
47 | | - const errorText = await response.text() |
48 | | - logger.error(`[${requestId}] Credential token request failed`, { |
49 | | - status: response.status, |
| 67 | + if (!result.ok) { |
| 68 | + logger.error(`[${requestId}] Credential token resolution failed`, { |
| 69 | + status: result.status, |
50 | 70 | credentialId, |
| 71 | + ...(result.code ? { code: result.code } : {}), |
51 | 72 | }) |
52 | | - let message = errorText |
53 | | - try { |
54 | | - const parsed = JSON.parse(errorText) |
55 | | - if (parsed.error) message = parsed.error |
56 | | - } catch { |
57 | | - // Use raw text |
58 | | - } |
59 | | - throw new Error(message) |
| 73 | + throw new Error( |
| 74 | + `Failed to obtain credential for ${params.toolLabel ?? credentialId}: ${result.error}` |
| 75 | + ) |
60 | 76 | } |
61 | 77 |
|
62 | | - const { accessToken } = (await response.json()) as { accessToken?: string } |
63 | | - if (!accessToken) { |
64 | | - throw new Error('Credential token response carried no access token') |
65 | | - } |
66 | | - return accessToken |
| 78 | + return result.token |
67 | 79 | } |
0 commit comments