oauth: add ServiceNow + make per-instance OAuth providers registry-driven (#9449)

* oauth: add ServiceNow provider; make per-instance OAuth registry-driven

ServiceNow's OAuth endpoints are per-instance
(https://<instance>.service-now.com/oauth_auth.do + /oauth_token.do), like
Snowflake's. Rather than add another bespoke special-case, generalize:
a registry entry may carry a `connect_config_template` (label/placeholder/
help_url + {instance}-templated auth_url/token_url + req_body_auth +
optional extra_params_key/strip_suffix). The instance-settings UI renders
one generic instance-name input for any such provider and substitutes
{instance} to build the per-client connect_config — a new per-instance
provider needs only a JSON entry, no frontend code.

- oauth_connect.json: servicenow + snowflake_oauth now carry a
  connect_config_template (snowflake keeps its account_identifier
  extra_params key for backward compatibility).
- windmill-oauth: add the ConnectConfigTemplate struct (frontend-only
  metadata; the backend's existing connect_config override resolves the
  concrete URLs generically — no other backend change).
- AuthSettings/InstanceSettings: replace the Snowflake + ServiceNow
  special-cases with one registry-driven path (instanceInputs map,
  setupTemplatedOauthUrls, loadInstanceInputs); per-instance providers are
  derived from the registry for the builtins list + dropdown.

Pairs with windmill-integrations#139 (ServiceNow hub integration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: point ee-repo-ref at servicenow-oauth EE branch (revert at merge)

Temporary CI pointer so check_ee_full / cargo_test build against the EE
slack-literal fix (windmill-ee-private#602). Revert to a pinned SHA once
that EE PR is merged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-06-04 20:58:47 +02:00
committed by GitHub
parent eb55356018
commit 93a74f229a
6 changed files with 197 additions and 77 deletions
+1 -1
View File
@@ -1 +1 @@
3742e0659c5e97aab03b9efeea14cd94a3ac658a
servicenow-oauth
+32 -16
View File
@@ -154,29 +154,34 @@
"zoho": {
"auth_url": "https://accounts.zoho.com/oauth/v2/auth",
"token_url": "https://accounts.zoho.com/oauth/v2/token",
"scopes": [
"ZohoAssist.sessionapi.ALL"
],
"scopes": ["ZohoAssist.sessionapi.ALL"],
"extra_params": {
"access_type": "offline"
}
},
"snowflake_oauth": {},
"snowflake_oauth": {
"connect_config_template": {
"display_name": "Snowflake",
"label": "Snowflake Account Identifier",
"placeholder": "<orgname>-<account_name>",
"help_url": "https://docs.snowflake.com/en/user-guide/admin-account-identifier#using-an-account-name-as-an-identifier",
"auth_url": "https://{instance}.snowflakecomputing.com/oauth/authorize",
"token_url": "https://{instance}.snowflakecomputing.com/oauth/token-request",
"req_body_auth": false,
"extra_params_key": "account_identifier",
"resource_mapping": { "account_identifier": "{instance}" }
}
},
"apify": {
"auth_url": "https://console.apify.com/authorize/oauth",
"token_url": "https://console-backend.apify.com/oauth/apps/token",
"scopes": [
"profile",
"full_api_access"
],
"scopes": ["profile", "full_api_access"],
"extra_params": {}
},
"docusign": {
"auth_url": "https://account.docusign.com/oauth/auth",
"token_url": "https://account.docusign.com/oauth/token",
"scopes": [
"signature"
],
"scopes": ["signature"],
"sandbox": {
"auth_url": "https://account-d.docusign.com/oauth/auth",
"token_url": "https://account-d.docusign.com/oauth/token"
@@ -185,14 +190,25 @@
"salesforce": {
"auth_url": "https://login.salesforce.com/services/oauth2/authorize",
"token_url": "https://login.salesforce.com/services/oauth2/token",
"scopes": [
"api",
"refresh_token",
"offline_access"
],
"scopes": ["api", "refresh_token", "offline_access"],
"sandbox": {
"auth_url": "https://test.salesforce.com/services/oauth2/authorize",
"token_url": "https://test.salesforce.com/services/oauth2/token"
}
},
"servicenow": {
"connect_config_template": {
"display_name": "ServiceNow",
"label": "ServiceNow Instance",
"placeholder": "<instance> (e.g. dev12345)",
"help_url": "https://www.servicenow.com/docs/bundle/zurich-platform-security/page/administer/security/concept/c_OAuthApplications.html",
"auth_url": "https://{instance}.service-now.com/oauth_auth.do",
"token_url": "https://{instance}.service-now.com/oauth_token.do",
"req_body_auth": true,
"strip_suffix": ".service-now.com",
"resource_mapping": {
"instance_url": "https://{instance}.service-now.com"
}
}
}
}
+44
View File
@@ -92,6 +92,12 @@ pub struct OAuthConfig {
/// entry, `build_oauth_clients` registers a second client under that key.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<OAuthSandboxOverride>,
/// Frontend-only metadata for per-instance OAuth providers (Snowflake,
/// ServiceNow, …) whose authorize/token URLs are derived from an
/// admin-entered instance name. Ignored by the backend, which only ever
/// sees the resulting concrete `connect_config`.
#[serde(skip_serializing_if = "Option::is_none")]
pub connect_config_template: Option<ConnectConfigTemplate>,
}
/// URL overrides for an OAuth provider's sandbox environment. Inherits
@@ -106,6 +112,43 @@ pub struct OAuthSandboxOverride {
pub userinfo_url: Option<String>,
}
/// Frontend metadata for a per-instance OAuth provider. The instance-settings
/// UI renders one generic instance-name input and substitutes `{instance}` into
/// `auth_url`/`token_url` to build the per-client `connect_config`. Adding a new
/// per-instance provider needs only a registry entry carrying this template —
/// no frontend code change. The backend never reads it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ConnectConfigTemplate {
/// Properly-cased provider name for the settings dropdown (e.g. "ServiceNow");
/// the UI falls back to a capitalized registry key when absent.
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub label: String,
pub placeholder: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_url: Option<String>,
pub auth_url: String,
pub token_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub req_body_auth: Option<bool>,
/// Key under `connect_config.extra_params` where the instance name is
/// stored (defaults to `instance`). Snowflake uses `account_identifier` for
/// backward compatibility with previously-saved configs.
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_params_key: Option<String>,
/// Optional host suffix stripped from the input before substitution (e.g.
/// `.service-now.com`), so the admin can paste a full host or a bare name.
#[serde(skip_serializing_if = "Option::is_none")]
pub strip_suffix: Option<String>,
/// Maps OAuth-connected resource arg fields to value templates substituting
/// `{instance}` (e.g. ServiceNow's `instance_url` ->
/// `https://{instance}.service-now.com`). Applied by the resource-connect
/// flow so the created resource carries the instance-specific fields the
/// scripts need (ServiceNow's token response omits the host).
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_mapping: Option<HashMap<String, String>>,
}
impl OAuthConfig {
/// Returns a copy of this config with sandbox URL overrides applied and
/// the nested `sandbox` field cleared. Returns `None` if no overrides are
@@ -817,6 +860,7 @@ mod tests {
token_url: Some("https://account-d.example.com/oauth/token".to_string()),
userinfo_url: None,
}),
connect_config_template: None,
}
}
@@ -13,6 +13,7 @@
type ResourceType
} from '$lib/gen'
import { emptyString, truncateRev, urlize } from '$lib/utils'
import oauthConnectRegistry from '$oauth_connect_registry'
import { createEventDispatcher, onDestroy } from 'svelte'
import Path from './Path.svelte'
import { Button, Skeleton } from './common'
@@ -489,12 +490,25 @@
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
}
if (resourceType == 'snowflake_oauth') {
const account_identifier = extra_params.find(([key, _]) => key == 'account_identifier')
if (account_identifier) {
args['account_identifier'] = account_identifier[1]
// Per-instance OAuth providers (Snowflake, ServiceNow, …): copy the
// admin-configured instance from the OAuth client's extra_params into the
// resource args, per the registry template's resource_mapping (e.g.
// ServiceNow -> instance_url: https://{instance}.service-now.com). Generic
// so a new per-instance provider needs only a registry entry.
const connectTemplate = (oauthConnectRegistry as Record<string, any>)[resourceType]
?.connect_config_template
if (connectTemplate?.resource_mapping) {
const instanceKey = connectTemplate.extra_params_key ?? 'instance'
const found = extra_params.find(([key, _]) => key === instanceKey)
if (found) {
for (const [argField, valueTemplate] of Object.entries(
connectTemplate.resource_mapping as Record<string, string>
)) {
args[argField] = valueTemplate.replaceAll('{instance}', found[1])
}
}
} else if (resourceType === 'quickbooks' && responseExtra['realmId']) {
}
if (resourceType === 'quickbooks' && responseExtra['realmId']) {
args['realmId'] = responseExtra['realmId']
}
+44 -16
View File
@@ -29,7 +29,10 @@
import oauthConnectRegistry from '$oauth_connect_registry'
interface Props {
snowflakeAccountIdentifier?: string
// Per-instance OAuth providers (Snowflake, ServiceNow, …): instance name
// keyed by provider, used to build their per-instance connect_config URLs.
// Required (and always bound by InstanceSettings) so it is never undefined.
instanceInputs: Record<string, string>
oauths?: Record<string, any>
requirePreexistingUserForOauth?: boolean
baseUrl?: string
@@ -39,7 +42,7 @@
}
let {
snowflakeAccountIdentifier = $bindable(),
instanceInputs = $bindable(),
oauths = $bindable(),
requirePreexistingUserForOauth = $bindable(),
baseUrl,
@@ -49,9 +52,6 @@
}: Props = $props()
$effect(() => {
if (snowflakeAccountIdentifier == undefined) {
snowflakeAccountIdentifier = ''
}
if (oauths == undefined) {
oauths = {}
}
@@ -79,7 +79,6 @@
'visma',
'sage_intacct',
'spotify',
'snowflake_oauth',
'teams',
'zoho',
'xero',
@@ -96,9 +95,20 @@
const windmillBuiltinsWithSandbox = Object.entries(oauthConnectRegistry)
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'sandbox' in cfg)
.map(([name]) => name)
// Per-instance providers (Snowflake, ServiceNow, …): registry entries that
// carry a `connect_config_template`. Derived from the registry so adding a
// new one needs only a JSON entry — they get a builtin tile + the generic
// instance-name input below, with no frontend change.
const connectConfigTemplates: Record<string, any> = Object.fromEntries(
Object.entries(oauthConnectRegistry)
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg)
.map(([name, cfg]) => [name, (cfg as any).connect_config_template])
)
const windmillBuiltinsTemplated = Object.keys(connectConfigTemplates)
const windmillBuiltins = [
...windmillBuiltinsBase,
...windmillBuiltinsWithSandbox.map((n) => `${n}_sandbox`)
...windmillBuiltinsWithSandbox.map((n) => `${n}_sandbox`),
...windmillBuiltinsTemplated
]
let showCustomOAuthForm = $state(false)
@@ -238,6 +248,20 @@
}
})
// Add per-instance providers (registry entries with a connect_config_template)
windmillBuiltinsTemplated.forEach((name) => {
if (!oauths || !oauths[name]) {
const icon = getOAuthProviderIcon(name)
items.push({
// Prefer the template's display_name (properly cased, e.g. "ServiceNow")
// over capitalize(name) which yields "Servicenow"/"Snowflake_oauth".
displayName: connectConfigTemplates[name]?.display_name ?? capitalize(name),
action: () => createOAuthClient(name),
icon: icon
})
}
})
// Add custom option
items.push({
displayName: `Custom OAuth client ${!$enterpriseLicense ? '(requires ee)' : ''}`,
@@ -486,19 +510,23 @@
{:else if !windmillBuiltins.includes(k) && k != 'slack'}
<CustomOauth bind:connect_config={oauths[k]['connect_config']} />
{/if}
{#if k == 'snowflake_oauth'}
{#if connectConfigTemplates[k]}
{@const tmpl = connectConfigTemplates[k]}
<label class="block pb-2">
<span class="text-primary font-semibold text-xs flex gap-2 items-center"
><a
href="https://docs.snowflake.com/en/user-guide/admin-account-identifier#using-an-account-name-as-an-identifier"
target="_blank">Snowflake Account Identifier</a
><ExternalLink size={12} /></span
>
<span class="text-primary font-semibold text-xs flex gap-2 items-center">
{#if tmpl.help_url}
<a href={tmpl.help_url} target="_blank">{tmpl.label}</a><ExternalLink
size={12}
/>
{:else}
{tmpl.label}
{/if}
</span>
<input
type="text"
placeholder="<orgname>-<account_name>"
placeholder={tmpl.placeholder}
required={true}
bind:value={snowflakeAccountIdentifier}
bind:value={instanceInputs[k]}
/>
</label>
{/if}
@@ -13,6 +13,7 @@
import { createEventDispatcher } from 'svelte'
import { setLicense } from '$lib/enterpriseUtils'
import AuthSettings from './AuthSettings.svelte'
import oauthConnectRegistry from '$oauth_connect_registry'
import InstanceSetting from './InstanceSetting.svelte'
import { writable, type Writable } from 'svelte/store'
import { ExternalLink, Loader2 } from 'lucide-svelte'
@@ -54,7 +55,9 @@
let initialValues: Record<string, any> = $state({})
let baseUrlIsFallback = $state(false)
let snowflakeAccountIdentifier = $state('')
// Per-instance OAuth providers (Snowflake, ServiceNow, …): instance name
// keyed by provider, used to build their per-instance connect_config URLs.
let instanceInputs: Record<string, string> = $state({})
let version: string = $state('')
let loading = $state(true)
@@ -147,12 +150,8 @@
$values = nvalues
loading = false
// populate snowflake account identifier from db
const account_identifier =
oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier
if (account_identifier) {
snowflakeAccountIdentifier = account_identifier
}
// populate per-instance OAuth provider inputs (snowflake, servicenow, …) from db
loadInstanceInputs(oauths)
}
export async function saveSettings() {
@@ -162,13 +161,7 @@
}
}
if (
oauths?.snowflake_oauth &&
oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier !==
snowflakeAccountIdentifier
) {
setupSnowflakeUrls()
}
setupTemplatedOauthUrls()
// Remove empty or invalid entries for critical error channels
$values.critical_error_channels = $values.critical_error_channels.filter((entry: any) => {
@@ -283,19 +276,54 @@
}
}
function setupSnowflakeUrls() {
// strip all whitespaces from account identifier
snowflakeAccountIdentifier = snowflakeAccountIdentifier.replace(/\s/g, '')
// Per-instance OAuth providers (Snowflake, ServiceNow, …) keyed by name ->
// their registry connect_config_template. Adding a new one needs only a
// registry entry — no code here.
const connectConfigTemplates: Record<string, any> = Object.fromEntries(
Object.entries(oauthConnectRegistry)
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg)
.map(([name, cfg]) => [name, (cfg as any).connect_config_template])
)
const connect_config = {
scopes: [],
auth_url: `https://${snowflakeAccountIdentifier}.snowflakecomputing.com/oauth/authorize`,
token_url: `https://${snowflakeAccountIdentifier}.snowflakecomputing.com/oauth/token-request`,
req_body_auth: false,
extra_params: { account_identifier: snowflakeAccountIdentifier },
extra_params_callback: {}
function normalizeInstanceInput(tmpl: any, raw: string): string {
let v = (raw ?? '').replace(/\s/g, '')
if (tmpl.strip_suffix) {
// accept a full host/URL or a bare name -> reduce to the bare instance
v = v.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
if (v.endsWith(tmpl.strip_suffix)) {
v = v.slice(0, -tmpl.strip_suffix.length)
}
}
return v
}
// Build each per-instance provider's connect_config from the admin-entered
// instance name + its registry template (substituting {instance} into the
// URLs). Replaces the old per-provider setup functions.
function setupTemplatedOauthUrls() {
for (const [name, tmpl] of Object.entries(connectConfigTemplates)) {
if (!oauths?.[name]) continue
const key = tmpl.extra_params_key ?? 'instance'
const v = normalizeInstanceInput(tmpl, instanceInputs[name] ?? '')
instanceInputs[name] = v
if (oauths[name].connect_config?.extra_params?.[key] === v) continue
oauths[name].connect_config = {
scopes: [],
auth_url: tmpl.auth_url.replaceAll('{instance}', v),
token_url: tmpl.token_url.replaceAll('{instance}', v),
req_body_auth: tmpl.req_body_auth ?? false,
extra_params: { [key]: v },
extra_params_callback: {}
}
}
}
// Recover the instance-name inputs from a saved oauths config (for load/discard).
function loadInstanceInputs(savedOauths: Record<string, any>) {
for (const [name, tmpl] of Object.entries(connectConfigTemplates)) {
const key = tmpl.extra_params_key ?? 'instance'
instanceInputs[name] = savedOauths?.[name]?.connect_config?.extra_params?.[key] ?? ''
}
oauths['snowflake_oauth'].connect_config = connect_config
}
let sendingStats = $state(false)
@@ -510,9 +538,7 @@
if (category === 'Auth/OAuth/SAML') {
oauths = JSON.parse(JSON.stringify(initialOauths))
requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth
const account_identifier =
initialOauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier
snowflakeAccountIdentifier = account_identifier ?? ''
loadInstanceInputs(initialOauths)
} else if (category === 'Registries') {
const v = initialValues['workspace_registries']
$values['workspace_registries'] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
@@ -524,9 +550,7 @@
$values = JSON.parse(JSON.stringify(initialValues))
oauths = JSON.parse(JSON.stringify(initialOauths))
requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth
const account_identifier =
initialOauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier
snowflakeAccountIdentifier = account_identifier ?? ''
loadInstanceInputs(initialOauths)
if (yamlMode) {
syncFormToYaml()
}
@@ -535,13 +559,7 @@
export async function saveCategorySettings(category: string) {
// Category-specific pre-processing
if (category === 'Auth/OAuth/SAML') {
if (
oauths?.snowflake_oauth &&
oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier !==
snowflakeAccountIdentifier
) {
setupSnowflakeUrls()
}
setupTemplatedOauthUrls()
}
if (category === 'Alerts' && $values?.critical_error_channels) {
@@ -1116,7 +1134,7 @@
{:else if category == 'Auth/OAuth/SAML'}
<AuthSettings
bind:oauths
bind:snowflakeAccountIdentifier
bind:instanceInputs
bind:requirePreexistingUserForOauth
baseUrl={$values?.base_url}
bind:tab={authSubTab}