fix(servicenow): update servicenow block to use basic auth instead of oauth (#2435)

* fix adding client ID and secret fields to supprot ouath

* revert servicenow to use basic auth instead of oauth

* fix failing tests

---------

Co-authored-by: priyanshu.solanki <priyanshu.solanki@saviynt.com>
Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Priyanshu Solanki
2025-12-17 21:41:46 -07:00
committed by GitHub
parent 5516fa39c3
commit 491bd783b5
24 changed files with 348 additions and 965 deletions

View File

@@ -2452,6 +2452,56 @@ export const GeminiIcon = (props: SVGProps<SVGSVGElement>) => (
</svg>
)
export const VertexIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
id='standard_product_icon'
xmlns='http://www.w3.org/2000/svg'
version='1.1'
viewBox='0 0 512 512'
>
<g id='bounding_box'>
<rect width='512' height='512' fill='none' />
</g>
<g id='art'>
<path
d='M128,244.99c-8.84,0-16-7.16-16-16v-95.97c0-8.84,7.16-16,16-16s16,7.16,16,16v95.97c0,8.84-7.16,16-16,16Z'
fill='#ea4335'
/>
<path
d='M256,458c-2.98,0-5.97-.83-8.59-2.5l-186-122c-7.46-4.74-9.65-14.63-4.91-22.09,4.75-7.46,14.64-9.65,22.09-4.91l177.41,116.53,177.41-116.53c7.45-4.74,17.34-2.55,22.09,4.91,4.74,7.46,2.55,17.34-4.91,22.09l-186,122c-2.62,1.67-5.61,2.5-8.59,2.5Z'
fill='#fbbc04'
/>
<path
d='M256,388.03c-8.84,0-16-7.16-16-16v-73.06c0-8.84,7.16-16,16-16s16,7.16,16,16v73.06c0,8.84-7.16,16-16,16Z'
fill='#34a853'
/>
<circle cx='128' cy='70' r='16' fill='#ea4335' />
<circle cx='128' cy='292' r='16' fill='#ea4335' />
<path
d='M384.23,308.01c-8.82,0-15.98-7.14-16-15.97l-.23-94.01c-.02-8.84,7.13-16.02,15.97-16.03h.04c8.82,0,15.98,7.14,16,15.97l.23,94.01c.02,8.84-7.13,16.02-15.97,16.03h-.04Z'
fill='#4285f4'
/>
<circle cx='384' cy='70' r='16' fill='#4285f4' />
<circle cx='384' cy='134' r='16' fill='#4285f4' />
<path
d='M320,220.36c-8.84,0-16-7.16-16-16v-103.02c0-8.84,7.16-16,16-16s16,7.16,16,16v103.02c0,8.84-7.16,16-16,16Z'
fill='#fbbc04'
/>
<circle cx='256' cy='171' r='16' fill='#34a853' />
<circle cx='256' cy='235' r='16' fill='#34a853' />
<circle cx='320' cy='265' r='16' fill='#fbbc04' />
<circle cx='320' cy='329' r='16' fill='#fbbc04' />
<path
d='M192,217.36c-8.84,0-16-7.16-16-16v-100.02c0-8.84,7.16-16,16-16s16,7.16,16,16v100.02c0,8.84-7.16,16-16,16Z'
fill='#fbbc04'
/>
<circle cx='192' cy='265' r='16' fill='#fbbc04' />
<circle cx='192' cy='329' r='16' fill='#fbbc04' />
</g>
</svg>
)
export const CerebrasIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
@@ -3337,17 +3387,14 @@ export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
export function ServiceNowIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1570 1403'
width='48'
height='48'
>
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 71.1 63.6'>
<path
fill='#62d84e'
fillRule='evenodd'
d='M1228.4 138.9c129.2 88.9 228.9 214.3 286.3 360.2 57.5 145.8 70 305.5 36 458.5S1437.8 1250 1324 1357.9c-13.3 12.9-28.8 23.4-45.8 30.8-17 7.5-35.2 11.9-53.7 12.9-18.5 1.1-37.1-1.1-54.8-6.6-17.7-5.4-34.3-13.9-49.1-25.2-48.2-35.9-101.8-63.8-158.8-82.6-57.1-18.9-116.7-28.5-176.8-28.5s-119.8 9.6-176.8 28.5c-57 18.8-110.7 46.7-158.9 82.6-14.6 11.2-31 19.8-48.6 25.3s-36 7.8-54.4 6.8c-18.4-.9-36.5-5.1-53.4-12.4s-32.4-17.5-45.8-30.2C132.5 1251 53 1110.8 19 956.8s-20.9-314.6 37.6-461c58.5-146.5 159.6-272 290.3-360.3S631.8.1 789.6.5c156.8 1.3 309.6 49.6 438.8 138.4m-291.8 1014c48.2-19.2 92-48 128.7-84.6 36.7-36.7 65.5-80.4 84.7-128.6 19.2-48.1 28.4-99.7 27-151.5 0-103.9-41.3-203.5-114.8-277S889 396.4 785 396.4s-203.7 41.3-277.2 114.8S393 684.3 393 788.2c-1.4 51.8 7.8 103.4 27 151.5 19.2 48.2 48 91.9 84.7 128.6 36.7 36.6 80.5 65.4 128.6 84.6 48.2 19.2 99.8 28.4 151.7 27 51.8 1.4 103.4-7.8 151.6-27'
clipRule='evenodd'
fill='#62D84E'
d='M35.8,0C16.1,0,0,15.9,0,35.6c0,9.8,4,19.3,11.2,26c2.5,2.4,6.4,2.6,9.2,0.5c9-6.7,21.4-6.7,30.4,0
c2.8,2.1,6.7,1.9,9.2-0.5C74.3,48,74.9,25.4,61.3,11.1C54.7,4.1,45.4,0.1,35.8,0 M35.6,53.5C26,53.8,18,46.2,17.8,36.7
c0-0.3,0-0.6,0-0.9c0-9.8,8-17.8,17.8-17.8s17.8,8,17.8,17.8c0.3,9.6-7.3,17.5-16.8,17.8C36.2,53.5,35.9,53.5,35.6,53.5'
/>
</svg>
)

