From 0598bd71f9e7c9764609f57ee063388430d55f7c Mon Sep 17 00:00:00 2001 From: "priyanshu.solanki" Date: Wed, 17 Dec 2025 16:58:00 -0700 Subject: [PATCH 1/3] fix adding client ID and secret fields to supprot ouath --- apps/docs/components/icons.tsx | 50 ++++++++ apps/docs/content/docs/en/tools/translate.mdx | 2 + .../auth/oauth2/callback/servicenow/route.ts | 12 +- .../api/auth/servicenow/authorize/route.ts | 110 ++++++++++++++---- .../components/oauth-required-modal.tsx | 25 +++- .../credential-selector.tsx | 64 +++++++++- apps/sim/blocks/blocks/servicenow.ts | 33 +++++- apps/sim/hooks/queries/oauth-connections.ts | 13 ++- apps/sim/tools/servicenow/types.ts | 3 + 9 files changed, 272 insertions(+), 40 deletions(-) diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 2e668f913e..6c7f641382 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2452,6 +2452,56 @@ export const GeminiIcon = (props: SVGProps) => ( ) +export const VertexIcon = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + +) + export const CerebrasIcon = (props: SVGProps) => ( @@ -63,9 +58,11 @@ export async function GET(request: NextRequest) { display: flex; align-items: center; justify-content: center; - height: 100vh; + min-height: 100vh; margin: 0; background: linear-gradient(135deg, #81B5A1 0%, #5A8A75 100%); + padding: 20px; + box-sizing: border-box; } .container { background: white; @@ -74,7 +71,7 @@ export async function GET(request: NextRequest) { box-shadow: 0 10px 40px rgba(0,0,0,0.1); text-align: center; max-width: 450px; - width: 90%; + width: 100%; } h2 { color: #111827; @@ -84,13 +81,23 @@ export async function GET(request: NextRequest) { color: #6b7280; margin: 0 0 1.5rem 0; } + .form-group { + text-align: left; + margin-bottom: 1rem; + } + label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + margin-bottom: 0.25rem; + } input { width: 100%; padding: 0.75rem; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; - margin-bottom: 1rem; box-sizing: border-box; } input:focus { @@ -108,14 +115,15 @@ export async function GET(request: NextRequest) { font-size: 1rem; cursor: pointer; font-weight: 500; + margin-top: 0.5rem; } button:hover { background: #6A9A87; } .help { - font-size: 0.875rem; + font-size: 0.75rem; color: #9ca3af; - margin-top: 1rem; + margin-top: 0.25rem; } .error { color: #dc2626; @@ -128,18 +136,41 @@ export async function GET(request: NextRequest) {

Connect Your ServiceNow Instance

-

Enter your ServiceNow instance URL to continue

+

Enter your ServiceNow credentials to continue

- +
+ + +

Your ServiceNow instance URL (e.g., https://yourcompany.service-now.com)

+
+
+ + +

OAuth Client ID from your ServiceNow Application Registry

+
+
+ + +

OAuth Client Secret from your ServiceNow Application Registry

+
-

Your instance URL looks like: https://yourcompany.service-now.com

- -`, - { - headers: { - 'Content-Type': 'text/html; charset=utf-8', - 'Cache-Control': 'no-store, no-cache, must-revalidate', - }, - } - ) - } - - // Validate instance URL - if (!isValidInstanceUrl(instanceUrl)) { - logger.error('Invalid ServiceNow instance URL:', { instanceUrl }) - return NextResponse.json( - { - error: - 'Invalid ServiceNow instance URL. Must be a valid .service-now.com or .servicenow.com domain.', - }, - { status: 400 } - ) - } - - // Clean the instance URL - const parsedUrl = new URL(instanceUrl) - const cleanInstanceUrl = parsedUrl.origin - - const baseUrl = getBaseUrl() - const redirectUri = `${baseUrl}/api/auth/oauth2/callback/servicenow` - - const state = crypto.randomUUID() - - // ServiceNow OAuth authorization URL - const oauthUrl = - `${cleanInstanceUrl}/oauth_auth.do?` + - new URLSearchParams({ - response_type: 'code', - client_id: clientId, - redirect_uri: redirectUri, - state: state, - scope: SERVICENOW_SCOPES, - }).toString() - - logger.info('Initiating ServiceNow OAuth:', { - instanceUrl: cleanInstanceUrl, - requestedScopes: SERVICENOW_SCOPES, - redirectUri, - returnUrl: returnUrl || 'not specified', - }) - - const response = NextResponse.redirect(oauthUrl) - - // Store state, instance URL, and credentials in cookies for validation in callback - response.cookies.set('servicenow_oauth_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, // 10 minutes - path: '/', - }) - - response.cookies.set('servicenow_instance_url', cleanInstanceUrl, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - // Store client credentials in cookies for the callback to use - response.cookies.set('servicenow_client_id', clientId, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - response.cookies.set('servicenow_client_secret', clientSecret, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - if (returnUrl) { - response.cookies.set('servicenow_return_url', returnUrl, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - } - - return response - } catch (error) { - logger.error('Error initiating ServiceNow authorization:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 64ecc92a51..818defe02f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -22,11 +22,6 @@ export interface OAuthRequiredModalProps { requiredScopes?: string[] serviceId?: string newScopes?: string[] - servicenowCredentials?: { - instanceUrl: string - clientId: string - clientSecret: string - } } const SCOPE_DESCRIPTIONS: Record = { @@ -302,7 +297,6 @@ export function OAuthRequiredModal({ requiredScopes = [], serviceId, newScopes = [], - servicenowCredentials, }: OAuthRequiredModalProps) { const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes) const { baseProvider } = parseProvider(provider) @@ -353,28 +347,6 @@ export function OAuthRequiredModal({ return } - if (providerId === 'servicenow') { - // ServiceNow requires credentials from the block - if ( - !servicenowCredentials?.instanceUrl || - !servicenowCredentials?.clientId || - !servicenowCredentials?.clientSecret - ) { - // If credentials are missing, redirect to authorize which will show a form - const returnUrl = encodeURIComponent(window.location.href) - window.location.href = `/api/auth/servicenow/authorize?returnUrl=${returnUrl}` - return - } - - // Pass the current URL and credentials so we can redirect back after OAuth - const returnUrl = encodeURIComponent(window.location.href) - const instanceUrl = encodeURIComponent(servicenowCredentials.instanceUrl) - const clientId = encodeURIComponent(servicenowCredentials.clientId) - const clientSecret = encodeURIComponent(servicenowCredentials.clientSecret) - window.location.href = `/api/auth/servicenow/authorize?returnUrl=${returnUrl}&instanceUrl=${instanceUrl}&clientId=${clientId}&clientSecret=${clientSecret}` - return - } - await client.oauth2.link({ providerId, callbackURL: window.location.href, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 1066ca7526..a487fb7b5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -17,30 +17,10 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import type { SubBlockConfig } from '@/blocks/types' import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' -import { useEnvironmentStore } from '@/stores/settings/environment/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('CredentialSelector') -/** - * Resolves environment variables with {{VAR_NAME}} syntax in a string - */ -function resolveEnvVars(value: string, envVars: Record): string { - if (!value) return value - const envMatches = value.match(/\{\{([^}]+)\}\}/g) - if (!envMatches) return value - - let resolvedValue = value - for (const match of envMatches) { - const envKey = match.slice(2, -2).trim() - const envValue = envVars[envKey] - if (envValue !== undefined) { - resolvedValue = resolvedValue.replaceAll(match, envValue) - } - } - return resolvedValue -} - interface CredentialSelectorProps { blockId: string subBlock: SubBlockConfig @@ -66,44 +46,14 @@ export function CredentialSelector({ const label = subBlock.placeholder || 'Select credential' const serviceId = subBlock.serviceId || '' - // Get ServiceNow-specific credentials from block values (for passing to OAuth modal) - const [instanceUrl] = useSubBlockValue(blockId, 'instanceUrl') - const [clientId] = useSubBlockValue(blockId, 'clientId') - const [clientSecret] = useSubBlockValue(blockId, 'clientSecret') - - // Get environment variables for resolving {{VAR}} patterns - const envVariables = useEnvironmentStore((state) => state.variables) - const envVars = useMemo(() => { - return Object.entries(envVariables).reduce( - (acc, [key, variable]) => { - acc[key] = variable.value - return acc - }, - {} as Record - ) - }, [envVariables]) - - // Resolve environment variables in ServiceNow credentials - const resolvedServicenowCredentials = useMemo(() => { - if (serviceId !== 'servicenow') return undefined - return { - instanceUrl: resolveEnvVars(instanceUrl || '', envVars), - clientId: resolveEnvVars(clientId || '', envVars), - clientSecret: resolveEnvVars(clientSecret || '', envVars), - } - }, [serviceId, instanceUrl, clientId, clientSecret, envVars]) - - // Use dependsOn gate to check if all required dependencies are satisfied const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview }) const hasDependencies = dependsOn.length > 0 - // Disable the credential selector if dependencies are not satisfied const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied) const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue const selectedId = typeof effectiveValue === 'string' ? effectiveValue : '' - // serviceId is now the canonical identifier - derive provider from it const effectiveProviderId = useMemo( () => getProviderIdFromServiceId(serviceId) as OAuthProvider, [serviceId] @@ -312,7 +262,6 @@ export function CredentialSelector({ requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)} newScopes={missingRequiredScopes} serviceId={serviceId} - servicenowCredentials={resolvedServicenowCredentials} /> )} diff --git a/apps/sim/blocks/blocks/servicenow.ts b/apps/sim/blocks/blocks/servicenow.ts index 867ccfc467..20e3ce8e9d 100644 --- a/apps/sim/blocks/blocks/servicenow.ts +++ b/apps/sim/blocks/blocks/servicenow.ts @@ -5,9 +5,9 @@ import type { ServiceNowResponse } from '@/tools/servicenow/types' export const ServiceNowBlock: BlockConfig = { type: 'servicenow', name: 'ServiceNow', - description: 'Create, read, update, delete, and bulk import ServiceNow records', + description: 'Create, read, update, and delete ServiceNow records', longDescription: - 'Integrate ServiceNow into your workflow. Can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, etc.). Supports bulk import operations for data migration and ETL.', + 'Integrate ServiceNow into your workflow. Create, read, update, and delete records in any ServiceNow table including incidents, tasks, change requests, users, and more.', docsLink: 'https://docs.sim.ai/tools/servicenow', category: 'tools', bgColor: '#032D42', @@ -19,12 +19,12 @@ export const ServiceNowBlock: BlockConfig = { title: 'Operation', type: 'dropdown', options: [ - { label: 'Create Record', id: 'create' }, - { label: 'Read Records', id: 'read' }, - { label: 'Update Record', id: 'update' }, - { label: 'Delete Record', id: 'delete' }, + { label: 'Create Record', id: 'servicenow_create_record' }, + { label: 'Read Records', id: 'servicenow_read_record' }, + { label: 'Update Record', id: 'servicenow_update_record' }, + { label: 'Delete Record', id: 'servicenow_delete_record' }, ], - value: () => 'read', + value: () => 'servicenow_read_record', }, // Instance URL { @@ -35,35 +35,24 @@ export const ServiceNowBlock: BlockConfig = { required: true, description: 'Your ServiceNow instance URL (e.g., https://yourcompany.service-now.com)', }, - // Client ID + // Username { - id: 'clientId', - title: 'Client ID', + id: 'username', + title: 'Username', type: 'short-input', - placeholder: 'Enter your ServiceNow OAuth Client ID', + placeholder: 'Enter your ServiceNow username', required: true, - description: 'OAuth Client ID from your ServiceNow Application Registry', + description: 'ServiceNow user with web service access', }, - // Client Secret + // Password { - id: 'clientSecret', - title: 'Client Secret', + id: 'password', + title: 'Password', type: 'short-input', - placeholder: 'Enter your ServiceNow OAuth Client Secret', + placeholder: 'Enter your ServiceNow password', password: true, required: true, - description: 'OAuth Client Secret from your ServiceNow Application Registry', - }, - // OAuth Credential - { - id: 'credential', - title: 'ServiceNow Account', - type: 'oauth-input', - serviceId: 'servicenow', - requiredScopes: ['useraccount'], - placeholder: 'Select ServiceNow account', - required: true, - dependsOn: ['instanceUrl', 'clientId', 'clientSecret'], + description: 'Password for the ServiceNow user', }, // Table Name { @@ -81,7 +70,7 @@ export const ServiceNowBlock: BlockConfig = { type: 'code', language: 'json', placeholder: '{\n "short_description": "Issue description",\n "priority": "1"\n}', - condition: { field: 'operation', value: 'create' }, + condition: { field: 'operation', value: 'servicenow_create_record' }, required: true, wandConfig: { enabled: true, @@ -114,21 +103,21 @@ Output: {"short_description": "Network outage", "description": "Network connecti title: 'Record sys_id', type: 'short-input', placeholder: 'Specific record sys_id (optional)', - condition: { field: 'operation', value: 'read' }, + condition: { field: 'operation', value: 'servicenow_read_record' }, }, { id: 'number', title: 'Record Number', type: 'short-input', placeholder: 'e.g., INC0010001 (optional)', - condition: { field: 'operation', value: 'read' }, + condition: { field: 'operation', value: 'servicenow_read_record' }, }, { id: 'query', title: 'Query String', type: 'short-input', placeholder: 'active=true^priority=1', - condition: { field: 'operation', value: 'read' }, + condition: { field: 'operation', value: 'servicenow_read_record' }, description: 'ServiceNow encoded query string', }, { @@ -136,14 +125,14 @@ Output: {"short_description": "Network outage", "description": "Network connecti title: 'Limit', type: 'short-input', placeholder: '10', - condition: { field: 'operation', value: 'read' }, + condition: { field: 'operation', value: 'servicenow_read_record' }, }, { id: 'fields', title: 'Fields to Return', type: 'short-input', placeholder: 'number,short_description,priority', - condition: { field: 'operation', value: 'read' }, + condition: { field: 'operation', value: 'servicenow_read_record' }, description: 'Comma-separated list of fields', }, // Update-specific: sysId and fields @@ -152,7 +141,7 @@ Output: {"short_description": "Network outage", "description": "Network connecti title: 'Record sys_id', type: 'short-input', placeholder: 'Record sys_id to update', - condition: { field: 'operation', value: 'update' }, + condition: { field: 'operation', value: 'servicenow_update_record' }, required: true, }, { @@ -161,7 +150,7 @@ Output: {"short_description": "Network outage", "description": "Network connecti type: 'code', language: 'json', placeholder: '{\n "state": "2",\n "assigned_to": "user.sys_id"\n}', - condition: { field: 'operation', value: 'update' }, + condition: { field: 'operation', value: 'servicenow_update_record' }, required: true, wandConfig: { enabled: true, @@ -193,7 +182,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st title: 'Record sys_id', type: 'short-input', placeholder: 'Record sys_id to delete', - condition: { field: 'operation', value: 'delete' }, + condition: { field: 'operation', value: 'servicenow_delete_record' }, required: true, }, ], @@ -205,64 +194,26 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st 'servicenow_delete_record', ], config: { - tool: (params) => { - switch (params.operation) { - case 'create': - return 'servicenow_create_record' - case 'read': - return 'servicenow_read_record' - case 'update': - return 'servicenow_update_record' - case 'delete': - return 'servicenow_delete_record' - default: - throw new Error(`Invalid ServiceNow operation: ${params.operation}`) - } - }, + tool: (params) => params.operation, params: (params) => { - const { operation, fields, records, credential, clientId, clientSecret, ...rest } = params + const { operation, fields, ...rest } = params + const isCreateOrUpdate = + operation === 'servicenow_create_record' || operation === 'servicenow_update_record' - // Parse JSON fields if provided - let parsedFields: Record | undefined - if (fields && (operation === 'create' || operation === 'update')) { - try { - parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields - } catch (error) { - throw new Error( - `Invalid JSON in fields: ${error instanceof Error ? error.message : String(error)}` - ) - } + if (fields && isCreateOrUpdate) { + const parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields + return { ...rest, fields: parsedFields } } - // Validate OAuth credential - if (!credential) { - throw new Error('ServiceNow account credential is required') - } - - // Build params - include clientId and clientSecret for token refresh - const baseParams: Record = { - ...rest, - credential, - clientId, - clientSecret, - } - - if (operation === 'create' || operation === 'update') { - return { - ...baseParams, - fields: parsedFields, - } - } - return baseParams + return rest }, }, }, inputs: { operation: { type: 'string', description: 'Operation to perform' }, instanceUrl: { type: 'string', description: 'ServiceNow instance URL' }, - clientId: { type: 'string', description: 'ServiceNow OAuth Client ID' }, - clientSecret: { type: 'string', description: 'ServiceNow OAuth Client Secret' }, - credential: { type: 'string', description: 'ServiceNow OAuth credential ID' }, + username: { type: 'string', description: 'ServiceNow username' }, + password: { type: 'string', description: 'ServiceNow password' }, tableName: { type: 'string', description: 'Table name' }, sysId: { type: 'string', description: 'Record sys_id' }, number: { type: 'string', description: 'Record number' }, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 6c7f641382..ddaf0f95ee 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3387,17 +3387,14 @@ export function SalesforceIcon(props: SVGProps) { export function ServiceNowIcon(props: SVGProps) { return ( - + ) diff --git a/apps/sim/hooks/queries/oauth-connections.ts b/apps/sim/hooks/queries/oauth-connections.ts index aabc2832af..f4e5eef3f9 100644 --- a/apps/sim/hooks/queries/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth-connections.ts @@ -22,24 +22,13 @@ export interface ServiceInfo extends OAuthServiceConfig { accounts?: { id: string; name: string }[] } -/** - * Providers that should be hidden from the integrations tab - * These providers have custom OAuth flows handled directly from their blocks - */ -const HIDDEN_FROM_INTEGRATIONS = ['servicenow'] - /** * Define available services from standardized OAuth providers */ function defineServices(): ServiceInfo[] { const servicesList: ServiceInfo[] = [] - Object.entries(OAUTH_PROVIDERS).forEach(([providerKey, provider]) => { - // Skip providers that should be hidden from integrations tab - if (HIDDEN_FROM_INTEGRATIONS.includes(providerKey)) { - return - } - + Object.entries(OAUTH_PROVIDERS).forEach(([_providerKey, provider]) => { Object.values(provider.services).forEach((service) => { servicesList.push({ ...service, @@ -153,13 +142,6 @@ export function useConnectOAuthService() { return { success: true } } - // ServiceNow requires a custom OAuth flow with instance URL input - if (providerId === 'servicenow') { - const returnUrl = encodeURIComponent(callbackURL) - window.location.href = `/api/auth/servicenow/authorize?returnUrl=${returnUrl}` - return { success: true } - } - await client.oauth2.link({ providerId, callbackURL, diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 0a16e81ed6..fa3027a3cf 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -237,8 +237,6 @@ export const env = createEnv({ WORDPRESS_CLIENT_SECRET: z.string().optional(), // WordPress.com OAuth client secret SPOTIFY_CLIENT_ID: z.string().optional(), // Spotify OAuth client ID SPOTIFY_CLIENT_SECRET: z.string().optional(), // Spotify OAuth client secret - SERVICENOW_CLIENT_ID: z.string().optional(), // ServiceNow OAuth client ID - SERVICENOW_CLIENT_SECRET: z.string().optional(), // ServiceNow OAuth client secret // E2B Remote Code Execution E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 3ae4a35ac2..239a234c68 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -29,7 +29,6 @@ import { PipedriveIcon, RedditIcon, SalesforceIcon, - ServiceNowIcon, ShopifyIcon, SlackIcon, SpotifyIcon, @@ -70,7 +69,6 @@ export type OAuthProvider = | 'salesforce' | 'linkedin' | 'shopify' - | 'servicenow' | 'zoom' | 'wordpress' | 'spotify' @@ -113,7 +111,6 @@ export type OAuthService = | 'salesforce' | 'linkedin' | 'shopify' - | 'servicenow' | 'zoom' | 'wordpress' | 'spotify' @@ -621,23 +618,6 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'shopify', }, - servicenow: { - id: 'servicenow', - name: 'ServiceNow', - icon: (props) => ServiceNowIcon(props), - services: { - servicenow: { - id: 'servicenow', - name: 'ServiceNow', - description: 'Manage incidents, tasks, and records in your ServiceNow instance.', - providerId: 'servicenow', - icon: (props) => ServiceNowIcon(props), - baseProviderIcon: (props) => ServiceNowIcon(props), - scopes: ['useraccount'], - }, - }, - defaultService: 'servicenow', - }, slack: { id: 'slack', name: 'Slack', @@ -1507,21 +1487,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: false, } } - case 'servicenow': { - // ServiceNow OAuth - token endpoint is instance-specific - // This is a placeholder; actual token endpoint is set during authorization - const { clientId, clientSecret } = getCredentials( - env.SERVICENOW_CLIENT_ID, - env.SERVICENOW_CLIENT_SECRET - ) - return { - tokenEndpoint: '', // Instance-specific, set during authorization - clientId, - clientSecret, - useBasicAuth: false, - supportsRefreshTokenRotation: true, - } - } case 'zoom': { const { clientId, clientSecret } = getCredentials(env.ZOOM_CLIENT_ID, env.ZOOM_CLIENT_SECRET) return { @@ -1600,13 +1565,11 @@ function buildAuthRequest( * This is a server-side utility function to refresh OAuth tokens * @param providerId The provider ID (e.g., 'google-drive') * @param refreshToken The refresh token to use - * @param instanceUrl Optional instance URL for providers with instance-specific endpoints (e.g., ServiceNow) * @returns Object containing the new access token and expiration time in seconds, or null if refresh failed */ export async function refreshOAuthToken( providerId: string, - refreshToken: string, - instanceUrl?: string + refreshToken: string ): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> { try { // Get the provider from the providerId (e.g., 'google-drive' -> 'google') @@ -1615,15 +1578,7 @@ export async function refreshOAuthToken( // Get provider configuration const config = getProviderAuthConfig(provider) - // For ServiceNow, the token endpoint is instance-specific - let tokenEndpoint = config.tokenEndpoint - if (provider === 'servicenow') { - if (!instanceUrl) { - logger.error('ServiceNow token refresh requires instance URL') - return null - } - tokenEndpoint = `${instanceUrl.replace(/\/$/, '')}/oauth_token.do` - } + const tokenEndpoint = config.tokenEndpoint // Build authentication request const { headers, bodyParams } = buildAuthRequest(config, refreshToken) diff --git a/apps/sim/tools/servicenow/create_record.ts b/apps/sim/tools/servicenow/create_record.ts index a8ee81e072..ec43c9b245 100644 --- a/apps/sim/tools/servicenow/create_record.ts +++ b/apps/sim/tools/servicenow/create_record.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ServiceNowCreateParams, ServiceNowCreateResponse } from '@/tools/servicenow/types' +import { createBasicAuthHeader } from '@/tools/servicenow/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowCreateRecordTool') @@ -10,11 +11,6 @@ export const createRecordTool: ToolConfig { - // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) - const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + const baseUrl = params.instanceUrl.replace(/\/$/, '') if (!baseUrl) { throw new Error('ServiceNow instance URL is required') } @@ -53,11 +54,11 @@ export const createRecordTool: ToolConfig { - if (!params.accessToken) { - throw new Error('OAuth access token is required') + if (!params.username || !params.password) { + throw new Error('ServiceNow username and password are required') } return { - Authorization: `Bearer ${params.accessToken}`, + Authorization: createBasicAuthHeader(params.username, params.password), 'Content-Type': 'application/json', Accept: 'application/json', } diff --git a/apps/sim/tools/servicenow/delete_record.ts b/apps/sim/tools/servicenow/delete_record.ts index 25021dbca8..135133d632 100644 --- a/apps/sim/tools/servicenow/delete_record.ts +++ b/apps/sim/tools/servicenow/delete_record.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ServiceNowDeleteParams, ServiceNowDeleteResponse } from '@/tools/servicenow/types' +import { createBasicAuthHeader } from '@/tools/servicenow/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowDeleteRecordTool') @@ -10,23 +11,24 @@ export const deleteRecordTool: ToolConfig { - // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) - const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + const baseUrl = params.instanceUrl.replace(/\/$/, '') if (!baseUrl) { throw new Error('ServiceNow instance URL is required') } @@ -53,11 +54,11 @@ export const deleteRecordTool: ToolConfig { - if (!params.accessToken) { - throw new Error('OAuth access token is required') + if (!params.username || !params.password) { + throw new Error('ServiceNow username and password are required') } return { - Authorization: `Bearer ${params.accessToken}`, + Authorization: createBasicAuthHeader(params.username, params.password), Accept: 'application/json', } }, diff --git a/apps/sim/tools/servicenow/read_record.ts b/apps/sim/tools/servicenow/read_record.ts index 93b81c06bd..7f1840a17a 100644 --- a/apps/sim/tools/servicenow/read_record.ts +++ b/apps/sim/tools/servicenow/read_record.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ServiceNowReadParams, ServiceNowReadResponse } from '@/tools/servicenow/types' +import { createBasicAuthHeader } from '@/tools/servicenow/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowReadRecordTool') @@ -10,23 +11,24 @@ export const readRecordTool: ToolConfig { - // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) - const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + const baseUrl = params.instanceUrl.replace(/\/$/, '') if (!baseUrl) { throw new Error('ServiceNow instance URL is required') } @@ -80,10 +81,13 @@ export const readRecordTool: ToolConfig { - if (!params.accessToken) { - throw new Error('OAuth access token is required') + if (!params.username || !params.password) { + throw new Error('ServiceNow username and password are required') } return { - Authorization: `Bearer ${params.accessToken}`, + Authorization: createBasicAuthHeader(params.username, params.password), Accept: 'application/json', } }, diff --git a/apps/sim/tools/servicenow/types.ts b/apps/sim/tools/servicenow/types.ts index faa76b7091..d96f0711f0 100644 --- a/apps/sim/tools/servicenow/types.ts +++ b/apps/sim/tools/servicenow/types.ts @@ -7,15 +7,10 @@ export interface ServiceNowRecord { } export interface ServiceNowBaseParams { - instanceUrl?: string + instanceUrl: string + username: string + password: string tableName: string - // OAuth fields (injected by the system when using OAuth) - credential?: string - accessToken?: string - idToken?: string // Stores the instance URL from OAuth - // Client credentials for token refresh - clientId?: string - clientSecret?: string } export interface ServiceNowCreateParams extends ServiceNowBaseParams { diff --git a/apps/sim/tools/servicenow/update_record.ts b/apps/sim/tools/servicenow/update_record.ts index 629468e7d0..11626ad836 100644 --- a/apps/sim/tools/servicenow/update_record.ts +++ b/apps/sim/tools/servicenow/update_record.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ServiceNowUpdateParams, ServiceNowUpdateResponse } from '@/tools/servicenow/types' +import { createBasicAuthHeader } from '@/tools/servicenow/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('ServiceNowUpdateRecordTool') @@ -10,23 +11,24 @@ export const updateRecordTool: ToolConfig { - // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) - const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + const baseUrl = params.instanceUrl.replace(/\/$/, '') if (!baseUrl) { throw new Error('ServiceNow instance URL is required') } @@ -59,11 +60,11 @@ export const updateRecordTool: ToolConfig { - if (!params.accessToken) { - throw new Error('OAuth access token is required') + if (!params.username || !params.password) { + throw new Error('ServiceNow username and password are required') } return { - Authorization: `Bearer ${params.accessToken}`, + Authorization: createBasicAuthHeader(params.username, params.password), 'Content-Type': 'application/json', Accept: 'application/json', } diff --git a/apps/sim/tools/servicenow/utils.ts b/apps/sim/tools/servicenow/utils.ts new file mode 100644 index 0000000000..486f2266fe --- /dev/null +++ b/apps/sim/tools/servicenow/utils.ts @@ -0,0 +1,10 @@ +/** + * Creates a Basic Authentication header from username and password + * @param username ServiceNow username + * @param password ServiceNow password + * @returns Base64 encoded Basic Auth header value + */ +export function createBasicAuthHeader(username: string, password: string): string { + const credentials = Buffer.from(`${username}:${password}`).toString('base64') + return `Basic ${credentials}` +} From ecaa013ee25c4e79ba9fb0c5bc835c834b4aa6e5 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 17 Dec 2025 18:32:28 -0800 Subject: [PATCH 3/3] fix failing tests --- apps/sim/app/api/auth/oauth/utils.test.ts | 17 ++--------------- apps/sim/lib/oauth/oauth.ts | 13 +------------ 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 95b3894a6d..2c61b903f1 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -38,7 +38,6 @@ vi.mock('@/lib/logs/console/logger', () => ({ })) import { db } from '@sim/db' -import { createLogger } from '@/lib/logs/console/logger' import { refreshOAuthToken } from '@/lib/oauth/oauth' import { getCredential, @@ -49,7 +48,6 @@ import { const mockDb = db as any const mockRefreshOAuthToken = refreshOAuthToken as any -const mockLogger = (createLogger as any)() describe('OAuth Utils', () => { beforeEach(() => { @@ -87,7 +85,6 @@ describe('OAuth Utils', () => { const userId = await getUserId('request-id') expect(userId).toBeUndefined() - expect(mockLogger.warn).toHaveBeenCalled() }) it('should return undefined if workflow is not found', async () => { @@ -96,7 +93,6 @@ describe('OAuth Utils', () => { const userId = await getUserId('request-id', 'nonexistent-workflow-id') expect(userId).toBeUndefined() - expect(mockLogger.warn).toHaveBeenCalled() }) }) @@ -121,7 +117,6 @@ describe('OAuth Utils', () => { const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id') expect(credential).toBeUndefined() - expect(mockLogger.warn).toHaveBeenCalled() }) }) @@ -139,7 +134,6 @@ describe('OAuth Utils', () => { expect(mockRefreshOAuthToken).not.toHaveBeenCalled() expect(result).toEqual({ accessToken: 'valid-token', refreshed: false }) - expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Access token is valid')) }) it('should refresh token when expired', async () => { @@ -159,13 +153,10 @@ describe('OAuth Utils', () => { const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') - expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined) + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') expect(mockDb.update).toHaveBeenCalled() expect(mockDb.set).toHaveBeenCalled() expect(result).toEqual({ accessToken: 'new-token', refreshed: true }) - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Successfully refreshed') - ) }) it('should handle refresh token error', async () => { @@ -182,8 +173,6 @@ describe('OAuth Utils', () => { await expect( refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') ).rejects.toThrow('Failed to refresh token') - - expect(mockLogger.error).toHaveBeenCalled() }) it('should not attempt refresh if no refresh token', async () => { @@ -239,7 +228,7 @@ describe('OAuth Utils', () => { const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') - expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined) + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') expect(mockDb.update).toHaveBeenCalled() expect(mockDb.set).toHaveBeenCalled() expect(token).toBe('new-token') @@ -251,7 +240,6 @@ describe('OAuth Utils', () => { const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id') expect(token).toBeNull() - expect(mockLogger.warn).toHaveBeenCalled() }) it('should return null if refresh fails', async () => { @@ -270,7 +258,6 @@ describe('OAuth Utils', () => { const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') expect(token).toBeNull() - expect(mockLogger.error).toHaveBeenCalled() }) }) }) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 239a234c68..7516732a2d 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1572,19 +1572,13 @@ export async function refreshOAuthToken( refreshToken: string ): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> { try { - // Get the provider from the providerId (e.g., 'google-drive' -> 'google') const provider = providerId.split('-')[0] - // Get provider configuration const config = getProviderAuthConfig(provider) - const tokenEndpoint = config.tokenEndpoint - - // Build authentication request const { headers, bodyParams } = buildAuthRequest(config, refreshToken) - // Refresh the token - const response = await fetch(tokenEndpoint, { + const response = await fetch(config.tokenEndpoint, { method: 'POST', headers, body: new URLSearchParams(bodyParams).toString(), @@ -1594,7 +1588,6 @@ export async function refreshOAuthToken( const errorText = await response.text() let errorData = errorText - // Try to parse the error as JSON for better diagnostics try { errorData = JSON.parse(errorText) } catch (_e) { @@ -1618,18 +1611,14 @@ export async function refreshOAuthToken( const data = await response.json() - // Extract token and expiration (different providers may use different field names) const accessToken = data.access_token - // Handle refresh token rotation for providers that support it let newRefreshToken = null if (config.supportsRefreshTokenRotation && data.refresh_token) { newRefreshToken = data.refresh_token logger.info(`Received new refresh token from ${provider}`) } - // Get expiration time - use provider's value or default to 1 hour (3600 seconds) - // Different providers use different names for this field const expiresIn = data.expires_in || data.expiresIn || 3600 if (!accessToken) {