feat(oauth): support per-provider sandbox URLs (#9358)

* feat(oauth): support per-provider sandbox URLs in registry + instance settings

* fix(oauth): polish sandbox review nits (cc lookup, header label, ee ref)

* refactor(oauth): drop dead build_oauth_clients duplicate in windmill-oauth

* refactor(oauth): derive sandbox-capable provider list from registry

* chore(docker): copy oauth_connect.json into frontend build stage

* test(oauth): cover sandbox helpers (as_sandbox, canonical_name, resolve)

* chore: update ee-repo-ref to 9297d8f790346e6a6ad540c7bca1a67f91ec11a2

This commit updates the EE repository reference after PR #595 was merged in windmill-ee-private.

Previous ee-repo-ref: 3ab3eca9ac15ebab6db991e7964bc5e48ce21f42

New ee-repo-ref: 9297d8f790346e6a6ad540c7bca1a67f91ec11a2

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-05-28 22:33:44 +00:00
committed by GitHub
co-authored by windmill-internal-app[bot]
parent 889101b7f0
commit 2bf11dcb15
10 changed files with 282 additions and 225 deletions
@@ -74,6 +74,16 @@
let value: string = $state('')
let valueToken: TokenResponse | undefined = undefined
let connects: string[] | undefined = $state(undefined)
const SANDBOX_SUFFIX = '_sandbox'
function stripSandboxSuffix(name: string): string {
return name.endsWith(SANDBOX_SUFFIX) ? name.slice(0, -SANDBOX_SUFFIX.length) : name
}
// `resourceType` is always the canonical type (e.g. `docusign`) so resource
// rows are uniform. `connectClient` carries the suffixed OAuth client name
// (e.g. `docusign_sandbox`) used to look up credentials/URLs at runtime
// and stored on `account.client` so token refresh hits the right endpoint.
let connectClient: string = $state('')
let connectsManual: { key: string; img?: string; instructions: string[] }[] | undefined =
$state(undefined)
let args: any = $state({})
@@ -152,7 +162,9 @@
description = ''
labels = undefined
wsSpecific = false
resourceType = rt ?? ''
const rawRt = rt ?? ''
connectClient = rawRt
resourceType = stripSandboxSuffix(rawRt)
valueToken = undefined
// Reset client credentials state
@@ -163,7 +175,7 @@
tokenUrl = ''
await loadConnects()
manual = !connects?.includes(resourceType)
manual = !connects?.includes(connectClient)
if (manual && express) {
dispatch('error', 'Express OAuth setup is not available for non OAuth resource types')
return
@@ -312,7 +324,8 @@
sendUserToast(data.error, true)
step = 2
} else if (data.type === 'success') {
resourceType = data.resource_type
connectClient = data.resource_type
resourceType = stripSandboxSuffix(connectClient)
value = data.res.access_token!
valueToken = data.res
responseExtra = data.extra ?? {}
@@ -325,7 +338,7 @@
}
async function getScopesAndParams() {
const connect = await OauthService.getOauthConnect({ client: resourceType })
const connect = await OauthService.getOauthConnect({ client: connectClient })
scopes = connect.scopes ?? []
extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][]
@@ -401,7 +414,7 @@
}
const tokenResponse = await OauthService.connectClientCredentials({
client: resourceType,
client: connectClient,
requestBody
})
@@ -428,7 +441,7 @@
* Requires user interaction and consent
* Opens popup for user to authenticate with OAuth provider
*/
const url = new URL(`/api/oauth/connect/${resourceType}`, window.location.origin)
const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin)
url.searchParams.append('scopes', scopes.join('+'))
if (extra_params.length > 0) {
extra_params.forEach(([key, value]) => url.searchParams.append(key, value))
@@ -490,7 +503,7 @@
const accountData: any = {
refresh_token: valueToken.refresh_token ?? '',
expires_in: valueToken.expires_in,
client: resourceType,
client: connectClient,
grant_type: valueToken.grant_type || 'authorization_code'
}
@@ -602,6 +615,7 @@
)
step = 1
resourceType = ''
connectClient = ''
}
}
@@ -660,10 +674,11 @@
<Button
unifiedSize="md"
variant="default"
selected={key === resourceType}
selected={key === connectClient}
on:click={() => {
manual = false
resourceType = key
connectClient = key
resourceType = stripSandboxSuffix(key)
next()
}}
>
@@ -703,6 +718,7 @@
selected={key === resourceType}
on:click={() => {
manual = true
connectClient = key
resourceType = key
next()
}}
@@ -725,6 +741,7 @@
btnClasses={key === resourceType ? '!border-2' : 'm-[1px]'}
on:click={() => {
manual = true
connectClient = key
resourceType = key
next()
}}
@@ -26,6 +26,7 @@
import { tick } from 'svelte'
import { Popover } from './meltComponents'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
import oauthConnectRegistry from '$oauth_connect_registry'
interface Props {
snowflakeAccountIdentifier?: string
@@ -59,7 +60,7 @@
}
})
const windmillBuiltins = [
const windmillBuiltinsBase = [
'azure_oauth',
'github',
'gitlab',
@@ -82,7 +83,21 @@
'teams',
'zoho',
'xero',
'apify'
'apify',
'docusign'
]
// Providers whose registry entry (`backend/oauth_connect.json`) carries a
// `sandbox` URL block. Each one gets a sibling `<name>_sandbox` dropdown
// entry and is treated as a builtin so we don't render the custom-URL form
// — the URLs come from the registry sandbox block. Derived at build time
// from the registry so adding a sandbox to a provider needs no frontend
// change.
const windmillBuiltinsWithSandbox = Object.entries(oauthConnectRegistry)
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'sandbox' in cfg)
.map(([name]) => name)
const windmillBuiltins = [
...windmillBuiltinsBase,
...windmillBuiltinsWithSandbox.map((n) => `${n}_sandbox`)
]
let showCustomOAuthForm = $state(false)
@@ -175,26 +190,29 @@
}
function getOAuthProviderIcon(name: string) {
// Sandbox variants share the parent provider's icon.
const lookup = name.endsWith('_sandbox') ? name.slice(0, -'_sandbox'.length) : name
// Handle special cases
if (name === 'teams') {
if (lookup === 'teams') {
return APP_TO_ICON_COMPONENT.ms_teams_webhook
}
if (name === 'snowflake_oauth') {
if (lookup === 'snowflake_oauth') {
return APP_TO_ICON_COMPONENT.snowflake
}
if (name === 'azure_oauth') {
if (lookup === 'azure_oauth') {
return APP_TO_ICON_COMPONENT.azure
}
// Try direct mapping, fallback to Circle icon if not found
return APP_TO_ICON_COMPONENT[name as keyof typeof APP_TO_ICON_COMPONENT] || Circle
return APP_TO_ICON_COMPONENT[lookup as keyof typeof APP_TO_ICON_COMPONENT] || Circle
}
function generateOAuthDropdownItems(): Item[] {
const items: Item[] = []
// Add built-in providers that are not already configured
windmillBuiltins.forEach((name) => {
windmillBuiltinsBase.forEach((name) => {
// Only show providers that are not already in the oauths object
if (!oauths || !oauths[name]) {
const icon = getOAuthProviderIcon(name)
@@ -206,6 +224,19 @@
}
})
// Add sandbox variants for providers that have sandbox URLs in the registry
windmillBuiltinsWithSandbox.forEach((name) => {
const sandboxKey = `${name}_sandbox`
if (!oauths || !oauths[sandboxKey]) {
const icon = getOAuthProviderIcon(name)
items.push({
displayName: `${capitalize(name)} (sandbox)`,
action: () => createOAuthClient(sandboxKey),
icon: icon
})
}
})
// Add custom option
items.push({
displayName: `Custom OAuth client ${!$enterpriseLicense ? '(requires ee)' : ''}`,
@@ -370,11 +401,14 @@
{#if oauths[k] && !(oauths[k] && 'login_config' in oauths[k])}
{#if !['slack', 'teams'].includes(k) && oauths[k]}
{@const IconComponent = getOAuthProviderIcon(k) as any}
{@const headerLabel = k.endsWith('_sandbox')
? `${k.slice(0, -'_sandbox'.length)} (sandbox)`
: k}
<div class="flex flex-col gap-2 pb-6">
<div class="flex flex-row items-center gap-2">
<IconComponent size={24} width="24" height="24" class="shrink-0" />
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-xs font-semibold text-emphasis">{k}</label>
<label class="text-xs font-semibold text-emphasis">{headerLabel}</label>
<Button
variant="subtle"
destructive