diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 44b23b28b7..20006c2a99 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -f806a793b07105417dd7a592fbfc93ec2f226d4e +5517ff637a675aee66d4e417d3f37266045f0e0c diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 611e3f7c17..ecb4f95166 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -343,6 +343,10 @@ "quickbooks": { "auth_url": "https://appcenter.intuit.com/connect/oauth2", "token_url": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", + "connect_fields": { + "realmId": "callback.realmId" + }, + "resource_fields": ["realmId", "isSandBox"], "scopes": ["com.intuit.quickbooks.accounting"] }, "visma": { @@ -468,15 +472,26 @@ "docusign": { "auth_url": "https://account.docusign.com/oauth/auth", "token_url": "https://account.docusign.com/oauth/token", + "userinfo_url": "https://account.docusign.com/oauth/userinfo", + "connect_fields": { + "account_id": "userinfo.accounts[is_default=true].account_id", + "base_uri": "userinfo.accounts[is_default=true].base_uri" + }, + "resource_fields": ["account_id", "base_uri"], "scopes": ["signature"], "sandbox": { "auth_url": "https://account-d.docusign.com/oauth/auth", - "token_url": "https://account-d.docusign.com/oauth/token" + "token_url": "https://account-d.docusign.com/oauth/token", + "userinfo_url": "https://account-d.docusign.com/oauth/userinfo" } }, "salesforce": { "auth_url": "https://login.salesforce.com/services/oauth2/authorize", "token_url": "https://login.salesforce.com/services/oauth2/token", + "connect_fields": { + "instance_url": "token.instance_url" + }, + "resource_fields": ["instance_url"], "scopes": ["api", "refresh_token", "offline_access"], "sandbox": { "auth_url": "https://test.salesforce.com/services/oauth2/authorize", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2398eb368a..36b170b22d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -8837,6 +8837,11 @@ paths: type: string state: type: string + params: + type: object + description: The callback URL's other query params, which the provider's `connect_fields` may read (QuickBooks' `realmId`) + additionalProperties: + type: string required: - code - state @@ -37929,6 +37934,13 @@ components: type: string grant_type: type: string + resource_fields: + type: object + additionalProperties: {} + description: > + Resource fields the provider's `connect_fields` picked on connect + (Salesforce's instance_url, Docusign's account_id and base_uri, + QuickBooks' realmId), for the connect dialog to prefill. required: - access_token diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 3b32f0436f..57fe97eac2 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -66,6 +66,8 @@ pub struct ClientWithScopes { pub extra_params_callback: Option>, pub allowed_domains: Option>, pub userinfo_url: Option, + /// See [`OAuthConfig::connect_fields`]. + pub connect_fields: Option>, pub grant_types: Vec, pub token_response_path: Option, /// Resolved token endpoint, exposed so the connect dialog can prefill and @@ -88,6 +90,15 @@ pub struct OAuthConfig { #[serde(default = "empty_string")] pub token_url: String, pub userinfo_url: Option, + /// Resource fields the connect callback fills from what the provider returns, + /// as field -> path (see [`connect_resource_fields`]). A path starts with its + /// source: `token.` (the raw token response, before `token_response_path` + /// unwraps it), `userinfo.` (`userinfo_url`'s response, fetched only when a + /// path needs it) or `callback.` (the callback URL's query params). The browser + /// relays `callback.` values, so they only prefill the user's own resource: + /// nothing server-side may trust them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connect_fields: Option>, /// The registry JSON may also carry frontend-only keys for the connect /// dialog, deliberately not modelled here: `scope_options`, a scope pick /// list, `resource_fields`, the fields of the resource type the dialog @@ -353,6 +364,10 @@ pub struct SlackTeam { pub struct OAuthCallback { pub code: String, pub state: String, + /// The callback URL's other query params (QuickBooks' `realmId`), read by + /// `connect_fields` paths under `callback.`. + #[serde(default)] + pub params: HashMap, } /// Build a basic OAuth client from configuration @@ -902,6 +917,48 @@ pub fn parse_token_response( }) } +/// What the connect callback returns: the token, and the resource fields its +/// `connect_fields` picked, which the connect dialog prefills. +#[derive(Debug, Serialize)] +pub struct ConnectCallbackResponse { + #[serde(flatten)] + pub token: TokenResponse, + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub resource_fields: HashMap, +} + +/// Picks each field's value out of `sources` (`{token, userinfo, callback}`). A path +/// is dot-separated keys, and a `key[k=v]` segment takes the element of array `key` +/// whose `k` is `v` (`userinfo.accounts[is_default=true].base_uri`). Only the listed +/// paths leave the server, and fields whose path misses are left out. +pub fn connect_resource_fields( + sources: &serde_json::Value, + fields: &HashMap, +) -> HashMap { + fn get<'a>(v: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { + path.split('.') + .try_fold(v, |v, seg| match seg.split_once('[') { + Some((key, filter)) => { + let (k, want) = filter.strip_suffix(']')?.split_once('=')?; + v.get(key)?.as_array()?.iter().find(|e| match e.get(k) { + Some(serde_json::Value::String(s)) => s == want, + Some(other) => other.to_string() == want, + None => false, + }) + } + None => v.get(seg), + }) + } + fields + .iter() + .filter_map(|(field, path)| { + get(sources, path) + .filter(|v| !v.is_null()) + .map(|v| (field.clone(), v.clone())) + }) + .collect() +} + /// Pre-fetched account fields needed for token refresh. pub struct OAuthAccountInfo { pub client: String, @@ -1260,11 +1317,50 @@ mod tests { assert_eq!(&*token.access_token, "xoxe.xoxp-new"); } + #[test] + fn connect_fields_pick_from_each_source() { + let registry: HashMap = + serde_json::from_str(include_str!("../../oauth_connect.json")).unwrap(); + let fields = |p: &str| registry[p].connect_fields.clone().unwrap(); + + let docusign = serde_json::json!({"userinfo": {"accounts": [ + {"account_id": "a1", "is_default": false, "base_uri": "https://na2.docusign.net"}, + {"account_id": "a2", "is_default": true, "base_uri": "https://demo.docusign.net"} + ]}}); + let got = connect_resource_fields(&docusign, &fields("docusign")); + assert_eq!(got["account_id"], "a2"); + assert_eq!(got["base_uri"], "https://demo.docusign.net"); + let no_account = serde_json::json!({"userinfo": {"accounts": []}}); + assert!(connect_resource_fields(&no_account, &fields("docusign")).is_empty()); + + let salesforce = serde_json::json!({"token": { + "access_token": "t", "instance_url": "https://acme.my.salesforce.com", "id_token": "x" + }}); + let want = HashMap::from([( + "instance_url".to_string(), + serde_json::json!("https://acme.my.salesforce.com"), + )]); + assert_eq!( + connect_resource_fields(&salesforce, &fields("salesforce")), + want + ); + let sandbox = registry["salesforce"].as_sandbox().unwrap(); + assert_eq!(sandbox.connect_fields, registry["salesforce"].connect_fields); + + let quickbooks = serde_json::json!({"callback": {"realmId": "9130"}}); + let got = connect_resource_fields(&quickbooks, &fields("quickbooks")); + assert_eq!( + got, + HashMap::from([("realmId".to_string(), serde_json::json!("9130"))]) + ); + } + fn sample_oauth_config(with_sandbox: bool) -> OAuthConfig { OAuthConfig { auth_url: "https://account.example.com/oauth/auth".to_string(), token_url: "https://account.example.com/oauth/token".to_string(), userinfo_url: Some("https://account.example.com/userinfo".to_string()), + connect_fields: None, scopes: Some(vec!["signature".to_string()]), cc_scopes: None, extra_params: None, diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index d29d97a1dc..81dd5225fb 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -185,7 +185,6 @@ * 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('') let description = $state('') let labels: string[] | undefined = $state(undefined) @@ -672,7 +671,7 @@ resourceType = stripSandboxSuffix(connectClient) value = data.res.access_token! valueToken = data.res - responseExtra = data.extra ?? {} + Object.assign(args, data.res.resource_fields ?? {}) fillGeneratedDescription() step = 4 // `fillPath` decides the path as surely as express does, so neither stops here. @@ -959,9 +958,6 @@ } } } - if (resourceType === 'quickbooks' && responseExtra['realmId']) { - args['realmId'] = responseExtra['realmId'] - } let accountData: any = undefined if (valueToken?.expires_in != undefined) { diff --git a/frontend/src/routes/(styled)/oauth/callback/[client_name]/+page.svelte b/frontend/src/routes/(styled)/oauth/callback/[client_name]/+page.svelte index bfb6a9b10b..f91e26c976 100644 --- a/frontend/src/routes/(styled)/oauth/callback/[client_name]/+page.svelte +++ b/frontend/src/routes/(styled)/oauth/callback/[client_name]/+page.svelte @@ -29,16 +29,16 @@ // goto('/resources') } else if (code && state) { try { - const extraParams = Object.fromEntries( + const params = Object.fromEntries( Array.from(Object.entries(urlParamsToObject(page.url.searchParams))).filter( ([key]) => key !== 'code' && key !== 'state' && key !== 'error' ) ) const res = await OauthService.connectCallback({ clientName: client_name ?? '', - requestBody: { code, state } + requestBody: { code, state, params } }) - const message = { type: 'success', res, resource_type: client_name, extra: extraParams } + const message = { type: 'success', res, resource_type: client_name } sendUserToast('successful', false) if (window.opener) { console.log('Sending oauth popup message')