diff --git a/packages/worker/src/app/handlers/account-secrets.node.test.ts b/packages/worker/src/app/handlers/account-secrets.node.test.ts index 23d1e20d7a..9f45fb9f63 100644 --- a/packages/worker/src/app/handlers/account-secrets.node.test.ts +++ b/packages/worker/src/app/handlers/account-secrets.node.test.ts @@ -230,3 +230,83 @@ test('connect oauth returns direct host approval links for saved token secrets', }), ) }) + +test('connect oauth omits direct host approval links when hosts are already approved', async () => { + mockModule.listSecrets.mockResolvedValueOnce([ + { + name: 'teslaAccessToken', + scope: 'user', + description: '', + appId: null, + allowedHosts: [ + 'auth.tesla.com', + 'fleet-api.prd.na.vn.cloud.tesla.com', + 'fleet-auth.prd.vn.cloud.tesla.com', + ], + allowedCapabilities: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + { + name: 'teslaRefreshToken', + scope: 'user', + description: '', + appId: null, + allowedHosts: [ + 'auth.tesla.com', + 'fleet-api.prd.na.vn.cloud.tesla.com', + 'fleet-auth.prd.vn.cloud.tesla.com', + ], + allowedCapabilities: [], + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + ttlMs: null, + }, + ]) + + const handler = createAccountSecretsApiHandler(createEnv()) + const response = await handler.action({ + request: new Request('https://example.com/account/secrets.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'connect_oauth', + provider: 'Tesla', + tokenUrl: 'https://auth.tesla.com/oauth2/v3/token', + apiBaseUrl: 'https://fleet-api.prd.na.vn.cloud.tesla.com', + flow: 'pkce', + clientIdValueName: 'tesla-client-id', + accessTokenSecretName: 'teslaAccessToken', + refreshTokenSecretName: 'teslaRefreshToken', + allowedHosts: [ + 'fleet-api.prd.na.vn.cloud.tesla.com', + 'fleet-auth.prd.vn.cloud.tesla.com', + ], + tokenPayload: { + access_token: 'access-token', + refresh_token: 'refresh-token', + }, + }), + }), + params: {}, + } as never) + + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload).toMatchObject({ + ok: true, + accessTokenSaved: true, + refreshTokenSaved: true, + hostApprovalLinks: [], + connectorName: 'Tesla', + }) + expect(payload.allowedHosts).toEqual( + expect.arrayContaining([ + 'auth.tesla.com', + 'fleet-api.prd.na.vn.cloud.tesla.com', + 'fleet-auth.prd.vn.cloud.tesla.com', + ]), + ) + expect(mockModule.createSecretHostApprovalToken).not.toHaveBeenCalled() +}) diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts index b876bf86f5..04547402a3 100644 --- a/packages/worker/src/app/handlers/account-secrets.ts +++ b/packages/worker/src/app/handlers/account-secrets.ts @@ -315,6 +315,16 @@ async function handleConnectOauthAction(input: { ) } + const approvedHostsBySecretName = new Map( + ( + await listSecrets({ + env: input.env, + userId: input.user.mcpUser.userId, + scope: 'user', + storageContext: null, + }) + ).map((secret) => [secret.name, new Set(secret.allowedHosts)]), + ) const accessSaved = await saveSecret({ env: input.env, userId: input.user.mcpUser.userId, @@ -381,6 +391,7 @@ async function handleConnectOauthAction(input: { userId: input.user.mcpUser.userId, allowedHosts, secretNames: approvalSecretNames, + approvedHostsBySecretName, }) } catch (error) { console.error('Failed to build OAuth host approval links.', { @@ -406,6 +417,7 @@ async function buildConnectOauthHostApprovalLinks(input: { userId: string allowedHosts: Array secretNames: Array + approvedHostsBySecretName?: Map> }) { const uniqueHosts = Array.from(new Set(input.allowedHosts)).slice( 0, @@ -415,6 +427,18 @@ async function buildConnectOauthHostApprovalLinks(input: { 0, maxConnectOauthApprovalSecrets, ) + const approvedHostsBySecretName = + input.approvedHostsBySecretName ?? + new Map( + ( + await listSecrets({ + env: input.env, + userId: input.userId, + scope: 'user', + storageContext: null, + }) + ).map((secret) => [secret.name, new Set(secret.allowedHosts)]), + ) const baseUrl = getAppBaseUrl({ env: input.env, requestUrl: input.request.url, @@ -422,6 +446,9 @@ async function buildConnectOauthHostApprovalLinks(input: { const links = await Promise.all( uniqueSecretNames.flatMap((secretName) => uniqueHosts.map(async (host) => { + if (approvedHostsBySecretName.get(secretName)?.has(host)) { + return null + } const token = await createSecretHostApprovalToken(input.env, { userId: input.userId, name: secretName, @@ -444,12 +471,14 @@ async function buildConnectOauthHostApprovalLinks(input: { }), ), ) - return links.sort((left, right) => { - return ( - left.secretName.localeCompare(right.secretName) || - left.host.localeCompare(right.host) - ) - }) + return links + .filter((link): link is ConnectOauthHostApprovalLink => link !== null) + .sort((left, right) => { + return ( + left.secretName.localeCompare(right.secretName) || + left.host.localeCompare(right.host) + ) + }) } async function handleOAuthExchangeAction(input: {