11import { db } from '@sim/db'
2- import { mcpServers } from '@sim/db/schema'
2+ import { mcpServers , pendingCredentialDraft } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
4- import { and , eq , isNull } from 'drizzle-orm'
4+ import { and , eq , isNull , lt } from 'drizzle-orm'
55import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
66import type {
77 ExecutionContext ,
@@ -12,6 +12,7 @@ import { routeExecution } from '@/lib/copilot/tools/server/router'
1212import { env } from '@/lib/core/config/env'
1313import { getBaseUrl } from '@/lib/core/utils/urls'
1414import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
15+ import { getAllOAuthServices } from '@/lib/oauth/utils'
1516import { validateMcpDomain } from '@/lib/mcp/domain-check'
1617import { mcpService } from '@/lib/mcp/service'
1718import { generateMcpServerId } from '@/lib/mcp/utils'
@@ -693,6 +694,112 @@ const SERVER_TOOLS = new Set<string>([
693694 'get_execution_summary' ,
694695] )
695696
697+ /**
698+ * Resolves a human-friendly provider name to a providerId and generates the
699+ * actual OAuth authorization URL via Better Auth's server-side API.
700+ *
701+ * Steps: resolve provider → create credential draft → look up user session →
702+ * call auth.api.oAuth2LinkAccount → return the real authorization URL.
703+ */
704+ async function generateOAuthLink (
705+ userId : string ,
706+ workspaceId : string | undefined ,
707+ workflowId : string | undefined ,
708+ providerName : string ,
709+ baseUrl : string
710+ ) : Promise < { url : string ; providerId : string ; serviceName : string } > {
711+ if ( ! workspaceId ) {
712+ throw new Error ( 'workspaceId is required to generate an OAuth link' )
713+ }
714+
715+ const allServices = getAllOAuthServices ( )
716+ const normalizedInput = providerName . toLowerCase ( ) . trim ( )
717+
718+ const matched =
719+ allServices . find ( ( s ) => s . providerId === normalizedInput ) ||
720+ allServices . find ( ( s ) => s . name . toLowerCase ( ) === normalizedInput ) ||
721+ allServices . find (
722+ ( s ) =>
723+ s . name . toLowerCase ( ) . includes ( normalizedInput ) ||
724+ normalizedInput . includes ( s . name . toLowerCase ( ) )
725+ ) ||
726+ allServices . find (
727+ ( s ) =>
728+ s . providerId . includes ( normalizedInput ) || normalizedInput . includes ( s . providerId )
729+ )
730+
731+ if ( ! matched ) {
732+ const available = allServices . map ( ( s ) => s . name ) . join ( ', ' )
733+ throw new Error (
734+ `Provider "${ providerName } " not found. Available providers: ${ available } `
735+ )
736+ }
737+
738+ const { providerId, name : serviceName } = matched
739+ const callbackURL =
740+ workflowId && workspaceId
741+ ? `${ baseUrl } /workspace/${ workspaceId } /w/${ workflowId } `
742+ : `${ baseUrl } /workspace/${ workspaceId } `
743+
744+ // Trello and Shopify use custom auth routes, not genericOAuth
745+ if ( providerId === 'trello' ) {
746+ return { url : `${ baseUrl } /api/auth/trello/authorize` , providerId, serviceName }
747+ }
748+ if ( providerId === 'shopify' ) {
749+ const returnUrl = encodeURIComponent ( callbackURL )
750+ return {
751+ url : `${ baseUrl } /api/auth/shopify/authorize?returnUrl=${ returnUrl } ` ,
752+ providerId,
753+ serviceName,
754+ }
755+ }
756+
757+ // Create credential draft so the callback hook creates the credential
758+ const now = new Date ( )
759+ await db
760+ . delete ( pendingCredentialDraft )
761+ . where ( and ( eq ( pendingCredentialDraft . userId , userId ) , lt ( pendingCredentialDraft . expiresAt , now ) ) )
762+ await db
763+ . insert ( pendingCredentialDraft )
764+ . values ( {
765+ id : crypto . randomUUID ( ) ,
766+ userId,
767+ workspaceId,
768+ providerId,
769+ displayName : serviceName ,
770+ expiresAt : new Date ( now . getTime ( ) + 15 * 60 * 1000 ) ,
771+ createdAt : now ,
772+ } )
773+ . onConflictDoUpdate ( {
774+ target : [
775+ pendingCredentialDraft . userId ,
776+ pendingCredentialDraft . providerId ,
777+ pendingCredentialDraft . workspaceId ,
778+ ] ,
779+ set : {
780+ displayName : serviceName ,
781+ expiresAt : new Date ( now . getTime ( ) + 15 * 60 * 1000 ) ,
782+ createdAt : now ,
783+ } ,
784+ } )
785+
786+ // Use Better Auth's server-side API with the real request headers (session cookie).
787+ const { auth } = await import ( '@/lib/auth/auth' )
788+ const { headers : getHeaders } = await import ( 'next/headers' )
789+ const reqHeaders = await getHeaders ( )
790+
791+ const data = ( await auth . api . oAuth2LinkAccount ( {
792+ body : { providerId, callbackURL } ,
793+ headers : reqHeaders ,
794+ } ) ) as { url ?: string ; redirect ?: boolean }
795+
796+ if ( ! data ?. url ) {
797+ throw new Error ( 'oAuth2LinkAccount did not return an authorization URL' )
798+ }
799+
800+ return { url : data . url , providerId, serviceName }
801+ }
802+
696803const SIM_WORKFLOW_TOOL_HANDLERS : Record <
697804 string ,
698805 ( params : Record < string , unknown > , context : ExecutionContext ) => Promise < ToolCallResult >
@@ -741,28 +848,38 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
741848 executeUpdateWorkspaceMcpServer ( p as unknown as UpdateWorkspaceMcpServerParams , c ) ,
742849 delete_workspace_mcp_server : ( p , c ) =>
743850 executeDeleteWorkspaceMcpServer ( p as unknown as DeleteWorkspaceMcpServerParams , c ) ,
744- oauth_get_auth_link : async ( p , _c ) => {
851+ oauth_get_auth_link : async ( p , c ) => {
745852 const providerName = ( p . providerName || p . provider_name || 'the provider' ) as string
853+ const baseUrl = getBaseUrl ( )
854+
746855 try {
747- const baseUrl = getBaseUrl ( )
748- const settingsUrl = `${ baseUrl } /workspace`
856+ const result = await generateOAuthLink ( c . userId , c . workspaceId , c . workflowId , providerName , baseUrl )
749857 return {
750858 success : true ,
751859 output : {
752- message : `To connect ${ providerName } , the user must authorize via their browser.` ,
753- oauth_url : settingsUrl ,
754- instructions : `Open ${ settingsUrl } in a browser and go to the workflow editor to connect ${ providerName } credentials. ` ,
755- provider : providerName ,
756- baseUrl ,
860+ message : `Authorization URL generated for ${ result . serviceName } . The user must open this URL in a browser to authorize .` ,
861+ oauth_url : result . url ,
862+ instructions : `Open this URL in your browser to connect ${ result . serviceName } : ${ result . url } ` ,
863+ provider : result . serviceName ,
864+ providerId : result . providerId ,
757865 } ,
758866 }
759- } catch {
867+ } catch ( err ) {
868+ logger . warn ( 'Failed to generate OAuth link, falling back to generic URL' , {
869+ providerName,
870+ error : err instanceof Error ? err . message : String ( err ) ,
871+ } )
872+ const workspaceUrl = c . workspaceId
873+ ? `${ baseUrl } /workspace/${ c . workspaceId } `
874+ : `${ baseUrl } /workspace`
760875 return {
761- success : true ,
876+ success : false ,
762877 output : {
763- message : `To connect ${ providerName } , the user must authorize via their browser.` ,
764- instructions : `Open the Sim workspace in a browser and go to the workflow editor to connect ${ providerName } credentials.` ,
878+ message : `Could not generate a direct OAuth link for ${ providerName } . The user can connect manually from the workspace.` ,
879+ oauth_url : workspaceUrl ,
880+ instructions : `Open ${ workspaceUrl } in a browser, go to Settings → Credentials, and connect ${ providerName } from there.` ,
765881 provider : providerName ,
882+ error : err instanceof Error ? err . message : String ( err ) ,
766883 } ,
767884 }
768885 }
0 commit comments