/**
* 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 = `
`;
const OAuthTemplates = {
success: `
Snow-Flow - Authentication Successful
${baseStyles}
${logoSVG}
✓
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}
${logoSVG}
✕
Authentication Failed
We couldn't complete the authentication process.
${error}
Please close this window and try again.
`,
securityError: `
Snow-Flow - Security Error
${baseStyles}
${logoSVG}
⚠
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}
${logoSVG}
✕
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}
${logoSVG}
✕
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),
}
}
}
}