feat: zero-setup oauth client credentials for registry providers (#9559)

* feat: zero-setup oauth client credentials for registry-declared providers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: support client-credentials-only custom oauth providers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add coupa client credentials provider to oauth registry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clarify oauth resource connect auth-method selection

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: support shared instance-level oauth client credentials

Admins can designate an instance OAuth entry's credentials as client
credentials; the connect dialog then runs the exchange server-side with
them instead of asking each user for their own. Replaces the per-provider
"Support Client Credentials Flow" toggle with a grant-type selector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update ee-repo-ref to be9f23b2c06b8b6ee0cd3e4d9f16bcd9e90129fb

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

Previous ee-repo-ref: 05643cbbc8c1bebf3509c691c5811b4057d96485

New ee-repo-ref: be9f23b2c06b8b6ee0cd3e4d9f16bcd9e90129fb

Automated by sync-ee-ref workflow.

* feat: allow both grant types on an instance oauth entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: bring-your-own oauth credentials from the others section

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: segmented oauth grant-type selector, always show grant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: enable client credentials for 5 more oauth providers

Verified against official docs: bitbucket, linkedin, spotify, xero and
zoho support the standard client_credentials grant with a plain
client_id + client_secret, compatible with Windmill's token exchange.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: hide create-manually link on the managed oauth connect path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: enable client credentials for salesforce and servicenow

Salesforce CC requires the org's My Domain token endpoint (login.salesforce.com
is unsupported for that grant), so add an optional cc_token_url registry field
that the connect form prefills for the client-credentials path instead of the
shared token_url. ServiceNow uses the same instance host for both grants, so it
only needs its token URL and req_body_auth surfaced at the top level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add instance-level client-credentials token url override

Some providers use a per-org/instance-specific token endpoint for the
client-credentials grant that differs from the authorization-code URL.
Add an optional cc_token_url on the instance OAuth entry, surfaced in
instance settings (prefilled from the registry template) when client
credentials is selected, and used for the CC exchange and refresh while
auth-code keeps its own token URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: remove redundant grant-type tags from oauth auth cards

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: extract reusable RadioCard component for the oauth auth chooser

A token-based selectable card (label, description, selected, onSelect,
optional icon) replacing the inline cards in the connect dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: hide sign-in option on the bring-your-own oauth path

Picking a provider from "Others" means bring your own credentials, so
the auth-code "Sign in" card (which uses the instance client) no longer
shows there — it goes straight to the client-credentials form. The
two-flow chooser stays on the instance-configured path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: restrict client-credentials token url to caller-supplied creds

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

* fix: resolve client-credentials id and secret all-or-nothing

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

* feat: host-pin client-credentials token url via instance-name input

For registry providers whose CC token URL is instance-templated (Coupa,
Salesforce My Domain, ServiceNow), the connect dialog and instance settings
collect an instance name and the backend substitutes it into the fixed-host
template, validating it as a hostname label. A free-form token URL is no longer
accepted for these providers, so the exchange host cannot be redirected.

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

* feat: client-credentials token url always comes from the registry

Bring-your-own CC is registry-only: the token URL is resolved server-side from
the built-in registry (host-pinned via an instance name for templated providers,
the fixed registry URL otherwise) and rejected for custom resource types. The
caller-supplied token URL field is removed from the connect dialog and the API.
Adds unit tests for the resolver.

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

* fix: address CC review - sandbox CC config and instance-templated providers

Resolve `_sandbox` provider keys to the parent registry entry in the instance
settings and connect-dialog helpers, so salesforce_sandbox (and future sandbox
entries) can enable client credentials. Use the effective CC token URL template
(cc_token_url or token_url) so the instance-name field works for Coupa/ServiceNow,
and hide that field when a connect_config_template already owns the instance input
(ServiceNow). Document the authorization contract on resolve_instance_cc_credentials.

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

* refactor: unify instance-templated oauth onto connect_config_template

Remove the separate cc_token_url and cc_instance config fields. An instance-
templated provider now declares one connect_config_template (auth_url optional
for client-credentials-only providers like Coupa); the CC flow reads its token
URL, label and strip_suffix to host-pin the exchange. Coupa and ServiceNow move
to connect_config_template; Coupa stays drawer-only (no auth_url -> excluded from
instance settings). Salesforce CC is removed for now (its auth-code/CC host split
needs the endpoint-profiles model).

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

* feat: cc_scopes defaults and instance config for client credentials

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

* fix: store empty auth_url for cc-only templated oauth providers

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

* fix: address review nits - sandbox key lookup, template doc, deref specs

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

* fix: default shared client-credentials connect to cc_scopes

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

* feat: support bring-your-own client credentials for instance-configured providers

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

* refactor: move oauth grant-type help into per-option tooltips

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

* fix: keep instance-configured oauth providers selectable from Others

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

* fix: preserve admin-configured scopes for custom client-credentials providers

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

* fix: use cc scopes on cc refresh and enforce cc grant for bring-your-own

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

* fix: require {instance} in leftmost host label for cc token url templates

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

* fix: drop token_url from unauthenticated get_connect response

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

* fix: fill byo templated resource args from the entered instance

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

* chore: update ee-repo-ref to 136f4634aca61e74ccb045372358a1e3f6b23e75

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

Previous ee-repo-ref: b5083e266492e908456e39401778a9cdcea46e94