View File

@@ -120,117 +120,117 @@ import {
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
calendly: CalendlyIcon,
mailchimp: MailchimpIcon,
postgresql: PostgresIcon,
twilio_voice: TwilioIcon,
elasticsearch: ElasticsearchIcon,
rds: RDSIcon,
translate: TranslateIcon,
dynamodb: DynamoDBIcon,
wordpress: WordpressIcon,
tavily: TavilyIcon,
zoom: ZoomIcon,
zep: ZepIcon,
zendesk: ZendeskIcon,
youtube: YouTubeIcon,
supabase: SupabaseIcon,
vision: EyeIcon,
zoom: ZoomIcon,
confluence: ConfluenceIcon,
arxiv: ArxivIcon,
webflow: WebflowIcon,
pinecone: PineconeIcon,
apollo: ApolloIcon,
servicenow: ServiceNowIcon,
whatsapp: WhatsAppIcon,
typeform: TypeformIcon,
qdrant: QdrantIcon,
shopify: ShopifyIcon,
asana: AsanaIcon,
sqs: SQSIcon,
apify: ApifyIcon,
memory: BrainIcon,
gitlab: GitLabIcon,
polymarket: PolymarketIcon,
serper: SerperIcon,
linear: LinearIcon,
exa: ExaAIIcon,
telegram: TelegramIcon,
salesforce: SalesforceIcon,
hubspot: HubspotIcon,
hunter: HunterIOIcon,
linkup: LinkupIcon,
mongodb: MongoDBIcon,
airtable: AirtableIcon,
discord: DiscordIcon,
ahrefs: AhrefsIcon,
neo4j: Neo4jIcon,
tts: TTSIcon,
jina: JinaAIIcon,
google_docs: GoogleDocsIcon,
perplexity: PerplexityIcon,
google_search: GoogleIcon,
x: xIcon,
kalshi: KalshiIcon,
google_calendar: GoogleCalendarIcon,
zep: ZepIcon,
posthog: PosthogIcon,
grafana: GrafanaIcon,
google_slides: GoogleSlidesIcon,
microsoft_planner: MicrosoftPlannerIcon,
thinking: BrainIcon,
pipedrive: PipedriveIcon,
dropbox: DropboxIcon,
stagehand: StagehandIcon,
google_forms: GoogleFormsIcon,
file: DocumentIcon,
mistral_parse: MistralIcon,
gmail: GmailIcon,
openai: OpenAIIcon,
outlook: OutlookIcon,
incidentio: IncidentioIcon,
onedrive: MicrosoftOneDriveIcon,
resend: ResendIcon,
google_vault: GoogleVaultIcon,
sharepoint: MicrosoftSharepointIcon,
huggingface: HuggingFaceIcon,
sendgrid: SendgridIcon,
video_generator: VideoIcon,
smtp: SmtpIcon,
google_groups: GoogleGroupsIcon,
mailgun: MailgunIcon,
clay: ClayIcon,
jira: JiraIcon,
search: SearchIcon,
linkedin: LinkedInIcon,
wealthbox: WealthboxIcon,
notion: NotionIcon,
elevenlabs: ElevenLabsIcon,
microsoft_teams: MicrosoftTeamsIcon,
github: GithubIcon,
sftp: SftpIcon,
ssh: SshIcon,
google_drive: GoogleDriveIcon,
sentry: SentryIcon,
reddit: RedditIcon,
parallel_ai: ParallelIcon,
spotify: SpotifyIcon,
stripe: StripeIcon,
s3: S3Icon,
trello: TrelloIcon,
mem0: Mem0Icon,
knowledge: PackageSearchIcon,
intercom: IntercomIcon,
twilio_sms: TwilioIcon,
duckduckgo: DuckDuckGoIcon,
slack: SlackIcon,
datadog: DatadogIcon,
microsoft_excel: MicrosoftExcelIcon,
image_generator: ImageIcon,
google_sheets: GoogleSheetsIcon,
wordpress: WordpressIcon,
wikipedia: WikipediaIcon,
cursor: CursorIcon,
firecrawl: FirecrawlIcon,
mysql: MySQLIcon,
browser_use: BrowserUseIcon,
whatsapp: WhatsAppIcon,
webflow: WebflowIcon,
wealthbox: WealthboxIcon,
vision: EyeIcon,
video_generator: VideoIcon,
typeform: TypeformIcon,
twilio_voice: TwilioIcon,
twilio_sms: TwilioIcon,
tts: TTSIcon,
trello: TrelloIcon,
translate: TranslateIcon,
thinking: BrainIcon,
telegram: TelegramIcon,
tavily: TavilyIcon,
supabase: SupabaseIcon,
stt: STTIcon,
stripe: StripeIcon,
stagehand: StagehandIcon,
ssh: SshIcon,
sqs: SQSIcon,
spotify: SpotifyIcon,
smtp: SmtpIcon,
slack: SlackIcon,
shopify: ShopifyIcon,
sharepoint: MicrosoftSharepointIcon,
sftp: SftpIcon,
servicenow: ServiceNowIcon,
serper: SerperIcon,
sentry: SentryIcon,
sendgrid: SendgridIcon,
search: SearchIcon,
salesforce: SalesforceIcon,
s3: S3Icon,
resend: ResendIcon,
reddit: RedditIcon,
rds: RDSIcon,
qdrant: QdrantIcon,
posthog: PosthogIcon,
postgresql: PostgresIcon,
polymarket: PolymarketIcon,
pipedrive: PipedriveIcon,
pinecone: PineconeIcon,
perplexity: PerplexityIcon,
parallel_ai: ParallelIcon,
outlook: OutlookIcon,
openai: OpenAIIcon,
onedrive: MicrosoftOneDriveIcon,
notion: NotionIcon,
neo4j: Neo4jIcon,
mysql: MySQLIcon,
mongodb: MongoDBIcon,
mistral_parse: MistralIcon,
microsoft_teams: MicrosoftTeamsIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_excel: MicrosoftExcelIcon,
memory: BrainIcon,
mem0: Mem0Icon,
mailgun: MailgunIcon,
mailchimp: MailchimpIcon,
linkup: LinkupIcon,
linkedin: LinkedInIcon,
linear: LinearIcon,
knowledge: PackageSearchIcon,
kalshi: KalshiIcon,
jira: JiraIcon,
jina: JinaAIIcon,
intercom: IntercomIcon,
incidentio: IncidentioIcon,
image_generator: ImageIcon,
hunter: HunterIOIcon,
huggingface: HuggingFaceIcon,
hubspot: HubspotIcon,
grafana: GrafanaIcon,
google_vault: GoogleVaultIcon,
google_slides: GoogleSlidesIcon,
google_sheets: GoogleSheetsIcon,
google_groups: GoogleGroupsIcon,
google_forms: GoogleFormsIcon,
google_drive: GoogleDriveIcon,
google_docs: GoogleDocsIcon,
google_calendar: GoogleCalendarIcon,
google_search: GoogleIcon,
gmail: GmailIcon,
gitlab: GitLabIcon,
github: GithubIcon,
firecrawl: FirecrawlIcon,
file: DocumentIcon,
exa: ExaAIIcon,
elevenlabs: ElevenLabsIcon,
elasticsearch: ElasticsearchIcon,
dynamodb: DynamoDBIcon,
duckduckgo: DuckDuckGoIcon,
dropbox: DropboxIcon,
discord: DiscordIcon,
datadog: DatadogIcon,
cursor: CursorIcon,
confluence: ConfluenceIcon,
clay: ClayIcon,
calendly: CalendlyIcon,
browser_use: BrowserUseIcon,
asana: AsanaIcon,
arxiv: ArxivIcon,
apollo: ApolloIcon,
apify: ApifyIcon,
airtable: AirtableIcon,
ahrefs: AhrefsIcon,
}

