From e26a9239a62a25abf90ef06ade4dde7f36e791bb Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 17 Jun 2026 18:32:01 +0200 Subject: [PATCH] feat: zero-setup oauth client credentials for registry providers (#9559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: zero-setup oauth client credentials for registry-declared providers Co-Authored-By: Claude Fable 5 * feat: support client-credentials-only custom oauth providers Co-Authored-By: Claude Fable 5 * feat: add coupa client credentials provider to oauth registry Co-Authored-By: Claude Fable 5 * feat: clarify oauth resource connect auth-method selection Co-Authored-By: Claude Fable 5 * 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 * 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 * feat: bring-your-own oauth credentials from the others section Co-Authored-By: Claude Fable 5 * feat: segmented oauth grant-type selector, always show grant Co-Authored-By: Claude Fable 5 * 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 * feat: hide create-manually link on the managed oauth connect path Co-Authored-By: Claude Fable 5 * 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 * 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 * style: remove redundant grant-type tags from oauth auth cards Co-Authored-By: Claude Fable 5 * 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 * 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 * fix: restrict client-credentials token url to caller-supplied creds Co-Authored-By: Claude Opus 4.8 (1M context) * fix: resolve client-credentials id and secret all-or-nothing Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * 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) * 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) * 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) * feat: cc_scopes defaults and instance config for client credentials Co-Authored-By: Claude Opus 4.8 (1M context) * fix: store empty auth_url for cc-only templated oauth providers Co-Authored-By: Claude Opus 4.8 (1M context) * fix: address review nits - sandbox key lookup, template doc, deref specs Co-Authored-By: Claude Opus 4.8 (1M context) * fix: default shared client-credentials connect to cc_scopes Co-Authored-By: Claude Opus 4.8 (1M context) * feat: support bring-your-own client credentials for instance-configured providers Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: move oauth grant-type help into per-option tooltips Co-Authored-By: Claude Opus 4.8 (1M context) * fix: keep instance-configured oauth providers selectable from Others Co-Authored-By: Claude Opus 4.8 (1M context) * fix: preserve admin-configured scopes for custom client-credentials providers Co-Authored-By: Claude Opus 4.8 (1M context) * fix: use cc scopes on cc refresh and enforce cc grant for bring-your-own Co-Authored-By: Claude Opus 4.8 (1M context) * fix: require {instance} in leftmost host label for cc token url templates Co-Authored-By: Claude Opus 4.8 (1M context) * fix: drop token_url from unauthenticated get_connect response Co-Authored-By: Claude Opus 4.8 (1M context) * fix: fill byo templated resource args from the entered instance Co-Authored-By: Claude Opus 4.8 (1M context) * 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 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/oauth_connect.json | 28 +- backend/windmill-api/openapi-deref.json | 73 ++- backend/windmill-api/openapi-deref.yaml | 97 ++- backend/windmill-api/openapi.yaml | 34 +- backend/windmill-oauth/src/lib.rs | 589 +++++++++++++++--- .../src/lib/components/ApiConnectForm.svelte | 5 +- .../src/lib/components/AppConnectInner.svelte | 466 ++++++++++---- .../src/lib/components/AuthSettings.svelte | 147 +++-- .../src/lib/components/CustomOauth.svelte | 13 +- .../lib/components/InstanceSettings.svelte | 10 +- frontend/src/lib/components/common/index.ts | 1 + .../common/radioCard/RadioCard.svelte | 59 ++ 13 files changed, 1221 insertions(+), 303 deletions(-) create mode 100644 frontend/src/lib/components/common/radioCard/RadioCard.svelte diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index fa76836c89..e8927cee6d 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -97b5cb2096d3a9b4818943c5abf181d914cb4e99 +136f4634aca61e74ccb045372358a1e3f6b23e75 diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 87cddda16b..0b9a19b873 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -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", diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 0cf2e9aca0..135131af41 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -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" + ] } } }, diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 22792b38e3..e45131d81b 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -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 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c2229de36f..119f47f96b 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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: diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 252f7ace28..bf1ea659aa 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -67,6 +67,13 @@ pub struct ClientWithScopes { pub allowed_domains: Option>, pub userinfo_url: Option, pub grant_types: Vec, + /// 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, pub scopes: Option>, + /// 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>, pub extra_params: Option>, pub extra_params_callback: Option>, pub req_body_auth: Option, @@ -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, - /// 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, } @@ -111,11 +127,13 @@ pub struct OAuthSandboxOverride { pub userinfo_url: Option, } -/// Frontend metadata for a per-instance OAuth provider. The instance-settings -/// UI renders one generic instance-name input and substitutes `{instance}` into +/// 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, - 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, pub token_url: String, #[serde(skip_serializing_if = "Option::is_none")] pub req_body_auth: Option, @@ -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 { @@ -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 = 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::>(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> { + let static_configs = + serde_json::from_str::>(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 { - 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)>> { + 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 = 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 { + let Some(cfg) = serde_json::from_str::>(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 { + 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::>(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 = 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>, http_client: &reqwest::Client, scopes: Option<&[String]>, ) -> Result { @@ -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 { - 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()); + } } diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 7d10db1d48..dd02a3c578 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -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() { diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 4872629cc1..1046ee69b0 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -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 = $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 + // 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)[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)[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 )) { - 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 @@ ({ + ? connects.filter(isSharedConnect).map((key) => ({ key })) : undefined} @@ -671,17 +876,18 @@ f={(x) => x.key} /> {#if step == 1} -
- +
+
+ + +
-

OAuth APIs

+

Instance-configured OAuth APIs

{#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}
- {#if connects && connects.length == 0} + {#if connects && connects.filter(isSharedConnect).length == 0}
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 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 documentation
@@ -718,7 +925,7 @@ {#if connectsManual && connectsManual?.length < 10}
- Resource Types have not been synced with the hub + Resource types have not been synced with the hub
{/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)} > @@ -749,16 +951,10 @@ @@ -867,6 +1063,14 @@
{/if} + {#if registryCcCapable()} + + {/if} {#key resourceTypeInfo} Create a resource backed by an OAuth connection, whose token is fetched from the external services and refreshed automatically if needed before expiration. - + {#if ccBringYourOwn} + + {/if} {#if resourceTypeInfo?.description} @@ -909,26 +1118,40 @@ {#if supportsClientCredentials} -
-

Authentication Method

-
- - - - Server-to-server authentication without user interaction. -

- Provide your own OAuth client credentials for this resource. -
-
+
+

Authentication

+ {#if ccOnly || ccBringYourOwn} +
+ {#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} +
+ {:else} +
+ + enableClientCredentials()} + /> +
+ {/if} - {#if useClientCredentials} + {#if useClientCredentials && !useSharedInstanceCreds}
- + {#if ccInstanceMeta} + + {/if}
{/if}
diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index 335b64277c..2ad6ab917a 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -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 = Object.fromEntries( Object.entries(oauthConnectRegistry) .filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg) @@ -112,6 +118,55 @@ ...windmillBuiltinsTemplated ] + /** Resolve a `_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)[ + 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)[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() @@ -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']} /> - {#if k === 'visma' || !windmillBuiltins.includes(k)} -
-
- { - 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' - ) - } - } - }} - /> - Support Client Credentials Flow + These credentials are for + {#if !windmillBuiltins.includes(k) || (registryCcCapable(k) && registryAuthCodeCapable(k))} + setGrantChoice(k, v)} + > + {#snippet children({ item })} + + + + {/snippet} + + {:else if registryCcCapable(k)} + + Client credentials (server-to-server) + Fill Client ID and Secret to share one service account, or leave them empty + so each user brings their own. - - Enables server-to-server authentication without user interaction. Use for - automated scripts and background jobs. -

- 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). -
-
-
- {/if} + + {:else} + Authorization code (browser sign-in) + {/if} +
{#if k === 'azure_oauth'} {:else if !windmillBuiltins.includes(k) && k != 'slack'} diff --git a/frontend/src/lib/components/CustomOauth.svelte b/frontend/src/lib/components/CustomOauth.svelte index 432a02152b..942dc553d7 100644 --- a/frontend/src/lib/components/CustomOauth.svelte +++ b/frontend/src/lib/components/CustomOauth.svelte @@ -1,12 +1,12 @@