From 93a74f229a91c82a8e474f7f3665eedfa103af68 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 4 Jun 2026 20:58:47 +0200 Subject: [PATCH] oauth: add ServiceNow + make per-instance OAuth providers registry-driven (#9449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * oauth: add ServiceNow provider; make per-instance OAuth registry-driven ServiceNow's OAuth endpoints are per-instance (https://.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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/ee-repo-ref.txt | 2 +- backend/oauth_connect.json | 48 ++++++---- backend/windmill-oauth/src/lib.rs | 44 +++++++++ .../src/lib/components/AppConnectInner.svelte | 24 ++++- .../src/lib/components/AuthSettings.svelte | 60 ++++++++---- .../lib/components/InstanceSettings.svelte | 96 +++++++++++-------- 6 files changed, 197 insertions(+), 77 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4e1f516570..15e5b90000 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -3742e0659c5e97aab03b9efeea14cd94a3ac658a +servicenow-oauth diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 36f565fc2b..01becd80f3 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -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": "-", + "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": " (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" + } + } } } diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 874a859200..59b4184cea 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -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, + /// 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, } /// URL overrides for an OAuth provider's sandbox environment. Inherits @@ -106,6 +112,43 @@ pub struct OAuthSandboxOverride { pub userinfo_url: Option, } +/// 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, + pub label: String, + pub placeholder: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub help_url: Option, + pub auth_url: String, + pub token_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub req_body_auth: Option, + /// 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, + /// 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, + /// 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>, +} + 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, } } diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 7b6ed37ecc..4872629cc1 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -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)[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 + )) { + args[argField] = valueTemplate.replaceAll('{instance}', found[1]) + } } - } else if (resourceType === 'quickbooks' && responseExtra['realmId']) { + } + if (resourceType === 'quickbooks' && responseExtra['realmId']) { args['realmId'] = responseExtra['realmId'] } diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index 734c6a1fe3..31706433f6 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -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 oauths?: Record 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 = 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'} {/if} - {#if k == 'snowflake_oauth'} + {#if connectConfigTemplates[k]} + {@const tmpl = connectConfigTemplates[k]} {/if} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 7342f1d3fa..9581357e84 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -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 = $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 = $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 = 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) { + 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'}