View File

@@ -1,6 +1,6 @@
---
title: ServiceNow
description: Create, read, update, delete, and bulk import ServiceNow records
description: Create, read, update, and delete ServiceNow records
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -10,9 +10,23 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#032D42"
/>
{/* MANUAL-CONTENT-START:intro */}
[ServiceNow](https://www.servicenow.com/) is a powerful cloud platform designed to streamline and automate IT service management (ITSM), workflows, and business processes across your organization. ServiceNow enables you to manage incidents, requests, tasks, users, and more using its extensive API.
With ServiceNow, you can:
- **Automate IT workflows**: Create, read, update, and delete records in any ServiceNow table, such as incidents, tasks, change requests, and users.
- **Integrate systems**: Connect ServiceNow with your other tools and processes for seamless automation.
- **Maintain a single source of truth**: Keep all your service and operations data organized and accessible.
- **Drive operational efficiency**: Reduce manual work and improve service quality with customizable workflows and automation.
In Sim, the ServiceNow integration enables your agents to interact directly with your ServiceNow instance as part of their workflows. Agents can create, read, update, or delete records in any ServiceNow table and leverage ticket or user data for sophisticated automation and decision-making. This integration bridges your workflow automation and IT operations, empowering your agents to manage service requests, incidents, users, and assets without manual intervention. By connecting Sim with ServiceNow, you can automate service management tasks, improve response times, and ensure consistent, secure access to your organization's vital service data.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
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.
@@ -27,7 +41,8 @@ Create a new record in a ServiceNow table
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) |
| `credential` | string | No | ServiceNow OAuth credential ID |
| `username` | string | Yes | ServiceNow username |
| `password` | string | Yes | ServiceNow password |
| `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user\) |
| `fields` | json | Yes | Fields to set on the record \(JSON object\) |
@@ -46,8 +61,9 @@ Read records from a ServiceNow table
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) |
| `credential` | string | No | ServiceNow OAuth credential ID |
| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) |
| `username` | string | Yes | ServiceNow username |
| `password` | string | Yes | ServiceNow password |
| `tableName` | string | Yes | Table name |
| `sysId` | string | No | Specific record sys_id |
| `number` | string | No | Record number \(e.g., INC0010001\) |
@@ -70,8 +86,9 @@ Update an existing record in a ServiceNow table
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) |
| `credential` | string | No | ServiceNow OAuth credential ID |
| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) |
| `username` | string | Yes | ServiceNow username |
| `password` | string | Yes | ServiceNow password |
| `tableName` | string | Yes | Table name |
| `sysId` | string | Yes | Record sys_id to update |
| `fields` | json | Yes | Fields to update \(JSON object\) |
@@ -91,8 +108,9 @@ Delete a record from a ServiceNow table
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) |
| `credential` | string | No | ServiceNow OAuth credential ID |
| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) |
| `username` | string | Yes | ServiceNow username |
| `password` | string | Yes | ServiceNow password |
| `tableName` | string | Yes | Table name |
| `sysId` | string | Yes | Record sys_id to delete |

View File

@@ -50,6 +50,8 @@ Send a chat completion request to any supported LLM provider
| `maxTokens` | number | No | Maximum tokens in the response |
| `azureEndpoint` | string | No | Azure OpenAI endpoint URL |
| `azureApiVersion` | string | No | Azure OpenAI API version |
| `vertexProject` | string | No | Google Cloud project ID for Vertex AI |
| `vertexLocation` | string | No | Google Cloud location for Vertex AI \(defaults to us-central1\) |
#### Output

View File

@@ -70,6 +70,7 @@ export const FOOTER_TOOLS = [
'Salesforce',
'SendGrid',
'Serper',
'ServiceNow',
'SharePoint',
'Slack',
'Smtp',

View File

@@ -2,7 +2,6 @@ import { Suspense } from 'react'
import dynamic from 'next/dynamic'
import { Background, Footer, Nav, StructuredData } from '@/app/(landing)/components'
// Lazy load heavy components for better initial load performance
const Hero = dynamic(() => import('@/app/(landing)/components/hero/hero'), {
loading: () => <div className='h-[600px] animate-pulse bg-gray-50' />,
})

View File

@@ -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()
})
})
})

View File

@@ -132,14 +132,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
try {
// Use the existing refreshOAuthToken function
// For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint
const instanceUrl =
providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined
const refreshResult = await refreshOAuthToken(
providerId,
credential.refreshToken!,
instanceUrl
)
const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!)
if (!refreshResult) {
logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, {
@@ -222,13 +215,9 @@ export async function refreshAccessTokenIfNeeded(
if (shouldRefresh) {
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
try {
// For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint
const instanceUrl =
credential.providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined
const refreshedToken = await refreshOAuthToken(
credential.providerId,
credential.refreshToken!,
instanceUrl
credential.refreshToken!
)
if (!refreshedToken) {
@@ -300,14 +289,7 @@ export async function refreshTokenIfNeeded(
}
try {
// For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint
const instanceUrl =
credential.providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined
const refreshResult = await refreshOAuthToken(
credential.providerId,
credential.refreshToken!,
instanceUrl
)
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!)
if (!refreshResult) {
logger.error(`[${requestId}] Failed to refresh token for credential`)

View File

@@ -1,166 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ServiceNowCallback')
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
const baseUrl = getBaseUrl()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
}
const { searchParams } = request.nextUrl
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
// Handle OAuth errors from ServiceNow
if (error) {
logger.error('ServiceNow OAuth error:', { error, errorDescription })
return NextResponse.redirect(
`${baseUrl}/workspace?error=servicenow_auth_error&message=${encodeURIComponent(errorDescription || error)}`
)
}
const storedState = request.cookies.get('servicenow_oauth_state')?.value
const storedInstanceUrl = request.cookies.get('servicenow_instance_url')?.value
const clientId = env.SERVICENOW_CLIENT_ID
const clientSecret = env.SERVICENOW_CLIENT_SECRET
if (!clientId || !clientSecret) {
logger.error('ServiceNow credentials not configured')
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_config_error`)
}
// Validate state parameter
if (!state || state !== storedState) {
logger.error('State mismatch in ServiceNow OAuth callback')
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_state_mismatch`)
}
// Validate authorization code
if (!code) {
logger.error('No code received from ServiceNow')
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_code`)
}
// Validate instance URL
if (!storedInstanceUrl) {
logger.error('No instance URL stored')
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_instance`)
}
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/servicenow`
// Exchange authorization code for access token
const tokenResponse = await fetch(`${storedInstanceUrl}/oauth_token.do`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
}).toString(),
})
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
logger.error('Failed to exchange code for token:', {
status: tokenResponse.status,
body: errorText,
})
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_token_error`)
}
const tokenData = await tokenResponse.json()
const accessToken = tokenData.access_token
const refreshToken = tokenData.refresh_token
const expiresIn = tokenData.expires_in
// ServiceNow always grants 'useraccount' scope but returns empty string
const scope = tokenData.scope || 'useraccount'
logger.info('ServiceNow token exchange successful:', {
hasAccessToken: !!accessToken,
hasRefreshToken: !!refreshToken,
expiresIn,
})
if (!accessToken) {
logger.error('No access token in response')
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_token`)
}
// Redirect to store endpoint with token data in cookies
const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/servicenow/store`)
const response = NextResponse.redirect(storeUrl)
// Store token data in secure cookies for the store endpoint
response.cookies.set('servicenow_pending_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60, // 1 minute
path: '/',
})
if (refreshToken) {
response.cookies.set('servicenow_pending_refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
}
response.cookies.set('servicenow_pending_instance', storedInstanceUrl, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
response.cookies.set('servicenow_pending_scope', scope || '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
if (expiresIn) {
response.cookies.set('servicenow_pending_expires_in', expiresIn.toString(), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60,
path: '/',
})
}
// Clean up OAuth state cookies
response.cookies.delete('servicenow_oauth_state')
response.cookies.delete('servicenow_instance_url')
return response
} catch (error) {
logger.error('Error in ServiceNow OAuth callback:', error)
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_callback_error`)
}
}

