/** * ServiceNow OAuth Authentication for SnowCode * Handles OAuth2 flow for ServiceNow integration */ import crypto from "crypto" import * as prompts from "@clack/prompts" import { Auth } from "./index" /** * OAuth HTML Templates with Snow-Flow minimalist branding */ const baseStyles = ` `; const logoSVG = ` SNOW FLOW `; const OAuthTemplates = { success: ` Snow-Flow - Authentication Successful ${baseStyles}

Connected!

Your ServiceNow instance is now connected to Snow-Flow.

You can close this window and return to your terminal.
`, error: (error: string) => ` Snow-Flow - Authentication Error ${baseStyles}

Authentication Failed

We couldn't complete the authentication process.

${error}
Please close this window and try again.
`, securityError: ` Snow-Flow - Security Error ${baseStyles}

Security Error

Invalid state parameter detected - possible CSRF attack.

Please close this window and start the authentication process again.
`, missingCode: ` Snow-Flow - Missing Authorization Code ${baseStyles}

Missing Authorization

No authorization code was received from ServiceNow.

Make sure you approve the authorization request in ServiceNow, then try again.
`, tokenExchangeFailed: (error: string) => ` Snow-Flow - Token Exchange Failed ${baseStyles}

Token Exchange Failed

Unable to exchange authorization code for access token.

${error}
Please verify your OAuth configuration (Client ID and Client Secret) and try again.
` } export interface ServiceNowAuthResult { success: boolean accessToken?: string refreshToken?: string expiresIn?: number error?: string } export interface ServiceNowOAuthOptions { instance: string clientId: string clientSecret: string } export class ServiceNowOAuth { private stateParameter?: string private codeVerifier?: string private codeChallenge?: string // Rate limiting private lastTokenRequest: number = 0 private tokenRequestCount: number = 0 private readonly TOKEN_REQUEST_WINDOW_MS = 60000 // 1 minute private readonly MAX_TOKEN_REQUESTS_PER_WINDOW = 10 /** * Check rate limiting for token requests */ private checkTokenRequestRateLimit(): boolean { const now = Date.now() if (now - this.lastTokenRequest > this.TOKEN_REQUEST_WINDOW_MS) { this.tokenRequestCount = 0 this.lastTokenRequest = now } if (this.tokenRequestCount >= this.MAX_TOKEN_REQUESTS_PER_WINDOW) { prompts.log.warn("Rate limit exceeded: Too many token requests. Please wait before retrying.") return false } this.tokenRequestCount++ return true } /** * Generate a random state parameter for CSRF protection */ private generateState(): string { return crypto.randomBytes(16).toString("base64url") } /** * Generate PKCE code verifier and challenge */ private generatePKCE() { this.codeVerifier = crypto.randomBytes(32).toString("base64url") const hash = crypto.createHash("sha256") hash.update(this.codeVerifier) this.codeChallenge = hash.digest("base64url") } /** * Normalize instance URL */ private normalizeInstanceUrl(instance: string): string { let normalized = instance.replace(/\/+$/, "") if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) { normalized = `https://${normalized}` } if ( !normalized.includes(".service-now.com") && !normalized.includes("localhost") && !normalized.includes("127.0.0.1") ) { const instanceName = normalized.replace("https://", "").replace("http://", "") normalized = `https://${instanceName}.service-now.com` } return normalized } /** * Validate client secret format */ private validateClientSecret(secret: string): { valid: boolean; reason?: string } { if (!secret || secret.trim() === "") { return { valid: false, reason: "Client secret is required" } } if (secret.length < 32) { return { valid: false, reason: "Client secret is too short (should be 32+ characters)", } } const commonPasswords = ["password", "admin", "secret", "client", "snow"] const lowerSecret = secret.toLowerCase() for (const common of commonPasswords) { if (lowerSecret.includes(common)) { return { valid: false, reason: `Client secret appears to be a common password - it should be a random string from ServiceNow`, } } } return { valid: true } } /** * Generate authorization URL */ private generateAuthUrl(instance: string, clientId: string): string { const baseUrl = instance.startsWith("http") ? instance : `https://${instance}` const params = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: "urn:ietf:wg:oauth:2.0:oob", state: this.stateParameter!, code_challenge: this.codeChallenge!, code_challenge_method: "S256", }) return `${baseUrl}/oauth_auth.do?${params.toString()}` } /** * Exchange authorization code for tokens */ private async exchangeCodeForTokens( instance: string, clientId: string, clientSecret: string, code: string, redirectUri?: string, ): Promise { if (!this.checkTokenRequestRateLimit()) { return { success: false, error: "Rate limit exceeded. Please wait before retrying.", } } const baseUrl = instance.startsWith("http") ? instance : `https://${instance}` const tokenUrl = `${baseUrl}/oauth_token.do` // redirect_uri MUST match the one used in authorization request const finalRedirectUri = redirectUri || "http://localhost:3005/callback" const params = new URLSearchParams({ grant_type: "authorization_code", code: code, redirect_uri: finalRedirectUri, client_id: clientId, client_secret: clientSecret, code_verifier: this.codeVerifier!, }) try { const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: params.toString(), }) if (!response.ok) { const errorText = await response.text() return { success: false, error: `Token exchange failed: ${response.status} ${errorText}`, } } const data = await response.json() if (data.error) { return { success: false, error: `OAuth error: ${data.error} - ${data.error_description || ""}`, } } return { success: true, accessToken: data.access_token, refreshToken: data.refresh_token, expiresIn: data.expires_in, } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), } } } /** * Detect if running in GitHub Codespaces */ private isCodespace(): boolean { // Check multiple Codespace indicators const hasCodespacesEnv = process.env.CODESPACES === 'true' const hasCodespaceName = !!process.env.CODESPACE_NAME const hasForwardingDomain = !!process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN // Debug logging if (hasCodespacesEnv || hasCodespaceName || hasForwardingDomain) { prompts.log.info(`🔍 Codespace detection:`) prompts.log.message(` CODESPACES=${process.env.CODESPACES}`) prompts.log.message(` CODESPACE_NAME=${process.env.CODESPACE_NAME}`) prompts.log.message(` GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`) } return hasCodespacesEnv || (hasCodespaceName && hasForwardingDomain) } /** * Get Codespace forwarded URL for port 3005 */ private getCodespaceForwardedUrl(): string | null { const codespaceName = process.env.CODESPACE_NAME const forwardingDomain = process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN if (!codespaceName || !forwardingDomain) { return null } return `https://${codespaceName}-3005.${forwardingDomain}/callback` } /** * Main authentication flow with localhost callback server */ async authenticate(options: ServiceNowOAuthOptions): Promise { try { const normalizedInstance = this.normalizeInstanceUrl(options.instance) // Validate client secret const secretValidation = this.validateClientSecret(options.clientSecret) if (!secretValidation.valid) { prompts.log.error(`Invalid OAuth Client Secret: ${secretValidation.reason}`) prompts.log.info("To get a valid OAuth secret:") prompts.log.message(" 1. Log into ServiceNow as admin") prompts.log.message(" 2. Navigate to: System OAuth > Application Registry") prompts.log.message(" 3. Create a new OAuth application") prompts.log.message(" 4. Set Redirect URL to: http://localhost:3005/callback") prompts.log.message(" 5. Copy the generated Client Secret") return { success: false, error: secretValidation.reason, } } prompts.log.step("Starting ServiceNow OAuth flow") prompts.log.info(`Instance: ${normalizedInstance}`) // Generate state and PKCE this.stateParameter = this.generateState() this.generatePKCE() // Always use localhost - simplest approach for all environments const port = 3005 const redirectUri = `http://localhost:${port}/callback` prompts.log.message("") // Generate authorization URL const authUrl = this.generateAuthUrlWithCallback(normalizedInstance, options.clientId, redirectUri) prompts.log.step("Opening browser for authentication...") prompts.log.info(`Authorization URL: ${authUrl}`) prompts.log.message("") // Auto-open browser this.openBrowser(authUrl) // Start callback server // In remote environments (Codespaces, Gitpod, etc), the callback won't reach localhost // The user will be prompted to paste the callback URL after clicking Approve const result = await this.startCallbackServer(port, normalizedInstance, options.clientId, options.clientSecret, redirectUri) if (result.success && result.accessToken) { // Save to SnowCode auth store await Auth.set("servicenow", { type: "servicenow-oauth", instance: normalizedInstance, clientId: options.clientId, clientSecret: options.clientSecret, accessToken: result.accessToken, refreshToken: result.refreshToken, expiresAt: result.expiresIn ? Date.now() + result.expiresIn * 1000 : undefined, }) prompts.log.success("Tokens saved securely") } return result } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) prompts.log.error(`Authentication failed: ${errorMessage}`) return { success: false, error: errorMessage, } } } /** * Handle authentication in Codespaces environment * With out-of-band flow, ServiceNow displays the authorization code directly */ private async handleCodespaceAuth( instance: string, clientId: string, clientSecret: string, ): Promise { prompts.log.info("📋 After approving in ServiceNow, copy the authorization code displayed") prompts.log.message("ServiceNow will show you an authorization code on the success page.") prompts.log.message("") const authCode = (await prompts.text({ message: "Paste the authorization code here:", placeholder: "Enter the code from ServiceNow", validate: (value) => { if (!value || value.trim() === "") { return "Authorization code is required" } // Authorization codes are typically alphanumeric, 20-40 chars if (value.trim().length < 10) { return "Authorization code seems too short" } return undefined }, })) as string if (prompts.isCancel(authCode)) { return { success: false, error: "Authentication cancelled by user", } } // Clean the code (remove whitespace) const code = authCode.trim() // Exchange code for tokens prompts.log.message("") const spinner = prompts.spinner() spinner.start("Exchanging authorization code for tokens") // Use out-of-band redirect_uri for Codespaces const tokenResult = await this.exchangeCodeForTokens( instance, clientId, clientSecret, code, "urn:ietf:wg:oauth:2.0:oob" ) if (tokenResult.success) { spinner.stop("Authentication successful ✓") } else { spinner.stop("Token exchange failed ✗") } return tokenResult } /** * Generate authorization URL with localhost callback */ private generateAuthUrlWithCallback(instance: string, clientId: string, redirectUri: string): string { const baseUrl = instance.startsWith("http") ? instance : `https://${instance}` const params = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: redirectUri, state: this.stateParameter!, code_challenge: this.codeChallenge!, code_challenge_method: "S256", }) return `${baseUrl}/oauth_auth.do?${params.toString()}` } /** * Auto-open browser */ private openBrowser(url: string): void { try { const { spawn } = require("child_process") let browserProcess: any if (process.platform === "darwin") { browserProcess = spawn("open", [url], { detached: true, stdio: "ignore" }) } else if (process.platform === "win32") { browserProcess = spawn("cmd", ["/c", "start", url], { detached: true, stdio: "ignore" }) } else if (process.platform === "linux") { // Try multiple Linux browser openers const openers = ["xdg-open", "gnome-open", "kde-open"] for (const opener of openers) { try { browserProcess = spawn(opener, [url], { detached: true, stdio: "ignore" }) break } catch (e) { continue } } } if (browserProcess && browserProcess.unref) { browserProcess.unref() } } catch (err) { prompts.log.warn("Could not auto-open browser. Please manually open the URL above.") } } /** * Start localhost callback server with Codespaces URL pasting fallback */ private async startCallbackServer( port: number, instance: string, clientId: string, clientSecret: string, redirectUri: string, ): Promise { const inCodespace = this.isCodespace() return new Promise((resolve) => { const { createServer } = require("http") const { URL } = require("url") let resolved = false const server = createServer(async (req: any, res: any) => { if (resolved) return try { const url = new URL(req.url!, `http://localhost:${port}`) if (url.pathname === "/callback") { const code = url.searchParams.get("code") const error = url.searchParams.get("error") const state = url.searchParams.get("state") // Validate state parameter if (state !== this.stateParameter) { res.writeHead(400, { "Content-Type": "text/html" }) res.end(OAuthTemplates.securityError) server.close() resolved = true resolve({ success: false, error: "Invalid state parameter" }) return } if (error) { res.writeHead(400, { "Content-Type": "text/html" }) res.end(OAuthTemplates.error(`OAuth error: ${error}`)) server.close() resolved = true resolve({ success: false, error: `OAuth error: ${error}` }) return } if (!code) { res.writeHead(400, { "Content-Type": "text/html" }) res.end(OAuthTemplates.missingCode) server.close() resolved = true resolve({ success: false, error: "No authorization code received" }) return } // Exchange code for tokens const spinner = prompts.spinner() spinner.start("Exchanging authorization code for tokens") const tokenResult = await this.exchangeCodeForTokens( instance, clientId, clientSecret, code, redirectUri ) if (tokenResult.success) { res.writeHead(200, { "Content-Type": "text/html" }) res.end(OAuthTemplates.success) spinner.stop("Authentication successful") server.close() resolved = true resolve(tokenResult) } else { res.writeHead(500, { "Content-Type": "text/html" }) res.end(OAuthTemplates.tokenExchangeFailed(tokenResult.error || "Unknown error")) spinner.stop("Token exchange failed") server.close() resolved = true resolve(tokenResult) } } else { res.writeHead(404, { "Content-Type": "text/plain" }) res.end("Not Found") } } catch (error) { res.writeHead(500, { "Content-Type": "text/plain" }) res.end("Internal Server Error") server.close() resolved = true resolve({ success: false, error: error instanceof Error ? error.message : String(error), }) } }) server.listen(port, async () => { prompts.log.info(`Callback server started on http://localhost:${port}/callback`) prompts.log.info("Waiting for OAuth callback...") // In Codespaces and remote environments, the callback won't work automatically if (inCodespace) { prompts.log.message("") prompts.log.info("🌐 GitHub Codespaces Detected") prompts.log.message("") prompts.log.info("After clicking 'Approve' in ServiceNow:") prompts.log.message(" 1. Your browser will show a 'Can't reach this page' or 404 error") prompts.log.message(" 2. Copy the FULL URL from your browser address bar") prompts.log.message(" (it starts with: http://localhost:3005/callback?code=...)") prompts.log.message(" 3. Paste it below in a few seconds") prompts.log.message("") // Wait a bit to see if automatic callback works (unlikely in Codespaces) await new Promise(resolve => setTimeout(resolve, 3000)) if (!resolved) { try { const callbackUrl = await prompts.text({ message: "Paste the callback URL here (or press Enter to keep waiting):", placeholder: "http://localhost:3005/callback?code=...&state=...", validate: (value) => { if (!value || value.trim() === "") { return undefined // Allow empty to continue waiting } if (!value.includes("/callback")) { return "URL must contain '/callback'" } return undefined } }) if (!prompts.isCancel(callbackUrl) && callbackUrl && callbackUrl.trim() !== "") { // Parse the pasted URL try { const parsedUrl = new URL(callbackUrl.replace('localhost:3005', 'localhost:' + port)) const code = parsedUrl.searchParams.get("code") const error = parsedUrl.searchParams.get("error") const state = parsedUrl.searchParams.get("state") // Validate state parameter if (state !== this.stateParameter) { server.close() resolved = true resolve({ success: false, error: "Invalid state parameter - possible CSRF attack" }) return } if (error) { server.close() resolved = true resolve({ success: false, error: `OAuth error: ${error}` }) return } if (!code) { prompts.log.warn("No authorization code found in URL, continuing to wait for callback...") } else { // Exchange code for tokens const spinner = prompts.spinner() spinner.start("Exchanging authorization code for tokens") const tokenResult = await this.exchangeCodeForTokens( instance, clientId, clientSecret, code, redirectUri ) if (tokenResult.success) { spinner.stop("Authentication successful") server.close() resolved = true resolve(tokenResult) } else { spinner.stop("Token exchange failed") server.close() resolved = true resolve(tokenResult) } } } catch (urlError) { prompts.log.warn("Invalid URL format, continuing to wait for callback...") } } } catch (promptError) { // If prompt fails, just continue waiting prompts.log.warn("Continuing to wait for automatic callback...") } } } }) // Timeout after 5 minutes setTimeout(() => { if (!resolved) { server.close() resolved = true resolve({ success: false, error: "Authentication timeout (5 minutes)", }) } }, 300000) }) } /** * Refresh access token using refresh token */ async refreshAccessToken( instance: string, clientId: string, clientSecret: string, refreshToken: string, ): Promise { if (!this.checkTokenRequestRateLimit()) { return { success: false, error: "Rate limit exceeded", } } const baseUrl = instance.startsWith("http") ? instance : `https://${instance}` const tokenUrl = `${baseUrl}/oauth_token.do` const params = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId, client_secret: clientSecret, }) try { const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: params.toString(), }) if (!response.ok) { return { success: false, error: `Token refresh failed: ${response.status}`, } } const data = await response.json() if (data.error) { return { success: false, error: `OAuth error: ${data.error}`, } } // Update stored tokens await Auth.set("servicenow", { type: "servicenow-oauth", instance, clientId, clientSecret, accessToken: data.access_token, refreshToken: data.refresh_token || refreshToken, expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, }) return { success: true, accessToken: data.access_token, refreshToken: data.refresh_token || refreshToken, expiresIn: data.expires_in, } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), } } } }