New ee-repo-ref: 136f4634aca61e74ccb045372358a1e3f6b23e75

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-06-17 16:32:01 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 windmill-internal-app[bot]
parent ba69d8147b
commit e26a9239a6
13 changed files with 1221 additions and 303 deletions
+1 -1
View File
@@ -1 +1 @@
97b5cb2096d3a9b4818943c5abf181d914cb4e99
136f4634aca61e74ccb045372358a1e3f6b23e75
+27 -1
View File
@@ -12,6 +12,7 @@
"bitbucket": {
"auth_url": "https://bitbucket.org/site/oauth2/authorize",
"token_url": "https://bitbucket.org/site/oauth2/access_token",
"grant_types": ["authorization_code", "client_credentials"],
"scopes": ["repository"]
},
"slack": {
@@ -103,6 +104,7 @@
"linkedin": {
"auth_url": "https://www.linkedin.com/oauth/v2/authorization",
"token_url": "https://www.linkedin.com/oauth/v2/accessToken",
"grant_types": ["authorization_code", "client_credentials"],
"scopes": ["w_member_social", "r_liteprofile", "r_emailaddress"],
"req_body_auth": true
},
@@ -114,14 +116,31 @@
"visma": {
"auth_url": "https://connect.visma.com/connect/authorize",
"token_url": "https://connect.visma.com/connect/token",
"grant_types": ["authorization_code", "client_credentials"],
"scopes": [
"offline_access",
"vismanet_erp_interactive_api:create",
"vismanet_erp_interactive_api:delete",
"vismanet_erp_interactive_api:read",
"vismanet_erp_interactive_api:update"
],
"cc_scopes": [
"vismanet_erp_service_api:create",
"vismanet_erp_service_api:delete",
"vismanet_erp_service_api:read",
"vismanet_erp_service_api:update"
]
},
"coupa": {
"grant_types": ["client_credentials"],
"connect_config_template": {
"display_name": "Coupa",
"label": "Coupa instance",
"placeholder": "your-instance",
"token_url": "https://{instance}.coupahost.com/oauth2/token",
"strip_suffix": ".coupahost.com"
}
},
"sage_intacct": {
"auth_url": "https://api.intacct.com/ia/api/v1/oauth2/authorize",
"token_url": "https://api.intacct.com/ia/api/v1/oauth2/token",
@@ -130,6 +149,7 @@
"spotify": {
"auth_url": "https://accounts.spotify.com/authorize",
"token_url": "https://accounts.spotify.com/api/token",
"grant_types": ["authorization_code", "client_credentials"],
"scopes": [
"user-read-playback-state",
"user-modify-playback-state",
@@ -149,12 +169,16 @@
"xero": {
"auth_url": "https://login.xero.com/identity/connect/authorize",
"token_url": "https://identity.xero.com/connect/token",
"scopes": ["offline_access", "accounting.transactions"]
"grant_types": ["authorization_code", "client_credentials"],
"scopes": ["offline_access", "accounting.transactions"],
"cc_scopes": ["accounting.transactions"]
},
"zoho": {
"auth_url": "https://accounts.zoho.com/oauth/v2/auth",
"token_url": "https://accounts.zoho.com/oauth/v2/token",
"grant_types": ["authorization_code", "client_credentials"],
"scopes": ["ZohoAssist.sessionapi.ALL"],
"cc_scopes": ["ZohoAssist.sessionapi.ALL"],
"extra_params": {
"access_type": "offline"
}
@@ -197,6 +221,8 @@
}
},
"servicenow": {
"grant_types": ["authorization_code", "client_credentials"],
"req_body_auth": true,
"connect_config_template": {
"display_name": "ServiceNow",
"label": "ServiceNow Instance",
+57 -16
View File
@@ -1,7 +1,7 @@
{
"openapi": "3.0.3",
"info": {
"version": "1.723.0",
"version": "1.728.0",
"title": "Windmill API",
"contact": {
"name": "Windmill Team",
@@ -9700,9 +9700,9 @@
"type": "string",
"description": "OAuth client secret for resource-level credentials (client_credentials flow only)"
},
"cc_token_url": {
"cc_instance": {
"type": "string",
"description": "OAuth token URL override for resource-level authentication (client_credentials flow only)"
"description": "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side (client_credentials flow only). The token URL is never caller-supplied."
},
"mcp_server_url": {
"type": "string",
@@ -9739,7 +9739,7 @@
}
}
},
"/oauth/connect_client_credentials/{client}": {
"/w/{workspace}/oauth/connect_client_credentials/{client}": {
"post": {
"summary": "connect OAuth using client credentials",
"operationId": "connectClientCredentials",
@@ -9747,6 +9747,9 @@
"oauth"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "client",
"in": "path",
@@ -9773,21 +9776,17 @@
},
"cc_client_id": {
"type": "string",
"description": "OAuth client ID for resource-level authentication"
"description": "OAuth client ID. Omit to use the credentials configured on the provider's instance OAuth entry."
},
"cc_client_secret": {
"type": "string",
"description": "OAuth client secret for resource-level authentication"
"description": "OAuth client secret. Omit to use the credentials configured on the provider's instance OAuth entry."
},
"cc_token_url": {
"cc_instance": {
"type": "string",
"description": "OAuth token URL override for resource-level authentication"
"description": "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side. The token URL is never caller-supplied."
}
},
"required": [
"cc_client_id",
"cc_client_secret"
]
}
}
}
}
@@ -10000,7 +9999,23 @@
"schema": {
"type": "array",
"items": {
"type": "string"
"type": "object",
"properties": {
"name": {
"type": "string"
},
"supports_client_credentials": {
"type": "boolean"
},
"has_shared_credentials": {
"type": "boolean"
}
},
"required": [
"name",
"supports_client_credentials",
"has_shared_credentials"
]
}
}
}
@@ -10049,6 +10064,10 @@
"items": {
"type": "string"
}
},
"client_credentials_configured": {
"type": "boolean",
"description": "The instance OAuth entry carries shared client-credentials, so the connect dialog can skip the bring-your-own form and run the exchange server-side"
}
}
}
@@ -12191,10 +12210,18 @@
"type": "string",
"description": "Best-effort, read from the draft JSON's `summary` field when the editor shape carries one."
},
"draft_path": {
"type": "string",
"description": "User-typed friendly path from the draft JSON's `draft_path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`)."
},
"draft_only": {
"type": "boolean",
"description": "No deployed counterpart exists at this path — the draft is the whole item."
},
"legacy_draft": {
"type": "boolean",
"description": "The listed draft is a legacy workspace-level row (email NULL) predating the per-user drafts migration. Only true when no per-user draft exists at this path."
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -12204,6 +12231,7 @@
"kind",
"path",
"draft_only",
"legacy_draft",
"created_at"
]
}
@@ -12316,6 +12344,10 @@
"force": {
"type": "boolean",
"description": "Skip the conflict check and overwrite the server copy."
},
"legacy": {
"type": "boolean",
"description": "Delete-only. Target the legacy workspace-level row (email NULL) instead of the current user's row. Used to discard a legacy draft from the review page."
}
}
}
@@ -12359,9 +12391,10 @@
"description": "Creates a new script when the path does not already exist.\nCreates a new version of an existing script when called with the same path and the current `parent_hash`.\n",
"operationId": "createScript",
"x-mcp-tool": true,
"x-mcp-instructions": "To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed.",
"x-mcp-instructions": "To create a NEW script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language, and leave parent_hash unset. For TypeScript, use 'bun' unless deno-specific APIs are needed. To UPDATE an existing script, do NOT delete and recreate it: call this tool with the same path and set parent_hash to the script's current hash, which you can read from the `hash` field returned by getScriptByPath. This creates a new version while preserving the script's history.",
"x-mcp-tool-include-fields": [
"path",
"parent_hash",
"content",
"language",
"summary",
@@ -33609,8 +33642,16 @@
"type": "string",
"nullable": true,
"description": "Workspace username of the draft owner. `null` represents\nthe legacy workspace-level (NULL-email) row. Emails never\nleave the server.\n"
},
"draft_saved_at": {
"type": "string",
"format": "date-time",
"description": "When this user's draft was last saved (`draft.created_at`),\nsurfaced in the fork modal as \"Last updated\".\n"
}
}
},
"required": [
"draft_saved_at"
]
}
}
},
+81 -16
View File
@@ -1,6 +1,6 @@
openapi: 3.0.3
info:
version: 1.723.0
version: 1.728.0
title: Windmill API
contact:
name: Windmill Team
@@ -7696,6 +7696,16 @@ paths:
Emails never
leave the server.
draft_saved_at:
type: string
format: date-time
description: >
When this user's draft was last saved
(`draft.created_at`),
surfaced in the fork modal as "Last updated".
required:
- draft_saved_at
required: &ref_79
- is_draft
/w/{workspace}/variables/get_value/{path}:
@@ -8900,11 +8910,14 @@ paths:
description: >-
OAuth client secret for resource-level credentials
(client_credentials flow only)
cc_token_url:
cc_instance:
type: string
description: >-
OAuth token URL override for resource-level authentication
(client_credentials flow only)
Instance name for built-in providers whose
client-credentials token URL is instance-templated;
substituted into the fixed-host registry template
server-side (client_credentials flow only). The token URL is
never caller-supplied.
mcp_server_url:
type: string
description: MCP server URL for MCP OAuth token refresh
@@ -8926,13 +8939,17 @@ paths:
text/plain:
schema:
type: string
/oauth/connect_client_credentials/{client}:
/w/{workspace}/oauth/connect_client_credentials/{client}:
post:
summary: connect OAuth using client credentials
operationId: connectClientCredentials
tags:
- oauth
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: client
in: path
description: OAuth client name
@@ -8953,16 +8970,21 @@ paths:
type: string
cc_client_id:
type: string
description: OAuth client ID for resource-level authentication
description: >-
OAuth client ID. Omit to use the credentials configured on
the provider's instance OAuth entry.
cc_client_secret:
type: string
description: OAuth client secret for resource-level authentication
cc_token_url:
description: >-
OAuth client secret. Omit to use the credentials configured
on the provider's instance OAuth entry.
cc_instance:
type: string
description: OAuth token URL override for resource-level authentication
required:
- cc_client_id
- cc_client_secret
description: >-
Instance name for built-in providers whose
client-credentials token URL is instance-templated;
substituted into the fixed-host registry template
server-side. The token URL is never caller-supplied.
responses:
'200':
description: OAuth token response
@@ -9113,7 +9135,18 @@ paths:
schema:
type: array
items:
type: string
type: object
properties:
name:
type: string
supports_client_credentials:
type: boolean
has_shared_credentials:
type: boolean
required:
- name
- supports_client_credentials
- has_shared_credentials
/oauth/get_connect/{client}:
get:
summary: get oauth connect
@@ -9145,6 +9178,12 @@ paths:
type: array
items:
type: string
client_credentials_configured:
type: boolean
description: >-
The instance OAuth entry carries shared
client-credentials, so the connect dialog can skip the
bring-your-own form and run the exchange server-side
/teams/activities:
post:
summary: send update to Microsoft Teams activity
@@ -12719,11 +12758,24 @@ paths:
description: >-
Best-effort, read from the draft JSON's `summary` field
when the editor shape carries one.
draft_path:
type: string
description: >-
User-typed friendly path from the draft JSON's
`draft_path`, when set and different from the storage
path (e.g. a never-deployed item parked at
`u/{user}/draft_{uuid}`).
draft_only:
type: boolean
description: >-
No deployed counterpart exists at this path — the draft
is the whole item.
legacy_draft:
type: boolean
description: >-
The listed draft is a legacy workspace-level row (email
NULL) predating the per-user drafts migration. Only true
when no per-user draft exists at this path.
created_at:
type: string
format: date-time
@@ -12731,6 +12783,7 @@ paths:
- kind
- path
- draft_only
- legacy_draft
- created_at
/w/{workspace}/drafts/get/{kind}/{path}:
get:
@@ -12832,6 +12885,12 @@ paths:
force:
type: boolean
description: Skip the conflict check and overwrite the server copy.
legacy:
type: boolean
description: >-
Delete-only. Target the legacy workspace-level row (email
NULL) instead of the current user's row. Used to discard a
legacy draft from the review page.
responses:
'200':
description: save result
@@ -12862,11 +12921,17 @@ paths:
operationId: createScript
x-mcp-tool: true
x-mcp-instructions: >-
To create a script, specify the path (e.g., 'f/my_folder/my_script'),
the content (source code), and the language. For TypeScript, use 'bun'
unless deno-specific APIs are needed.
To create a NEW script, specify the path (e.g.,
'f/my_folder/my_script'), the content (source code), and the language,
and leave parent_hash unset. For TypeScript, use 'bun' unless
deno-specific APIs are needed. To UPDATE an existing script, do NOT
delete and recreate it: call this tool with the same path and set
parent_hash to the script's current hash, which you can read from the
`hash` field returned by getScriptByPath. This creates a new version
while preserving the script's history.
x-mcp-tool-include-fields:
- path
- parent_hash
- content
- language
- summary
+23 -11
View File
@@ -6288,9 +6288,9 @@ paths:
cc_client_secret:
type: string
description: "OAuth client secret for resource-level credentials (client_credentials flow only)"
cc_token_url:
cc_instance:
type: string
description: "OAuth token URL override for resource-level authentication (client_credentials flow only)"
description: "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side (client_credentials flow only). The token URL is never caller-supplied."
mcp_server_url:
type: string
description: "MCP server URL for MCP OAuth token refresh"
@@ -6311,13 +6311,14 @@ paths:
schema:
type: string
/oauth/connect_client_credentials/{client}:
/w/{workspace}/oauth/connect_client_credentials/{client}:
post:
summary: connect OAuth using client credentials
operationId: connectClientCredentials
tags:
- oauth
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: client
in: path
description: OAuth client name
@@ -6338,16 +6339,13 @@ paths:
type: string
cc_client_id:
type: string
description: "OAuth client ID for resource-level authentication"
description: "OAuth client ID. Omit to use the credentials configured on the provider's instance OAuth entry."
cc_client_secret:
type: string
description: "OAuth client secret for resource-level authentication"
cc_token_url:
description: "OAuth client secret. Omit to use the credentials configured on the provider's instance OAuth entry."
cc_instance:
type: string
description: "OAuth token URL override for resource-level authentication"
required:
- cc_client_id
- cc_client_secret
description: "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side. The token URL is never caller-supplied."
responses:
"200":
description: OAuth token response
@@ -6481,7 +6479,18 @@ paths:
schema:
type: array
items:
type: string
type: object
properties:
name:
type: string
supports_client_credentials:
type: boolean
has_shared_credentials:
type: boolean
required:
- name
- supports_client_credentials
- has_shared_credentials
/oauth/get_connect/{client}:
get:
@@ -6514,6 +6523,9 @@ paths:
type: array
items:
type: string
client_credentials_configured:
type: boolean
description: "The instance OAuth entry carries shared client-credentials, so the connect dialog can skip the bring-your-own form and run the exchange server-side"
/teams/activities:
post:
+504 -85
View File
@@ -67,6 +67,13 @@ pub struct ClientWithScopes {
pub allowed_domains: Option<Vec<String>>,
pub userinfo_url: Option<String>,
pub grant_types: Vec<String>,
/// Resolved token endpoint, exposed so the connect dialog can prefill and
/// persist it on client-credentials accounts.
pub token_url: String,
/// Whether the instance entry carries shared credentials (non-empty id +
/// secret). Providers without them are bring-your-own only — the connect
/// dialog lists them under "Others", not "Instance-configured".
pub has_shared_credentials: bool,
}
/// Map of OAuth client names to their configurations
@@ -81,6 +88,13 @@ pub struct OAuthConfig {
pub token_url: String,
pub userinfo_url: Option<String>,
pub scopes: Option<Vec<String>>,
/// Default scopes for the client-credentials (2-legged) flow. These differ
/// from the authorization-code `scopes` for most providers (member/consent
/// scopes are invalid in a 2-legged token request), so CC never defaults to
/// `scopes`. Absent means no default scope — the caller supplies any
/// provider-specific scopes themselves.
#[serde(skip_serializing_if = "Option::is_none")]
pub cc_scopes: Option<Vec<String>>,
pub extra_params: Option<HashMap<String, String>>,
pub extra_params_callback: Option<HashMap<String, String>>,
pub req_body_auth: Option<bool>,
@@ -91,10 +105,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`.
/// Metadata for per-instance OAuth providers (Snowflake, ServiceNow, Coupa,
/// …) whose authorize/token URLs carry an `{instance}` placeholder filled
/// from an instance name. The instance-settings UI uses it to build the
/// per-client `connect_config` for the authorization-code flow; the
/// client-credentials flow reads its `token_url`/`strip_suffix`/`label`
/// directly to host-pin the exchange.
#[serde(skip_serializing_if = "Option::is_none")]
pub connect_config_template: Option<ConnectConfigTemplate>,
}
@@ -111,11 +127,13 @@ 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
/// 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.
/// no frontend code change. The client-credentials flow additionally reads
/// `token_url`, `strip_suffix`, and `label` from it server-side (see
/// `resolve_cc_token_url_input`) to host-pin the token exchange.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ConnectConfigTemplate {
/// Properly-cased provider name for the settings dropdown (e.g. "ServiceNow");
@@ -126,7 +144,10 @@ pub struct ConnectConfigTemplate {
pub placeholder: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_url: Option<String>,
pub auth_url: String,
/// Authorize endpoint (with `{instance}`). Absent for client-credentials-only
/// providers (e.g. Coupa) that have no browser sign-in flow.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_url: Option<String>,
pub token_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub req_body_auth: Option<bool>,
@@ -237,8 +258,12 @@ fn empty_string() -> String {
"".to_string()
}
/// Placeholder authorize URL for providers that only support the
/// client-credentials grant (the authorize endpoint is never used by it).
pub const MISSING_AUTH_URL: &str = "https://missing-auth-url";
fn empty_auth() -> String {
"https://missing-auth-url".to_string()
MISSING_AUTH_URL.to_string()
}
fn default_grant_types() -> Vec<String> {
@@ -348,77 +373,349 @@ pub async fn build_slack_client(
Ok(client)
}
/// Build OAuth client for client credentials flow with resource-level credentials
/// Build OAuth client for client credentials flow with resource-level credentials.
///
/// No instance-level entry is required: the provider endpoint config resolves
/// from the instance `oauths` entry when one exists, else from the static
/// registry, else is synthesized from the token URL override alone. Returns the
/// built client together with the resolved [`OAuthConfig`] so callers can reuse
/// its scopes / `extra_params_callback`.
pub async fn build_client_credentials_oauth_client(
db: &DB,
client_name: &str,
client_id: &str,
client_secret: &str,
cc_token_url_override: Option<&str>,
resolved_token_url: Option<&str>,
connect_configs_json: &str,
) -> error::Result<(OClient, OAuthClient)> {
) -> error::Result<(OClient, OAuthConfig)> {
use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING};
let oauths = load_value_from_global_settings(db, OAUTH_SETTING).await?;
let oauths = oauths.unwrap_or_default();
let oauth_config = oauths
.get(client_name)
.ok_or_else(|| error::Error::BadRequest("OAuth configuration not found".to_string()))?;
let instance_entry: Option<OAuthClient> = oauths
.as_ref()
.and_then(|o| o.get(client_name))
.and_then(|v| match serde_json::from_value(v.clone()) {
Ok(entry) => Some(entry),
Err(e) => {
tracing::warn!(
client = %client_name,
"Invalid instance OAuth entry, falling back to static registry: {e}"
);
None
}
});
let oauth_client_config: OAuthClient = serde_json::from_value(oauth_config.clone())
.map_err(|e| error::Error::BadRequest(format!("Invalid OAuth config: {}", e)))?;
let parse_static_configs = || {
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json).map_err(|e| {
error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e))
})
let resolve_from_registry = |client_name: &str| -> error::Result<Option<OAuthConfig>> {
let static_configs =
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json).map_err(
|e| error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)),
)?;
Ok(resolve_registry_config(&static_configs, client_name))
};
let resolve_from_registry = |client_name: &str| -> error::Result<OAuthConfig> {
let static_configs = parse_static_configs()?;
resolve_registry_config(&static_configs, client_name).ok_or_else(|| {
// A token URL alone is enough for client credentials: providers that only
// support this grant have no authorize endpoint to configure.
let instance_connect_config = instance_entry
.as_ref()
.and_then(|e| e.connect_config.clone())
.filter(|c| !c.token_url.is_empty())
.map(|mut c| {
if c.auth_url.is_empty() {
c.auth_url = empty_auth();
}
c
});
let from_instance = instance_connect_config.is_some();
let mut connect_config = match instance_connect_config {
Some(config) => config,
None => resolve_from_registry(client_name)?.ok_or_else(|| {
error::Error::BadRequest(format!(
"OAuth configuration not found for '{}' in either global settings or static config",
"No token URL available for '{}': not found in instance OAuth settings or static \
config",
client_name
))
})
})?,
};
let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config {
if !config.auth_url.is_empty() && !config.token_url.is_empty() {
config.clone()
} else {
resolve_from_registry(client_name)?
}
} else {
resolve_from_registry(client_name)?
};
if let Some(override_url) = cc_token_url_override {
connect_config.token_url = override_url.to_string();
// Registry providers default their client-credentials scopes from `cc_scopes`,
// never the authorization-code `scopes` (which several providers reject for a
// 2-legged token request). Instance-configured entries keep their admin-set
// scopes untouched.
if !from_instance {
connect_config.scopes = connect_config.cc_scopes.clone();
}
let caller_supplied_creds = !client_id.is_empty() && !client_secret.is_empty();
// Apply the server-resolved concrete token URL. Instance-templated providers
// (e.g. Coupa) carry an empty or `{instance}`-templated token URL in their
// registry config; the resolved value (host-pinned for bring-your-own,
// persisted on the row for refresh) is what completes it. The caller never
// supplies a free-form token URL: this value always comes from
// `resolve_cc_token_url_input` or a previously-resolved persisted URL.
if let Some(url) = resolved_token_url {
connect_config.token_url = url.to_string();
}
if connect_config.token_url.is_empty() {
return Err(error::Error::BadRequest(format!(
"No token URL configured for '{}'",
client_name
)));
}
// Fall back to the instance entry's own credentials when the caller supplies
// none: the shared instance-level client-credentials setup, where an admin
// configures one service-account client for everyone and the secret never
// leaves the server. Only entries that explicitly enable the
// client_credentials grant qualify, so an authorization-code-only client's
// secret is never reused for this flow.
let instance_cc_creds = instance_entry.as_ref().filter(|e| {
e.grant_types.iter().any(|g| g == "client_credentials")
&& !e.id.is_empty()
&& !e.secret.is_empty()
});
// All-or-nothing: use the caller's credentials only when both id and secret
// are present, otherwise fall back entirely to the instance entry. Never mix
// a caller-supplied id with the admin secret (or vice versa).
let (resolved_client_id, resolved_client_secret) = if caller_supplied_creds {
(client_id.to_string(), client_secret.to_string())
} else {
instance_cc_creds
.map(|e| (e.id.clone(), e.secret.clone()))
.unwrap_or_default()
};
let resource_oauth_client = OAuthClient {
id: client_id.to_string(),
secret: client_secret.to_string(),
allowed_domains: oauth_client_config.allowed_domains.clone(),
id: resolved_client_id,
secret: resolved_client_secret,
allowed_domains: instance_entry
.as_ref()
.and_then(|e| e.allowed_domains.clone()),
connect_config: Some(connect_config.clone()),
login_config: oauth_client_config.login_config.clone(),
display_name: oauth_client_config.display_name.clone(),
grant_types: oauth_client_config.grant_types.clone(),
tenant: oauth_client_config.tenant.clone(),
login_config: instance_entry.as_ref().and_then(|e| e.login_config.clone()),
display_name: instance_entry.as_ref().and_then(|e| e.display_name.clone()),
grant_types: instance_entry
.as_ref()
.map(|e| e.grant_types.clone())
.unwrap_or_else(default_grant_types),
tenant: instance_entry.as_ref().and_then(|e| e.tenant.clone()),
};
let base_url = (**BASE_URL.load()).clone();
let (_, client) = build_basic_client(
client_name.to_string(),
connect_config,
connect_config.clone(),
resource_oauth_client,
false,
&base_url,
None,
)?;
Ok((client, oauth_client_config))
Ok((client, connect_config))
}
/// Shared instance-level client-credentials for `client_name`: the `(id, secret,
/// token_url)` from its instance `oauths` entry, but only when that entry both
/// declares the `client_credentials` grant and carries non-empty credentials.
/// Lets the connect flow use one admin-configured service-account client instead
/// of asking each user for their own.
///
/// # Authorization
/// Returns the admin's shared service-account secret, so callers MUST first
/// verify the caller's authorization to use it (workspace membership plus
/// read-write access — operators and read-only tokens are excluded). This helper
/// performs no authorization itself.
pub async fn resolve_instance_cc_credentials(
db: &DB,
client_name: &str,
) -> error::Result<Option<(String, String, Option<String>)>> {
use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING};
let oauths = load_value_from_global_settings(db, OAUTH_SETTING).await?;
let entry: Option<OAuthClient> = oauths
.as_ref()
.and_then(|o| o.get(client_name))
.and_then(|v| serde_json::from_value(v.clone()).ok());
Ok(entry.and_then(|e| {
let cc_grant = e.grant_types.iter().any(|g| g == "client_credentials");
if cc_grant && !e.id.is_empty() && !e.secret.is_empty() {
// Token URL from the entry's connect_config (built by instance settings
// from the connect_config_template), so the account row is
// self-contained for refresh.
let token_url = e
.connect_config
.as_ref()
.map(|c| c.token_url.clone())
.filter(|u| !u.is_empty());
Some((e.id, e.secret, token_url))
} else {
None
}
}))
}
/// Resolve the concrete client-credentials token URL for a bring-your-own
/// connection. The caller never supplies a token URL: it always comes from the
/// built-in registry, so the exchange host can never be redirected.
///
/// Supported only for registry providers. For one whose CC token URL carries an
/// `{instance}` placeholder (Coupa, ServiceNow, …) — declared in its
/// `connect_config_template` — the caller supplies only an instance name,
/// validated as a bare hostname label and substituted into the fixed-host
/// template. A fixed-host registry provider uses its registry token URL directly.
/// A custom resource type (no registry entry) is rejected: there is no known host
/// to send credentials to.
pub fn resolve_cc_token_url_input(
connect_configs_json: &str,
client_name: &str,
caller_instance: Option<&str>,
) -> error::Result<String> {
let Some(cfg) = serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)
.ok()
.and_then(|m| resolve_registry_config(&m, client_name))
else {
return Err(error::Error::BadRequest(format!(
"Client credentials with your own credentials are only supported for built-in OAuth \
providers, not '{client_name}'. Configure shared credentials on the instance OAuth \
entry instead."
)));
};
// Instance-templated providers carry the `{instance}` token URL (and its
// label/strip_suffix) in `connect_config_template`; fixed-host providers use
// the plain `token_url`.
let tmpl = cfg.connect_config_template.as_ref();
let template = tmpl
.map(|t| t.token_url.clone())
.filter(|u| !u.is_empty())
.or_else(|| Some(cfg.token_url.clone()).filter(|u| !u.is_empty()))
.ok_or_else(|| {
error::Error::BadRequest(format!("No token URL is configured for '{client_name}'"))
})?;
if !template.contains("{instance}") {
// Fixed-host registry provider: its registry token URL is authoritative.
return Ok(template);
}
// Structural host-pinning guard: only substitute when `{instance}` is the
// leftmost host label of a fixed-host template (`scheme://{instance}.fixed-host/…`).
// The hostname-label validation below keeps the value clean, but only this
// check guarantees the substituted value can never change the registrable
// domain — so a malformed template (e.g. `https://{instance}/token`) can't turn
// the caller's instance name into a full attacker-controlled host (SSRF /
// credential exfiltration). The template is a code-reviewed registry file, so a
// violation is a programming error.
let placeholder = "{instance}";
let idx = template.find(placeholder).unwrap();
let after = &template[idx + placeholder.len()..];
if !template[..idx].ends_with("://") || !after.starts_with('.') {
return Err(error::Error::InternalErr(format!(
"Invalid instance-templated token URL for '{client_name}': {{instance}} must be the \
leftmost host label (scheme://{{instance}}.fixed-host/…)"
)));
}
let raw = caller_instance
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
error::Error::BadRequest(format!(
"{} is required for {client_name}",
tmpl.map(|t| t.label.as_str()).unwrap_or("An instance name")
))
})?;
// Strip an optional known host suffix so the user can paste a full host or a
// bare name, then accept only a hostname label — never any character that
// could move the host out of the template's domain.
let value = tmpl
.and_then(|t| t.strip_suffix.as_deref())
.and_then(|sfx| raw.strip_suffix(sfx))
.unwrap_or(raw)
.trim_end_matches('.');
let valid = !value.is_empty()
&& !value.starts_with(['-', '.'])
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.');
if !valid {
return Err(error::Error::BadRequest(format!(
"invalid instance name '{raw}' for {client_name}"
)));
}
Ok(template.replace("{instance}", value))
}
/// Resolve the concrete bring-your-own client-credentials token URL for any
/// provider, never from a caller-supplied URL:
/// - **Built-in registry providers** resolve from the registry via
/// [`resolve_cc_token_url_input`] (host-pinned from the caller's instance name
/// for instance-templated ones).
/// - **Custom providers configured at the instance level** use the admin's
/// `connect_config.token_url`. The caller has no instance template to fill, so
/// an instance name is rejected.
///
/// This is the single entry point the connect/account-creation handlers should
/// use so both resolve identically.
pub async fn resolve_cc_token_url(
db: &DB,
client_name: &str,
caller_instance: Option<&str>,
connect_configs_json: &str,
) -> error::Result<String> {
use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING};
let supports_cc =
|grant_types: &[String]| grant_types.iter().any(|g| g == "client_credentials");
let registry_cfg = serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)
.ok()
.and_then(|m| resolve_registry_config(&m, client_name));
if let Some(cfg) = registry_cfg {
// Built-in provider: only honor it for client credentials if it actually
// declares that grant, so an authorization-code-only provider can't be
// driven through the CC API.
if !supports_cc(&cfg.grant_types) {
return Err(error::Error::BadRequest(format!(
"'{client_name}' is not enabled for the client_credentials grant"
)));
}
return resolve_cc_token_url_input(connect_configs_json, client_name, caller_instance);
}
// Custom (non-registry) provider: the token URL comes from the admin's
// instance connect_config (an admin-configured, trusted host), never the
// caller. The instance entry must also enable the client-credentials grant.
let entry: Option<OAuthClient> = load_value_from_global_settings(db, OAUTH_SETTING)
.await?
.as_ref()
.and_then(|o| o.get(client_name))
.and_then(|v| serde_json::from_value(v.clone()).ok());
let instance_token_url = entry
.as_ref()
.filter(|e| supports_cc(&e.grant_types))
.and_then(|e| e.connect_config.clone())
.map(|c| c.token_url)
.filter(|u| !u.is_empty());
match instance_token_url {
Some(_)
if caller_instance
.map(|s| !s.trim().is_empty())
.unwrap_or(false) =>
{
Err(error::Error::BadRequest(format!(
"An instance name only applies to built-in instance-templated providers, not \
'{client_name}'"
)))
}
Some(url) => Ok(url),
None => Err(error::Error::BadRequest(format!(
"Client credentials with your own credentials require '{client_name}' to be a built-in \
OAuth provider or an instance entry that enables the client_credentials grant"
))),
}
}
/// Exchange authorization code for tokens
@@ -462,7 +759,7 @@ pub async fn exchange_token(
client: OClient,
refresh_token: &str,
grant_type: &str,
oauth_client_info: Option<&ClientWithScopes>,
extra_params_callback: Option<&HashMap<String, String>>,
http_client: &reqwest::Client,
scopes: Option<&[String]>,
) -> Result<TokenResponse, Error> {
@@ -483,11 +780,9 @@ pub async fn exchange_token(
"client_credentials" => {
let mut token_request = client.exchange_client_credentials();
if let Some(oauth_info) = oauth_client_info {
if let Some(extra_params) = oauth_info.extra_params_callback.as_ref() {
for (key, value) in extra_params.iter() {
token_request = token_request.param(key.clone(), value.clone());
}
if let Some(extra_params) = extra_params_callback {
for (key, value) in extra_params.iter() {
token_request = token_request.param(key.clone(), value.clone());
}
}
@@ -579,49 +874,78 @@ pub async fn refresh_token_for_account<'c>(
http_client: &reqwest::Client,
connect_configs_json: &str,
) -> error::Result<String> {
let oauth_client_info = oauth_clients
.connects
.get(&account.client)
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?
.clone();
// Instance-configured client: required for authorization_code (the refresh
// token exchange uses the instance app's credentials). For client_credentials
// it is resolved inside `build_client_credentials_oauth_client` instead.
let oauth_client_info = oauth_clients.connects.get(&account.client).cloned();
let mut client = if account.grant_type == "client_credentials" {
match (&account.cc_client_id, &account.cc_client_secret) {
(Some(client_id), Some(client_secret)) => {
let (client, _) = build_client_credentials_oauth_client(
db,
&account.client,
client_id,
client_secret,
account.cc_token_url.as_deref(),
connect_configs_json,
)
.await?;
client
}
_ => {
return Err(error::Error::BadRequest(
"client_credentials flow requires cc_client_id and cc_client_secret to be stored in account".to_string()
));
}
}
let is_client_credentials = account.grant_type == "client_credentials";
let (mut client, cc_config) = if is_client_credentials {
// Bring-your-own accounts store their own credentials (and resolved token
// URL) on the row. Shared instance accounts store none: passing empty
// credentials makes the builder re-resolve the admin's service-account
// credentials and token URL from the instance entry on every refresh, so a
// rotated or removed shared secret takes effect immediately (mirrors the
// authorization-code model, where the row never holds the app secret).
let (client_id, client_secret) = match (&account.cc_client_id, &account.cc_client_secret) {
(Some(id), Some(secret)) => (id.as_str(), secret.as_str()),
_ => ("", ""),
};
let (client, config) = build_client_credentials_oauth_client(
db,
&account.client,
client_id,
client_secret,
account.cc_token_url.as_deref(),
connect_configs_json,
)
.await?;
(client, Some(config))
} else {
oauth_client_info.client.to_owned()
let info = oauth_client_info
.as_ref()
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?;
(info.client.to_owned(), None)
};
// Account-level scopes override instance-level scopes
// Account-level scopes (when stored) override these defaults. Client-credentials
// accounts default to the resolved CC config's scopes (`cc_scopes` for registry
// providers, the admin's instance scopes for custom ones) — never the instance
// client's authorization-code scopes, which are invalid in a 2-legged request.
// Authorization-code accounts default to the instance client's scopes.
let fallback_scopes = if is_client_credentials {
cc_config
.as_ref()
.and_then(|c| c.scopes.clone())
.unwrap_or_default()
} else {
oauth_client_info
.as_ref()
.map(|i| i.scopes.clone())
.unwrap_or_default()
};
let effective_scopes = account
.scopes
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&oauth_client_info.scopes);
.unwrap_or(&fallback_scopes);
if account.grant_type == "client_credentials" {
if is_client_credentials {
for scope in effective_scopes.iter() {
client.add_scope(scope);
}
}
let extra_params_callback = oauth_client_info
.as_ref()
.and_then(|i| i.extra_params_callback.clone())
.or_else(|| {
cc_config
.as_ref()
.and_then(|c| c.extra_params_callback.clone())
});
tracing::info!(
grant_type = %account.grant_type,
client = %account.client,
@@ -634,7 +958,7 @@ pub async fn refresh_token_for_account<'c>(
client,
&account.refresh_token,
&account.grant_type,
Some(&oauth_client_info),
extra_params_callback.as_ref(),
http_client,
Some(effective_scopes),
)
@@ -846,6 +1170,7 @@ mod tests {
token_url: "https://account.example.com/oauth/token".to_string(),
userinfo_url: Some("https://account.example.com/userinfo".to_string()),
scopes: Some(vec!["signature".to_string()]),
cc_scopes: None,
extra_params: None,
extra_params_callback: None,
req_body_auth: None,
@@ -927,4 +1252,98 @@ mod tests {
registry.insert("docusign".to_string(), sample_oauth_config(false));
assert!(resolve_registry_config(&registry, "docusign_sandbox").is_none());
}
const CC_REGISTRY: &str = r#"{
"coupa": {
"grant_types": ["client_credentials"],
"connect_config_template": {
"label": "Coupa instance",
"placeholder": "x",
"token_url": "https://{instance}.coupahost.com/oauth2/token",
"strip_suffix": ".coupahost.com"
}
},
"servicenow": {
"grant_types": ["authorization_code", "client_credentials"],
"connect_config_template": {
"label": "ServiceNow instance",
"placeholder": "dev12345",
"auth_url": "https://{instance}.service-now.com/oauth_auth.do",
"token_url": "https://{instance}.service-now.com/oauth_token.do",
"strip_suffix": ".service-now.com"
}
},
"visma": {
"auth_url": "https://connect.visma.com/connect/authorize",
"token_url": "https://connect.visma.com/connect/token",
"grant_types": ["authorization_code", "client_credentials"]
},
"bad_host_tpl": {
"grant_types": ["client_credentials"],
"connect_config_template": {
"label": "x", "placeholder": "x",
"token_url": "https://{instance}/token"
}
},
"bad_mid_tpl": {
"grant_types": ["client_credentials"],
"connect_config_template": {
"label": "x", "placeholder": "x",
"token_url": "https://api.{instance}.evil.com/token"
}
}
}"#;
#[test]
fn cc_token_url_templated_substitutes_instance() {
let url = resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("acme")).unwrap();
assert_eq!(url, "https://acme.coupahost.com/oauth2/token");
}
#[test]
fn cc_token_url_templated_from_connect_config_template() {
// ServiceNow's CC token URL comes from its connect_config_template.
let url = resolve_cc_token_url_input(CC_REGISTRY, "servicenow", Some("dev99")).unwrap();
assert_eq!(url, "https://dev99.service-now.com/oauth_token.do");
}
#[test]
fn cc_token_url_strips_known_host_suffix() {
let url =
resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("acme.coupahost.com")).unwrap();
assert_eq!(url, "https://acme.coupahost.com/oauth2/token");
}
#[test]
fn cc_token_url_rejects_instance_that_escapes_the_host() {
// A '/' (or any non-hostname char) must not let the caller move the host
// out of the template's domain.
assert!(resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("evil.com/oauth")).is_err());
assert!(resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("a@b")).is_err());
}
#[test]
fn cc_token_url_requires_instance_when_templated() {
assert!(resolve_cc_token_url_input(CC_REGISTRY, "coupa", None).is_err());
}
#[test]
fn cc_token_url_fixed_host_uses_registry_url() {
let url = resolve_cc_token_url_input(CC_REGISTRY, "visma", None).unwrap();
assert_eq!(url, "https://connect.visma.com/connect/token");
}
#[test]
fn cc_token_url_rejects_custom_provider() {
// No registry entry: bring-your-own client credentials are not allowed.
assert!(resolve_cc_token_url_input(CC_REGISTRY, "my_custom_thing", Some("acme")).is_err());
}
#[test]
fn cc_token_url_rejects_template_not_in_subdomain_position() {
// `{instance}` must be the leftmost host label of a fixed-host template, so
// a malformed template can't let the instance value control the host.
assert!(resolve_cc_token_url_input(CC_REGISTRY, "bad_host_tpl", Some("evil.com")).is_err());
assert!(resolve_cc_token_url_input(CC_REGISTRY, "bad_mid_tpl", Some("evil")).is_err());
}
}
@@ -47,8 +47,9 @@
async function isSupabaseAvailable() {
try {
supabaseWizard =
((await OauthService.listOauthConnects()) ?? {})['supabase_wizard'] != undefined
supabaseWizard = ((await OauthService.listOauthConnects()) ?? []).some(
(c) => c.name === 'supabase_wizard'
)
} catch (error) {}
}
async function loadSchema() {
+343 -123
View File
@@ -16,7 +16,7 @@
import oauthConnectRegistry from '$oauth_connect_registry'
import { createEventDispatcher, onDestroy } from 'svelte'
import Path from './Path.svelte'
import { Button, Skeleton } from './common'
import { Button, RadioCard, Skeleton } from './common'
import ApiConnectForm from './ApiConnectForm.svelte'
import SearchItems from './SearchItems.svelte'
import WhitelistIp from './WhitelistIp.svelte'
@@ -27,11 +27,10 @@
import { base } from '$lib/base'
import Required from './Required.svelte'
import Toggle from './Toggle.svelte'
import { Pen } from 'lucide-svelte'
import { Pen, Search } from 'lucide-svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { apiTokenApps, forceSecretValue, linkedSecretValue } from './app_connect'
import type { SchemaProperty } from '$lib/common'
import Tooltip from './Tooltip.svelte'
import TextInput from './text_input/TextInput.svelte'
import { sameTopDomainOrigin } from '$lib/cookies'
import SyncResourceTypes from './SyncResourceTypes.svelte'
@@ -75,6 +74,18 @@
let value: string = $state('')
let valueToken: TokenResponse | undefined = undefined
let connects: string[] | undefined = $state(undefined)
/** Per-provider instance-entry metadata, keyed by provider name. */
let connectsInfo: Record<
string,
{ supports_client_credentials: boolean; has_shared_credentials: boolean }
> = $state({})
/** An instance entry with shared credentials (admin id+secret): connect with
* no input. Shown under "Instance-configured"; bring-your-own-only providers
* (no shared creds) are shown under "Others" instead. */
function isSharedConnect(key: string): boolean {
return connectsInfo[key]?.has_shared_credentials ?? false
}
const SANDBOX_SUFFIX = '_sandbox'
function stripSandboxSuffix(name: string): string {
@@ -119,6 +130,9 @@
}
let scopes: string[] = $state([])
/** The authorization-code default scopes (instance entry / registry), kept so
* toggling back from client-credentials can restore them. */
let instanceScopes: string[] = $state([])
let extra_params: [string, string][] = []
let responseExtra: Record<string, string> = $state({})
let path: string = $state('')
@@ -147,11 +161,132 @@
*/
let clientId = $state('')
let clientSecret = $state('')
let tokenUrl = $state('')
let ccInstance = $state('')
let resourceTypeInfo: ResourceType | undefined = $state(undefined)
let resourceTypeNotFound = $state(false)
function registryEntry(): any {
const reg = oauthConnectRegistry as Record<string, any>
// Resolve `_sandbox` clients to their parent registry entry (e.g.
// salesforce_sandbox -> salesforce) so sandbox connections see CC metadata.
return reg[stripSandboxSuffix(connectClient)] ?? reg[stripSandboxSuffix(resourceType)]
}
/** The static registry declares this provider supports client credentials */
function registryCcCapable(): boolean {
return registryEntry()?.grant_types?.includes('client_credentials') ?? false
}
/** Instance-name metadata for providers whose token URL is instance-templated
* (carried in `connect_config_template`): the user enters an instance name
* instead of a full token URL, and the backend substitutes it into the
* fixed-host template so the exchange host stays pinned. */
let ccInstanceMeta = $derived(
registryEntry()?.connect_config_template as
| { label: string; placeholder: string; help_url?: string }
| undefined
)
/** Instance entry declares client credentials but not authorization_code
* (custom provider configured with only a token URL) */
let authCodeUnavailable = $state(false)
/** Instance entry carries shared client-credentials (id + secret); the user
* doesn't enter their own — the exchange runs server-side with those creds */
let ccInstanceConfigured = $state(false)
/** The user wants their own credentials (picked the provider from the "Others"
* section) — overrides the shared instance credentials for this connection */
let ccBringYourOwn = $state(false)
/** Connect with the shared instance credentials (no form) rather than the
* bring-your-own form */
let useSharedInstanceCreds = $derived(ccInstanceConfigured && !ccBringYourOwn)
/** Connectable via client credentials only: registry-declared provider with
* no instance OAuth client, or instance provider without an authorize URL */
let ccOnly = $derived.by(
() =>
authCodeUnavailable ||
(registryCcCapable() && connectClient != '' && !(connects?.includes(connectClient) ?? false))
)
/** Clear CC inputs and scopes so a previous selection never leaks into a new one */
function resetClientCredentialsState() {
supportsClientCredentials = false
useClientCredentials = false
authCodeUnavailable = false
ccInstanceConfigured = false
ccBringYourOwn = false
clientId = ''
clientSecret = ''
ccInstance = ''
scopes = []
}
/** Default scopes for the client-credentials grant. Registry providers use
* their `cc_scopes` (auth-code scopes are invalid in a 2-legged request);
* custom (non-registry) providers configured at the instance level have no
* registry entry, so they keep their admin-configured scopes (`instanceScopes`)
* instead of being zeroed. */
function defaultCcScopes(): string[] {
const entry = registryEntry()
return entry ? (entry.cc_scopes ?? []) : instanceScopes
}
function enableClientCredentials() {
manual = false
supportsClientCredentials = true
if (!useClientCredentials) {
// Switching into client-credentials: default to the CC scopes (never the
// authorization-code scopes — most providers reject member/consent scopes
// in a 2-legged request). Only reset on the transition so edits made while
// already in CC mode are preserved.
scopes = defaultCcScopes()
}
useClientCredentials = true
}
/** Switch to the browser sign-in (authorization-code) grant, restoring its
* default scopes when coming from the client-credentials grant. */
function selectAuthCodeGrant() {
if (useClientCredentials) {
scopes = instanceScopes
}
useClientCredentials = false
}
/** Static registry declares client-credentials support for `key`. */
function isCcCapable(key: string): boolean {
return (
(oauthConnectRegistry as Record<string, any>)[stripSandboxSuffix(key)]?.grant_types?.includes(
'client_credentials'
) ?? false
)
}
/** Step-1 "Others" selection: CC-capable resource types open the client-
* credentials form with the user's own credentials — even when the instance
* has shared ones (the "Instance-configured OAuth APIs" section is the entry
* point for those). Every other type opens the raw manual form. */
function selectFromOthers(key: string) {
connectClient = key
resourceType = key
resetClientCredentialsState()
// Registry CC providers and instance-configured providers that declare the
// client-credentials grant (incl. custom providers set up with only a token
// URL and no shared creds) open the bring-your-own form. Everything else is
// a manual resource.
if (isCcCapable(key) || (connectsInfo[key]?.supports_client_credentials ?? false)) {
ccBringYourOwn = true
enableClientCredentials()
} else {
manual = true
}
next()
}
let pathError = $state('')
export async function open(rt?: string) {
@@ -168,22 +303,33 @@
resourceType = stripSandboxSuffix(rawRt)
valueToken = undefined
// Reset client credentials state
supportsClientCredentials = false
useClientCredentials = false
clientId = ''
clientSecret = ''
tokenUrl = ''
resetClientCredentialsState()
await loadConnects()
manual = !connects?.includes(connectClient)
const inConnects = connects?.includes(connectClient) ?? false
// Registry-declared client-credentials providers are connectable even
// without an instance OAuth client
manual = !inConnects && !(rt && registryCcCapable())
if (manual && express) {
dispatch('error', 'Express OAuth setup is not available for non OAuth resource types')
return
}
if (!inConnects && !manual && express) {
// Client-credentials connections need interactive credential entry
dispatch('error', 'Express OAuth setup is not available for client credentials providers')
return
}
if (!inConnects && !manual) {
enableClientCredentials()
}
if (rt) {
if (!manual && express) {
await getScopesAndParams()
if (authCodeUnavailable) {
// No popup flow to drive express setup with
dispatch('error', 'Express OAuth setup is not available for client credentials providers')
return
}
step = 2
}
next()
@@ -193,18 +339,19 @@
async function loadConnects() {
if (!connects) {
try {
connects = (await OauthService.listOauthConnects())
.filter((x) => x != 'supabase_wizard')
.sort((a, b) => a.localeCompare(b))
const list = (await OauthService.listOauthConnects())
.filter((x) => x.name != 'supabase_wizard')
.sort((a, b) => a.name.localeCompare(b.name))
connects = list.map((x) => x.name)
connectsInfo = Object.fromEntries(list.map((x) => [x.name, x]))
} catch (e) {
connects = []
connectsInfo = {}
console.error('Error loading OAuth connects', e)
}
}
}
const connectAndManual = ['gitlab']
run(() => {
isGoogleSignin =
step == 1 &&
@@ -227,7 +374,11 @@
args['api_key'] == '' &&
args['key'] == '' &&
linkedSecrets.length > 0
: false)) ||
: useClientCredentials &&
!useSharedInstanceCreds &&
(clientId.trim() == '' ||
clientSecret.trim() == '' ||
(!!ccInstanceMeta && ccInstance.trim() == '')))) ||
step == 3 ||
(step == 4 && pathError != '') ||
!isValid
@@ -241,8 +392,11 @@
workspace: effectiveWorkspace
})
// "Others" lists every resource type — including instance-configured OAuth
// providers — so any of them can also be connected with the user's own
// credentials or manually, not only via the shared instance setup (same as
// the authorization-code behavior).
connectsManual = availableRts
.filter((x) => connectAndManual.includes(x) || !Object.keys(connects ?? {}).includes(x))
.map(
(x) =>
({
@@ -339,15 +493,39 @@
}
async function getScopesAndParams() {
if (!connects?.includes(connectClient)) {
// No instance OAuth client (registry-declared CC-only provider):
// defaults come from the static registry instead.
instanceScopes = registryEntry()?.scopes ?? []
scopes = useClientCredentials ? defaultCcScopes() : instanceScopes
extra_params = []
supportsClientCredentials = registryCcCapable()
return
}
const connect = await OauthService.getOauthConnect({ client: connectClient })
scopes = connect.scopes ?? []
instanceScopes = connect.scopes ?? []
extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][]
/**
* Check if the OAuth provider supports client_credentials grant type
* This determines whether to show the OAuth flow selection UI
* The CC flow is offered when the static registry declares it for the
* provider, or the admin enabled it on the instance entry (custom
* providers)
*/
supportsClientCredentials = connect.grant_types?.includes('client_credentials') ?? false
supportsClientCredentials =
registryCcCapable() || (connect.grant_types?.includes('client_credentials') ?? false)
// Shared instance credentials: the user connects without entering any creds
ccInstanceConfigured = connect.client_credentials_configured ?? false
// Custom provider configured with only a token URL: no popup flow possible
authCodeUnavailable =
supportsClientCredentials && !(connect.grant_types?.includes('authorization_code') ?? true)
if (authCodeUnavailable) {
useClientCredentials = true
}
// Default scopes to the active grant: client-credentials uses the registry's
// cc_scopes (auth-code scopes are invalid in a 2-legged request), every other
// path keeps the instance entry's scopes. Applies to shared instance creds,
// not just bring-your-own. Switching grants resets to these defaults.
scopes = useClientCredentials ? defaultCcScopes() : instanceScopes
}
async function getResourceTypeInfo() {
@@ -386,37 +564,46 @@
if (useClientCredentials) {
/**
* Client credentials flow: Direct API call to backend
* No popup window or user interaction required
* Uses instance-level OAuth credentials for server-to-server auth
* No popup window or user interaction required — the resource-level
* credentials are exchanged directly against the token URL
*/
try {
// Trim whitespace from credentials to avoid false negatives
const trimmedClientId = clientId.trim()
const trimmedClientSecret = clientSecret.trim()
const trimmedInstance = ccInstance.trim()
// Instance-templated providers collect an instance name; the backend
// builds the host-pinned token URL from it. Other registry providers
// need no URL input (the token URL comes from the registry).
const needsInstance = !!ccInstanceMeta
// Validate required fields
if (!trimmedClientId || !trimmedClientSecret) {
// Bring-your-own credentials are required unless the provider has
// shared instance credentials, in which case the exchange runs
// server-side with those and no input is collected here.
if (
!useSharedInstanceCreds &&
(!trimmedClientId || !trimmedClientSecret || (needsInstance && !trimmedInstance))
) {
sendUserToast(
'Client ID and Client Secret are required for client credentials flow',
needsInstance
? `Client ID, Client Secret and ${ccInstanceMeta?.label} are required for client credentials flow`
: 'Client ID and Client Secret are required for client credentials flow',
true
)
return
}
const requestBody: any = {
scopes: scopes,
cc_client_id: trimmedClientId,
cc_client_secret: trimmedClientSecret
}
// Add token URL override if provided
if (tokenUrl.trim()) {
requestBody.cc_token_url = tokenUrl.trim()
}
const tokenResponse = await OauthService.connectClientCredentials({
workspace: effectiveWorkspace,
client: connectClient,
requestBody
requestBody: useSharedInstanceCreds
? { scopes: scopes }
: {
scopes: scopes,
cc_client_id: trimmedClientId,
cc_client_secret: trimmedClientSecret,
...(needsInstance ? { cc_instance: trimmedInstance } : {})
}
})
// Process the token response like in popup flow
@@ -490,21 +677,34 @@
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
}
// 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.
// Per-instance OAuth providers (Snowflake, ServiceNow, …): fill the
// resource args from the connection's instance, per the registry
// template's resource_mapping (e.g. ServiceNow -> instance_url:
// https://{instance}.service-now.com). Bring-your-own carries the instance
// the user entered in `ccInstance` (raw, possibly a full host); the shared
// path carries it (already normalized) in the connect entry's extra_params.
// Prefer the user-entered one so the saved resource matches the exchange.
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) {
let instanceValue = extra_params.find(([key, _]) => key === instanceKey)?.[1] ?? ''
if (ccInstance.trim()) {
const stripSuffix = connectTemplate.strip_suffix as string | undefined
let v = ccInstance
.trim()
.replace(/^https?:\/\//, '')
.replace(/\/.*$/, '')
if (stripSuffix && v.endsWith(stripSuffix)) {
v = v.slice(0, -stripSuffix.length)
}
instanceValue = v.replace(/\.+$/, '')
}
if (instanceValue) {
for (const [argField, valueTemplate] of Object.entries(
connectTemplate.resource_mapping as Record<string, string>
)) {
args[argField] = valueTemplate.replaceAll('{instance}', found[1])
args[argField] = valueTemplate.replaceAll('{instance}', instanceValue)
}
}
}
@@ -526,13 +726,18 @@
accountData.scopes = scopes
}
// Add client credentials if using client_credentials flow
if (useClientCredentials) {
// Client-credentials accounts are self-contained: the refresh worker
// re-exchanges using only what is stored on the account row. With
// shared instance credentials the backend copies them onto the row,
// so nothing is sent from here.
if (useClientCredentials && !useSharedInstanceCreds) {
accountData.cc_client_id = clientId.trim()
accountData.cc_client_secret = clientSecret.trim()
// Add token URL override if provided
if (tokenUrl.trim()) {
accountData.cc_token_url = tokenUrl.trim()
// Instance-templated providers send an instance name; the backend
// resolves and stores the host-pinned token URL. Other registry
// providers need nothing more (token URL comes from the registry).
if (ccInstanceMeta) {
accountData.cc_instance = ccInstance.trim()
}
}
@@ -657,7 +862,7 @@
<SearchItems
{filter}
items={connects
? connects.map((key) => ({
? connects.filter(isSharedConnect).map((key) => ({
key
}))
: undefined}
@@ -671,17 +876,18 @@
f={(x) => x.key}
/>
{#if step == 1}
<div class="w-12/12 pb-2 flex flex-row my-1 gap-1">
<input
type="text"
placeholder="Search resource type"
bind:value={filter}
class="text-2xl grow"
id="search-resource-type"
/>
<div class="pb-2 my-1">
<div class="relative w-full">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 text-tertiary" size={14} />
<TextInput
inputProps={{ placeholder: 'Search resource type', id: 'search-resource-type' }}
bind:value={filter}
class="pl-7 text-xs w-full"
/>
</div>
</div>
<h2 class="mb-4 text-sm font-semibold text-emphasis">OAuth APIs</h2>
<h2 class="mb-4 text-sm font-semibold text-emphasis">Instance-configured OAuth APIs</h2>
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center">
{#if filteredConnects}
{#each filteredConnects as { key }}
@@ -693,6 +899,7 @@
manual = false
connectClient = key
resourceType = stripSandboxSuffix(key)
resetClientCredentialsState()
next()
}}
>
@@ -705,10 +912,10 @@
{/each}
{/if}
</div>
{#if connects && connects.length == 0}
{#if connects && connects.filter(isSharedConnect).length == 0}
<div class="text-secondary text-xs w-full"
>No OAuth APIs has been setup on the instance. To add oauth APIs, first sync the resource
types with the hub, then add oauth configuration. See <a
>No OAuth APIs have been set up on this instance. To add OAuth APIs, first sync the resource
types with the hub, then add OAuth configuration. See <a
href="https://www.windmill.dev/docs/misc/setup_oauth">documentation</a
>
</div>
@@ -718,7 +925,7 @@
{#if connectsManual && connectsManual?.length < 10}
<div class="text-secondary text-xs p-2">
Resource Types have not been synced with the hub
Resource types have not been synced with the hub
</div>
{/if}
@@ -730,12 +937,7 @@
unifiedSize="md"
variant="default"
selected={key === resourceType}
on:click={() => {
manual = true
connectClient = key
resourceType = key
next()
}}
on:click={() => selectFromOthers(key)}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
@@ -749,16 +951,10 @@
<Button
aiId={`app-connect-inner-${key}`}
aiDescription={`Connect to ${key}`}
size="sm"
unifiedSize="md"
variant="default"
color={key === resourceType ? 'blue' : 'light'}
btnClasses={key === resourceType ? '!border-2' : 'm-[1px]'}
on:click={() => {
manual = true
connectClient = key
resourceType = key
next()
}}
selected={key === resourceType}
on:click={() => selectFromOthers(key)}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
@@ -867,6 +1063,14 @@
<SyncResourceTypes onSynced={getResourceTypeInfo} />
</div>
{/if}
{#if registryCcCapable()}
<button
onclick={() => enableClientCredentials()}
class="text-xs font-normal text-accent w-fit -mt-4"
>
Acquire the token automatically via client credentials instead
</button>
{/if}
{#key resourceTypeInfo}
<ApiConnectForm
bind:linkedSecrets
@@ -889,12 +1093,17 @@
>Create a resource backed by an OAuth connection, whose token is fetched from the
external services and refreshed automatically if needed before expiration.</div
>
<button
onclick={() => (manual = true)}
class="text-xs font-normal text-accent w-fit mt-2"
>
Create resource manually instead
</button>
{#if ccBringYourOwn}
<button
onclick={() => {
manual = true
useClientCredentials = false
}}
class="text-xs font-normal text-accent w-fit mt-2"
>
Create resource manually instead
</button>
{/if}
</div>
{#if resourceTypeInfo?.description}
@@ -909,26 +1118,40 @@
<LabelsInput bind:labels class="-mt-5" />
{#if supportsClientCredentials}
<div>
<h3 class="text-sm font-semibold text-emphasis mb-1">Authentication Method</h3>
<div class="flex items-center gap-2 mb-2">
<input
type="checkbox"
style="width: 16px; height: 16px; margin: 0;"
bind:checked={useClientCredentials}
id="useClienCrediential"
/>
<label for="useClienCrediential" class="text-xs font-semibold text-emphasis"
>Use Client Credentials Flow</label
>
<Tooltip>
Server-to-server authentication without user interaction.
<br /><br />
Provide your own OAuth client credentials for this resource.
</Tooltip>
</div>
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis mb-1">Authentication</h3>
{#if ccOnly || ccBringYourOwn}
<div class="text-xs text-secondary font-normal mb-2">
{#if useSharedInstanceCreds}
{resourceType} connects server-to-server using the credentials configured for this
instance. The token is acquired and refreshed automatically.
{:else}
{resourceType} connects server-to-server. Enter a client ID and secret; the token is
acquired and refreshed automatically.
{/if}
</div>
{:else}
<div class="flex flex-col gap-2 mb-2">
<RadioCard
label={`Sign in through ${resourceType}`}
description="Opens a browser window to log in and authorize. Connects as you."
selected={!useClientCredentials}
onSelect={selectAuthCodeGrant}
/>
<RadioCard
label={useSharedInstanceCreds
? 'Use the configured instance credentials'
: 'Use a client ID and secret'}
description={useSharedInstanceCreds
? "Runs server-to-server with this instance's credentials. No input needed."
: 'Runs server-to-server. Best for automation or service accounts.'}
selected={useClientCredentials}
onSelect={() => enableClientCredentials()}
/>
</div>
{/if}
{#if useClientCredentials}
{#if useClientCredentials && !useSharedInstanceCreds}
<form class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Client ID</span>
@@ -938,7 +1161,7 @@
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Client Secret</span>
<span class="text-xs font-semibold text-emphasis">Client secret</span>
<TextInput
inputProps={{
type: 'password',
@@ -948,22 +1171,19 @@
bind:value={clientSecret}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis"
>Token URL Override (Optional)</span
>
<div class="text-xs text-primary font-normal">
Override the instance-level token URL for this resource
</div>
<TextInput
inputProps={{
type: 'url',
placeholder: 'Custom token endpoint URL',
required: false
}}
bind:value={tokenUrl}
/>
</label>
{#if ccInstanceMeta}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">{ccInstanceMeta.label}</span>
<div class="text-xs text-secondary font-normal">
Used to build this provider's token endpoint, stored with the connection for
automatic token refresh
</div>
<TextInput
inputProps={{ placeholder: ccInstanceMeta.placeholder, required: true }}
bind:value={ccInstance}
/>
</label>
{/if}
</form>
{/if}
</div>
+104 -43
View File
@@ -18,6 +18,8 @@
import { capitalize, type Item } from '$lib/utils'
import ClipboardPanel from './details/ClipboardPanel.svelte'
import Toggle from './Toggle.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import DropdownV2 from './DropdownV2.svelte'
import { APP_TO_ICON_COMPONENT } from './icons'
import { ExternalLink, Plus, Circle, X } from 'lucide-svelte'
@@ -100,6 +102,10 @@
// 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.
// Every per-instance templated provider gets a settings tile + instance input:
// authorization-code ones (ServiceNow) provide an `auth_url`, client-credentials-only
// ones (Coupa) provide only a `token_url`. The admin enters their instance host so
// the shared credentials point at the right endpoint.
const connectConfigTemplates: Record<string, any> = Object.fromEntries(
Object.entries(oauthConnectRegistry)
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg)
@@ -112,6 +118,55 @@
...windmillBuiltinsTemplated
]
/** Resolve a `<name>_sandbox` key to its parent registry entry (sandbox
* variants inherit the parent's grant_types), matching the connect dialog. */
function canonicalRegistryKey(name: string): string {
return name.endsWith('_sandbox') ? name.slice(0, -'_sandbox'.length) : name
}
/** The static registry declares client credentials for this provider */
function registryCcCapable(name: string): boolean {
return (
(oauthConnectRegistry as Record<string, any>)[
canonicalRegistryKey(name)
]?.grant_types?.includes('client_credentials') ?? false
)
}
/** The static registry supports authorization code for this provider. A
* provider with no explicit grant_types defaults to authorization code. */
function registryAuthCodeCapable(name: string): boolean {
const reg = (oauthConnectRegistry as Record<string, any>)[canonicalRegistryKey(name)]
if (!reg) return false
return reg.grant_types ? reg.grant_types.includes('authorization_code') : true
}
/** Built-in provider that only supports client credentials (e.g. Coupa): no
* authorization-code flow to choose, so the grant is fixed. */
function registryCcOnly(name: string): boolean {
return registryCcCapable(name) && !registryAuthCodeCapable(name)
}
/** Map the entry's grant_types to the single-select choice (so the segmented
* control always has exactly one selected and can never be empty) */
function grantChoice(name: string): string {
const gts = oauths?.[name]?.['grant_types'] ?? ['authorization_code']
const cc = gts.includes('client_credentials')
const ac = gts.includes('authorization_code')
if (cc && ac) return 'both'
if (cc) return 'client_credentials'
return 'authorization_code'
}
/** Set the grant types from the segmented choice. The instance credentials are
* then used for every selected grant — authorization-code popup and/or
* server-to-server. */
function setGrantChoice(name: string, choice: string) {
if (!oauths || !oauths[name]) return
oauths[name]['grant_types'] =
choice === 'both' ? ['authorization_code', 'client_credentials'] : [choice]
}
let showCustomOAuthForm = $state(false)
let customOAuthName = $state('')
let customNameInput = $state<HTMLInputElement>()
@@ -125,7 +180,11 @@
if (oauths && name) {
// Create a new object to ensure the new item is added at the end
const newOauths = { ...oauths }
newOauths[name] = { id: '', secret: '', grant_types: ['authorization_code'] }
newOauths[name] = {
id: '',
secret: '',
grant_types: registryCcOnly(name) ? ['client_credentials'] : ['authorization_code']
}
oauths = newOauths
dropdownOpen = false
}
@@ -463,49 +522,51 @@
bind:password={oauths[k]['secret']}
/>
</label>
{#if k === 'visma' || !windmillBuiltins.includes(k)}
<div class="mb-8">
<div style="display: flex; align-items: center; gap: 8px;">
<input
type="checkbox"
style="width: 16px; height: 16px; margin: 0;"
checked={oauths?.[k]?.['grant_types']?.includes('client_credentials') ??
false}
onchange={(e) => {
const target = e.target as HTMLInputElement
if (oauths && oauths[k]) {
if (!oauths[k]['grant_types']) {
oauths[k]['grant_types'] = ['authorization_code']
}
if (target.checked) {
if (!oauths[k]['grant_types'].includes('client_credentials')) {
oauths[k]['grant_types'] = [
...oauths[k]['grant_types'],
'client_credentials'
]
}
} else {
oauths[k]['grant_types'] = oauths[k]['grant_types'].filter(
(gt: string) => gt !== 'client_credentials'
)
}
}
}}
/>
<span class="text-xs font-semibold text-emphasis"
>Support Client Credentials Flow</span
<div class="flex flex-col gap-2 mb-2">
<span class="text-xs font-semibold text-emphasis">These credentials are for</span>
{#if !windmillBuiltins.includes(k) || (registryCcCapable(k) && registryAuthCodeCapable(k))}
<ToggleButtonGroup
selected={grantChoice(k)}
onSelected={(v) => setGrantChoice(k, v)}
>
{#snippet children({ item })}
<ToggleButton
value="authorization_code"
label="Authorization code"
showTooltipIcon
tooltip="Users sign in through a browser popup using this app's Client ID and Secret."
{item}
/>
<ToggleButton
value="client_credentials"
label="Client credentials"
showTooltipIcon
tooltip={`Server-to-server. Fill Client ID and Secret to share one service account for every connection, or leave them empty so each user brings their own.${!windmillBuiltins.includes(k) ? ' A Token URL is required below.' : ''}`}
{item}
/>
<ToggleButton
value="both"
label="Both"
showTooltipIcon
tooltip="Offer both flows; the same Client ID and Secret are used for each selected grant."
{item}
/>
{/snippet}
</ToggleButtonGroup>
{:else if registryCcCapable(k)}
<span class="text-xs text-secondary font-normal flex items-center gap-1">
Client credentials (server-to-server)
<Tooltip
>Fill Client ID and Secret to share one service account, or leave them empty
so each user brings their own.</Tooltip
>
<Tooltip>
Enables server-to-server authentication without user interaction. Use for
automated scripts and background jobs.
<br /><br />
When enabled, users can provide their own client credentials at the resource
level. The Client ID and Secret configured above are only used for the traditional
OAuth flow (popup window).
</Tooltip>
</div>
</div>
{/if}
</span>
{:else}
<span class="text-xs text-secondary font-normal"
>Authorization code (browser sign-in)</span
>
{/if}
</div>
{#if k === 'azure_oauth'}
<AzureOauthSettings bind:connect_config={oauths[k]['connect_config']} />
{:else if !windmillBuiltins.includes(k) && k != 'slack'}
@@ -1,12 +1,12 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { run } from 'svelte/legacy'
import OauthExtraParams from './OauthExtraParams.svelte'
import OauthScopes from './OauthScopes.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
let { connect_config = $bindable() } = $props();
let { connect_config = $bindable() } = $props()
run(() => {
if (!connect_config) {
@@ -19,12 +19,17 @@
extra_params_callback: {}
}
}
});
})
</script>
<div class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Auth URL</span>
<span class="text-emphasis font-semibold text-xs"
>Auth URL <Tooltip
>Leave empty for providers that only support the client credentials flow: the provider is
then offered exclusively with server-to-server authentication.</Tooltip
></span
>
<input
type="text"
placeholder="https://github.com/login/oauth/authorize"
@@ -279,6 +279,10 @@
// 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.
// Every per-instance templated provider is configurable here: authorization-code
// ones (ServiceNow, Snowflake) provide an `auth_url`, client-credentials-only
// ones (Coupa) provide only a `token_url`. Both need the admin to enter their
// instance host so the shared credentials point at the right endpoint.
const connectConfigTemplates: Record<string, any> = Object.fromEntries(
Object.entries(oauthConnectRegistry)
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg)
@@ -309,7 +313,11 @@
if (oauths[name].connect_config?.extra_params?.[key] === v) continue
oauths[name].connect_config = {
scopes: [],
auth_url: tmpl.auth_url.replaceAll('{instance}', v),
// CC-only templated providers have no auth_url; store an empty string
// (not omitted) so the instance-config parser still types the entry.
// The backend treats an empty auth_url as the unused placeholder for
// the client-credentials grant.
auth_url: tmpl.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 },
@@ -16,6 +16,7 @@ export { default as TabContent } from './tabs/TabContent.svelte'
export { default as Tabs } from './tabs/Tabs.svelte'
export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte'
export { default as FileInput } from './fileInput/FileInput.svelte'
export { default as RadioCard } from './radioCard/RadioCard.svelte'
export { default as Section } from '../Section.svelte'
export { default as Url } from './Url.svelte'
@@ -0,0 +1,59 @@
<script lang="ts">
import { Circle, CircleDot } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { Snippet } from 'svelte'
let {
label,
description = undefined,
selected = false,
onSelect,
disabled = false,
icon = undefined,
class: className = ''
}: {
/** Title shown in bold at the top of the card */
label: string
/** Optional supporting line under the label */
description?: string
/** Whether this card is the selected option */
selected?: boolean
/** Called when the card is clicked */
onSelect: () => void
disabled?: boolean
/** Optional leading icon, rendered after the radio */
icon?: Snippet
class?: string
} = $props()
</script>
<button
type="button"
{disabled}
onclick={onSelect}
class={twMerge(
'w-full text-left rounded-md border p-3 transition-colors',
selected
? 'border-border-selected bg-surface-selected'
: 'border-border-light hover:bg-surface-hover',
disabled && 'opacity-50 cursor-not-allowed',
className
)}
>
<div class="flex items-start gap-2">
{#if selected}
<CircleDot size={16} class="text-accent shrink-0 mt-0.5" />
{:else}
<Circle size={16} class="text-hint shrink-0 mt-0.5" />
{/if}
{#if icon}
<div class="shrink-0 mt-0.5">{@render icon()}</div>
{/if}
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold text-emphasis">{label}</div>
{#if description}
<div class="text-xs font-normal text-secondary mt-0.5">{description}</div>
{/if}
</div>
</div>
</button>