View File

@@ -1,142 +0,0 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
const logger = createLogger('ServiceNowStore')
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
const baseUrl = getBaseUrl()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized attempt to store ServiceNow token')
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
}
// Retrieve token data from cookies
const accessToken = request.cookies.get('servicenow_pending_token')?.value
const refreshToken = request.cookies.get('servicenow_pending_refresh_token')?.value
const instanceUrl = request.cookies.get('servicenow_pending_instance')?.value
const scope = request.cookies.get('servicenow_pending_scope')?.value
const expiresInStr = request.cookies.get('servicenow_pending_expires_in')?.value
if (!accessToken || !instanceUrl) {
logger.error('Missing token or instance URL in cookies')
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_missing_data`)
}
// Validate the token by fetching user info from ServiceNow
const userResponse = await fetch(
`${instanceUrl}/api/now/table/sys_user?sysparm_query=user_name=${encodeURIComponent('javascript:gs.getUserName()')}&sysparm_limit=1`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
}
)
// Alternative: Use the instance info endpoint instead
let accountIdentifier = instanceUrl
let userInfo: Record<string, unknown> | null = null
// Try to get current user info
try {
const whoamiResponse = await fetch(`${instanceUrl}/api/now/ui/user/current_user`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (whoamiResponse.ok) {
const whoamiData = await whoamiResponse.json()
userInfo = whoamiData.result
if (userInfo?.user_sys_id) {
accountIdentifier = userInfo.user_sys_id as string
} else if (userInfo?.user_name) {
accountIdentifier = userInfo.user_name as string
}
logger.info('Retrieved ServiceNow user info', { accountIdentifier })
}
} catch (e) {
logger.warn('Could not retrieve ServiceNow user info, using instance URL as identifier')
}
// Calculate expiration time
const now = new Date()
const expiresIn = expiresInStr ? Number.parseInt(expiresInStr, 10) : 3600 // Default to 1 hour
const accessTokenExpiresAt = new Date(now.getTime() + expiresIn * 1000)
// Check for existing ServiceNow account for this user
const existing = await db.query.account.findFirst({
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'servicenow')),
})
// ServiceNow always grants 'useraccount' scope but returns empty string
const effectiveScope = scope?.trim() ? scope : 'useraccount'
const accountData = {
accessToken: accessToken,
refreshToken: refreshToken || null,
accountId: accountIdentifier,
scope: effectiveScope,
updatedAt: now,
accessTokenExpiresAt: accessTokenExpiresAt,
idToken: instanceUrl, // Store instance URL in idToken for API calls
}
if (existing) {
await db.update(account).set(accountData).where(eq(account.id, existing.id))
logger.info('Updated existing ServiceNow account', { accountId: existing.id })
} else {
await safeAccountInsert(
{
id: `servicenow_${session.user.id}_${Date.now()}`,
userId: session.user.id,
providerId: 'servicenow',
accountId: accountData.accountId,
accessToken: accountData.accessToken,
refreshToken: accountData.refreshToken || undefined,
accessTokenExpiresAt: accountData.accessTokenExpiresAt,
scope: accountData.scope,
idToken: accountData.idToken,
createdAt: now,
updatedAt: now,
},
{ provider: 'ServiceNow', identifier: instanceUrl }
)
logger.info('Created new ServiceNow account')
}
// Get return URL from cookie
const returnUrl = request.cookies.get('servicenow_return_url')?.value
const redirectUrl = returnUrl || `${baseUrl}/workspace`
const finalUrl = new URL(redirectUrl)
finalUrl.searchParams.set('servicenow_connected', 'true')
const response = NextResponse.redirect(finalUrl.toString())
// Clean up all ServiceNow cookies
response.cookies.delete('servicenow_pending_token')
response.cookies.delete('servicenow_pending_refresh_token')
response.cookies.delete('servicenow_pending_instance')
response.cookies.delete('servicenow_pending_scope')
response.cookies.delete('servicenow_pending_expires_in')
response.cookies.delete('servicenow_return_url')
return response
} catch (error) {
logger.error('Error storing ServiceNow token:', error)
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_store_error`)
}
}

