mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
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:
co-authored by
Claude Opus 4.8
windmill-internal-app[bot]
parent
ba69d8147b
commit
e26a9239a6
@@ -1 +1 @@
|
||||
97b5cb2096d3a9b4818943c5abf181d914cb4e99
|
||||
136f4634aca61e74ccb045372358a1e3f6b23e75
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(®istry, "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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user