View File

@@ -1,264 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ServiceNowAuthorize')
export const dynamic = 'force-dynamic'
/**
* ServiceNow OAuth scopes
* useraccount - Default scope for user account access
* Note: ServiceNow always returns 'useraccount' in OAuth responses regardless of requested scopes.
* Table API permissions are configured at the OAuth application level in ServiceNow.
*/
const SERVICENOW_SCOPES = 'useraccount'
/**
* Validates a ServiceNow instance URL format
*/
function isValidInstanceUrl(url: string): boolean {
try {
const parsed = new URL(url)
return (
parsed.protocol === 'https:' &&
(parsed.hostname.endsWith('.service-now.com') || parsed.hostname.endsWith('.servicenow.com'))
)
} catch {
return false
}
}
export async function GET(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const clientId = env.SERVICENOW_CLIENT_ID
if (!clientId) {
logger.error('SERVICENOW_CLIENT_ID not configured')
return NextResponse.json({ error: 'ServiceNow client ID not configured' }, { status: 500 })
}
const instanceUrl = request.nextUrl.searchParams.get('instanceUrl')
const returnUrl = request.nextUrl.searchParams.get('returnUrl')
if (!instanceUrl) {
const returnUrlParam = returnUrl ? encodeURIComponent(returnUrl) : ''
return new NextResponse(
`<!DOCTYPE html>
<html>
<head>
<title>Connect ServiceNow Instance</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #81B5A1 0%, #5A8A75 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
text-align: center;
max-width: 450px;
width: 90%;
}
h2 {
color: #111827;
margin: 0 0 0.5rem 0;
}
p {
color: #6b7280;
margin: 0 0 1.5rem 0;
}
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 {
outline: none;
border-color: #81B5A1;
box-shadow: 0 0 0 3px rgba(129, 181, 161, 0.2);
}
button {
width: 100%;
padding: 0.75rem;
background: #81B5A1;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
}
button:hover {
background: #6A9A87;
}
.help {
font-size: 0.875rem;
color: #9ca3af;
margin-top: 1rem;
}
.error {
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
display: none;
}
</style>
</head>
<body>
<div class="container">
<h2>Connect Your ServiceNow Instance</h2>
<p>Enter your ServiceNow instance URL to continue</p>
<div id="error" class="error"></div>
<form onsubmit="handleSubmit(event)">
<input
type="text"
id="instanceUrl"
placeholder="https://mycompany.service-now.com"
required
/>
<button type="submit">Connect Instance</button>
</form>
<p class="help">Your instance URL looks like: https://yourcompany.service-now.com</p>
</div>
<script>
const returnUrl = '${returnUrlParam}';
function handleSubmit(e) {
e.preventDefault();
const errorEl = document.getElementById('error');
let instanceUrl = document.getElementById('instanceUrl').value.trim();
// Ensure https:// prefix
if (!instanceUrl.startsWith('https://') && !instanceUrl.startsWith('http://')) {
instanceUrl = 'https://' + instanceUrl;
}
// Validate the URL format
try {
const parsed = new URL(instanceUrl);
if (!parsed.hostname.endsWith('.service-now.com') && !parsed.hostname.endsWith('.servicenow.com')) {
errorEl.textContent = 'Please enter a valid ServiceNow instance URL (e.g., https://yourcompany.service-now.com)';
errorEl.style.display = 'block';
return;
}
// Clean the URL (remove trailing slashes, paths)
instanceUrl = parsed.origin;
} catch {
errorEl.textContent = 'Please enter a valid URL';
errorEl.style.display = 'block';
return;
}
let url = window.location.pathname + '?instanceUrl=' + encodeURIComponent(instanceUrl);
if (returnUrl) {
url += '&returnUrl=' + returnUrl;
}
window.location.href = url;
}
</script>
</body>
</html>`,
{
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 and instance URL 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: '/',
})
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 })
}
}

View File

@@ -347,13 +347,6 @@ export function OAuthRequiredModal({
return
}
if (providerId === 'servicenow') {
// Pass the current URL so we can redirect back after OAuth
const returnUrl = encodeURIComponent(window.location.href)
window.location.href = `/api/auth/servicenow/authorize?returnUrl=${returnUrl}`
return
}
await client.oauth2.link({
providerId,
callbackURL: window.location.href,

View File

@@ -12,6 +12,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
@@ -45,10 +46,14 @@ export function CredentialSelector({
const label = subBlock.placeholder || 'Select credential'
const serviceId = subBlock.serviceId || ''
const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const hasDependencies = dependsOn.length > 0
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]
@@ -130,7 +135,7 @@ export function CredentialSelector({
const needsUpdate =
hasSelection &&
missingRequiredScopes.length > 0 &&
!disabled &&
!effectiveDisabled &&
!isPreview &&
!credentialsLoading
@@ -230,8 +235,10 @@ export function CredentialSelector({
selectedValue={selectedId}
onChange={handleComboboxChange}
onOpenChange={handleOpenChange}
placeholder={label}
disabled={disabled}
placeholder={
hasDependencies && !depsSatisfied ? 'Fill in required fields above first' : label
}
disabled={effectiveDisabled}
editable={true}
filterOptions={true}
isLoading={credentialsLoading}

View File

@@ -1,16 +1,13 @@
import { ServiceNowIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { ServiceNowResponse } from '@/tools/servicenow/types'
export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
type: 'servicenow',
name: 'ServiceNow',
description: 'Create, read, update, delete, and bulk import ServiceNow records',
authMode: AuthMode.OAuth,
hideFromToolbar: true,
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',
@@ -22,12 +19,12 @@ export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
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
{
@@ -36,17 +33,26 @@ export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
type: 'short-input',
placeholder: 'https://instance.service-now.com',
required: true,
description: 'Your ServiceNow instance URL',
description: 'Your ServiceNow instance URL (e.g., https://yourcompany.service-now.com)',
},
// OAuth Credential
// Username
{
id: 'credential',
title: 'ServiceNow Account',
type: 'oauth-input',
serviceId: 'servicenow',
requiredScopes: ['useraccount'],
placeholder: 'Select ServiceNow account',
id: 'username',
title: 'Username',
type: 'short-input',
placeholder: 'Enter your ServiceNow username',
required: true,
description: 'ServiceNow user with web service access',
},
// Password
{
id: 'password',
title: 'Password',
type: 'short-input',
placeholder: 'Enter your ServiceNow password',
password: true,
required: true,
description: 'Password for the ServiceNow user',
},
// Table Name
{
@@ -64,7 +70,7 @@ export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
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,
@@ -97,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',
},
{
@@ -119,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
@@ -135,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,
},
{
@@ -144,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,
@@ -176,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,
},
],
@@ -188,60 +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, ...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<string, any> | 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
const baseParams: Record<string, any> = {
...rest,
credential,
}
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' },
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' },

View File

@@ -3387,17 +3387,14 @@ export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
export function ServiceNowIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1570 1403'
width='48'
height='48'
>
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 71.1 63.6'>
<path
fill='#62d84e'
fillRule='evenodd'
d='M1228.4 138.9c129.2 88.9 228.9 214.3 286.3 360.2 57.5 145.8 70 305.5 36 458.5S1437.8 1250 1324 1357.9c-13.3 12.9-28.8 23.4-45.8 30.8-17 7.5-35.2 11.9-53.7 12.9-18.5 1.1-37.1-1.1-54.8-6.6-17.7-5.4-34.3-13.9-49.1-25.2-48.2-35.9-101.8-63.8-158.8-82.6-57.1-18.9-116.7-28.5-176.8-28.5s-119.8 9.6-176.8 28.5c-57 18.8-110.7 46.7-158.9 82.6-14.6 11.2-31 19.8-48.6 25.3s-36 7.8-54.4 6.8c-18.4-.9-36.5-5.1-53.4-12.4s-32.4-17.5-45.8-30.2C132.5 1251 53 1110.8 19 956.8s-20.9-314.6 37.6-461c58.5-146.5 159.6-272 290.3-360.3S631.8.1 789.6.5c156.8 1.3 309.6 49.6 438.8 138.4m-291.8 1014c48.2-19.2 92-48 128.7-84.6 36.7-36.7 65.5-80.4 84.7-128.6 19.2-48.1 28.4-99.7 27-151.5 0-103.9-41.3-203.5-114.8-277S889 396.4 785 396.4s-203.7 41.3-277.2 114.8S393 684.3 393 788.2c-1.4 51.8 7.8 103.4 27 151.5 19.2 48.2 48 91.9 84.7 128.6 36.7 36.6 80.5 65.4 128.6 84.6 48.2 19.2 99.8 28.4 151.7 27 51.8 1.4 103.4-7.8 151.6-27'
clipRule='evenodd'
fill='#62D84E'
d='M35.8,0C16.1,0,0,15.9,0,35.6c0,9.8,4,19.3,11.2,26c2.5,2.4,6.4,2.6,9.2,0.5c9-6.7,21.4-6.7,30.4,0
c2.8,2.1,6.7,1.9,9.2-0.5C74.3,48,74.9,25.4,61.3,11.1C54.7,4.1,45.4,0.1,35.8,0 M35.6,53.5C26,53.8,18,46.2,17.8,36.7
c0-0.3,0-0.6,0-0.9c0-9.8,8-17.8,17.8-17.8s17.8,8,17.8,17.8c0.3,9.6-7.3,17.5-16.8,17.8C36.2,53.5,35.9,53.5,35.6,53.5'
/>
</svg>
)

View File

@@ -28,7 +28,7 @@ export interface ServiceInfo extends OAuthServiceConfig {
function defineServices(): ServiceInfo[] {
const servicesList: ServiceInfo[] = []
Object.values(OAUTH_PROVIDERS).forEach((provider) => {
Object.entries(OAUTH_PROVIDERS).forEach(([_providerKey, provider]) => {
Object.values(provider.services).forEach((service) => {
servicesList.push({
...service,
@@ -142,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,

View File

@@ -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

View File

@@ -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<string, OAuthProviderConfig> = {
},
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,36 +1565,20 @@ 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')
const provider = providerId.split('-')[0]
// 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`
}
// 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(),
@@ -1639,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) {
@@ -1663,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) {

View File

@@ -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<ServiceNowCreateParams, ServiceNowCrea
description: 'Create a new record in a ServiceNow table',
version: '1.0.0',
oauth: {
required: true,
provider: 'servicenow',
},
params: {
instanceUrl: {
type: 'string',
@@ -22,11 +18,17 @@ export const createRecordTool: ToolConfig<ServiceNowCreateParams, ServiceNowCrea
visibility: 'user-only',
description: 'ServiceNow instance URL (e.g., https://instance.service-now.com)',
},
credential: {
username: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'ServiceNow OAuth credential ID',
required: true,
visibility: 'user-only',
description: 'ServiceNow username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'ServiceNow password',
},
tableName: {
type: 'string',
@@ -44,8 +46,7 @@ export const createRecordTool: ToolConfig<ServiceNowCreateParams, ServiceNowCrea
request: {
url: (params) => {
// 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<ServiceNowCreateParams, ServiceNowCrea
},
method: 'POST',
headers: (params) => {
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',
}

View File

@@ -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<ServiceNowDeleteParams, ServiceNowDele
description: 'Delete a record from a ServiceNow table',
version: '1.0.0',
oauth: {
required: true,
provider: 'servicenow',
},
params: {
instanceUrl: {
type: 'string',
required: false,
required: true,
visibility: 'user-only',
description: 'ServiceNow instance URL (auto-detected from OAuth if not provided)',
description: 'ServiceNow instance URL (e.g., https://instance.service-now.com)',
},
credential: {
username: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'ServiceNow OAuth credential ID',
required: true,
visibility: 'user-only',
description: 'ServiceNow username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'ServiceNow password',
},
tableName: {
type: 'string',
@@ -44,8 +46,7 @@ export const deleteRecordTool: ToolConfig<ServiceNowDeleteParams, ServiceNowDele
request: {
url: (params) => {
// 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<ServiceNowDeleteParams, ServiceNowDele
},
method: 'DELETE',
headers: (params) => {
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',
}
},

View File

@@ -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<ServiceNowReadParams, ServiceNowReadResp
description: 'Read records from a ServiceNow table',
version: '1.0.0',
oauth: {
required: true,
provider: 'servicenow',
},
params: {
instanceUrl: {
type: 'string',
required: false,
required: true,
visibility: 'user-only',
description: 'ServiceNow instance URL (auto-detected from OAuth if not provided)',
description: 'ServiceNow instance URL (e.g., https://instance.service-now.com)',
},
credential: {
username: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'ServiceNow OAuth credential ID',
required: true,
visibility: 'user-only',
description: 'ServiceNow username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'ServiceNow password',
},
tableName: {
type: 'string',
@@ -68,8 +70,7 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
request: {
url: (params) => {
// 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<ServiceNowReadParams, ServiceNowReadResp
if (params.sysId) {
url = `${url}/${params.sysId}`
} else if (params.number) {
queryParams.append('number', params.number)
}
if (params.query) {
const numberQuery = `number=${params.number}`
const existingQuery = params.query
queryParams.append(
'sysparm_query',
existingQuery ? `${existingQuery}^${numberQuery}` : numberQuery
)
} else if (params.query) {
queryParams.append('sysparm_query', params.query)
}
@@ -100,11 +104,11 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
},
method: 'GET',
headers: (params) => {
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',
}
},

View File

@@ -7,12 +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
}
export interface ServiceNowCreateParams extends ServiceNowBaseParams {

View File

@@ -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<ServiceNowUpdateParams, ServiceNowUpda
description: 'Update an existing record in a ServiceNow table',
version: '1.0.0',
oauth: {
required: true,
provider: 'servicenow',
},
params: {
instanceUrl: {
type: 'string',
required: false,
required: true,
visibility: 'user-only',
description: 'ServiceNow instance URL (auto-detected from OAuth if not provided)',
description: 'ServiceNow instance URL (e.g., https://instance.service-now.com)',
},
credential: {
username: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'ServiceNow OAuth credential ID',
required: true,
visibility: 'user-only',
description: 'ServiceNow username',
},
password: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'ServiceNow password',
},
tableName: {
type: 'string',
@@ -50,8 +52,7 @@ export const updateRecordTool: ToolConfig<ServiceNowUpdateParams, ServiceNowUpda
request: {
url: (params) => {
// 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<ServiceNowUpdateParams, ServiceNowUpda
},
method: 'PATCH',
headers: (params) => {
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',
}

View File

@@ -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}`
}