diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 2ccace33b6..c2c253e8f0 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -b469efc9ceddfaaa1040e4cb2c18993822f92c78 +ff4d04f17721d84fb9a1f655af50ff7e4e7dcbc7 diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 1a94723eb4..0509e7f30a 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -18,7 +18,32 @@ "slack": { "auth_url": "https://slack.com/oauth/v2/authorize", "token_url": "https://slack.com/api/oauth.v2.access", - "scopes": ["chat:write", "chat:write.public", "channels:join", "files:write"] + "scopes": [ + "chat:write", + "chat:write.public", + "channels:join", + "files:write", + "users:read", + "users:read.email", + "channels:read", + "channels:history" + ], + "user_scopes": [ + "channels:history", + "groups:history", + "im:history", + "mpim:history", + "search:read", + "users:read", + "chat:write", + "users:read.email", + "channels:read", + "groups:read", + "im:read", + "mpim:read", + "files:read" + ], + "token_response_path": "authed_user" }, "supabase_wizard": { "auth_url": "https://api.supabase.com/v1/oauth/authorize", diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index a8a8e83883..d37ab44742 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -68,6 +68,7 @@ pub struct ClientWithScopes { pub allowed_domains: Option>, pub userinfo_url: Option, pub grant_types: Vec, + pub token_response_path: Option, /// Resolved token endpoint, exposed so the connect dialog can prefill and /// persist it on client-credentials accounts. pub token_url: String, @@ -88,10 +89,11 @@ pub struct OAuthConfig { #[serde(default = "empty_string")] pub token_url: String, pub userinfo_url: Option, - /// The registry JSON may also carry two frontend-only keys for the connect + /// The registry JSON may also carry frontend-only keys for the connect /// dialog, deliberately not modelled here: `scope_options`, a scope pick - /// list, and `resource_fields`, the fields of the resource type the dialog - /// asks for once the token is in (Snowflake's database and warehouse). + /// list, `resource_fields`, the fields of the resource type the dialog + /// asks for once the token is in (Snowflake's database and warehouse), and + /// `user_scopes`, Slack's user-token scopes, sent as `user_scope`. pub scopes: Option>, /// Default scopes for the client-credentials (2-legged) flow. These differ /// from the authorization-code `scopes` for most providers (member/consent @@ -103,6 +105,11 @@ pub struct OAuthConfig { pub extra_params: Option>, pub extra_params_callback: Option>, pub req_body_auth: Option, + /// Key of a nested object holding the token when the response has none at + /// the top level: Slack v2 puts a user token (`user_scope`) under + /// `authed_user`, and a bot token, when one was asked for, at the top. + #[serde(skip_serializing_if = "Option::is_none")] + pub token_response_path: Option, #[serde(default = "default_grant_types")] pub grant_types: Vec, /// Optional URL overrides for the provider's sandbox environment. When @@ -793,6 +800,7 @@ pub async fn exchange_token( extra_params_callback: Option<&HashMap>, http_client: &reqwest::Client, scopes: Option<&[String]>, + token_response_path: Option<&str>, ) -> Result { let token_json = match grant_type { "authorization_code" | "" => { @@ -831,12 +839,24 @@ pub async fn exchange_token( } }; - let token = serde_json::from_value::(token_json.clone()).map_err(|e| { + parse_token_response(token_json, token_response_path) +} + +/// Deserialize a token endpoint response, reading the token from the object at +/// `token_response_path` when the top level carries none. +pub fn parse_token_response( + token_json: serde_json::Value, + token_response_path: Option<&str>, +) -> Result { + let token_json = match token_response_path.and_then(|p| token_json.get(p)) { + Some(nested) if token_json.get("access_token").is_none() => nested.clone(), + _ => token_json, + }; + serde_json::from_value::(token_json.clone()).map_err(|e| { Error::BadConfig(format!( "Error deserializing response as a new token: {e}\nresponse:{token_json}" )) - })?; - Ok(token) + }) } /// Pre-fetched account fields needed for token refresh. @@ -992,6 +1012,14 @@ pub async fn refresh_token_for_account<'c>( extra_params_callback.as_ref(), http_client, Some(effective_scopes), + oauth_client_info + .as_ref() + .and_then(|i| i.token_response_path.as_deref()) + .or_else(|| { + cc_config + .as_ref() + .and_then(|c| c.token_response_path.as_deref()) + }), ) .await; @@ -1207,6 +1235,30 @@ mod tests { ); } + #[test] + fn test_parse_token_response_nested() { + let bot = serde_json::json!({ + "ok": true, + "access_token": "xoxb-bot", + "authed_user": {"id": "U1"} + }); + let token = parse_token_response(bot, Some("authed_user")).unwrap(); + assert_eq!(&*token.access_token, "xoxb-bot"); + + let user = serde_json::json!({ + "ok": true, + "authed_user": {"access_token": "xoxp-user", "refresh_token": "xoxe-1", "expires_in": 43200} + }); + let token = parse_token_response(user, Some("authed_user")).unwrap(); + assert_eq!(&*token.access_token, "xoxp-user"); + assert_eq!(&*token.refresh_token.unwrap(), "xoxe-1"); + + let refresh = + serde_json::json!({"ok": true, "access_token": "xoxe.xoxp-new", "token_type": "user"}); + let token = parse_token_response(refresh, Some("authed_user")).unwrap(); + assert_eq!(&*token.access_token, "xoxe.xoxp-new"); + } + fn sample_oauth_config(with_sandbox: bool) -> OAuthConfig { OAuthConfig { auth_url: "https://account.example.com/oauth/auth".to_string(), @@ -1217,6 +1269,7 @@ mod tests { extra_params: None, extra_params_callback: None, req_body_auth: None, + token_response_path: None, grant_types: default_grant_types(), sandbox: with_sandbox.then(|| OAuthSandboxOverride { auth_url: Some("https://account-d.example.com/oauth/auth".to_string()), diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index cec2263801..ed9a27c0b8 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -201,6 +201,38 @@ */ let useClientCredentials = $state(false) + /** Slack v2 grants a user token for `user_scope` and a bot token for `scope`, + * from the same app: the registry's `user_scopes` offers the choice. */ + let useUserToken = $state(false) + /** Both kinds share one resource type, so the default path and description tell them apart. */ + let tokenKind = $derived( + registryEntry()?.user_scopes && !useClientCredentials + ? useUserToken + ? 'user' + : 'bot' + : undefined + ) + let defaultName = $derived(tokenKind ? `${resourceType}_${tokenKind}` : resourceType) + /** Last description filled in from `tokenKind`, replaced on reconnect unless the user edited it. */ + let generatedDescription = '' + + function fillGeneratedDescription() { + if (description === generatedDescription) { + description = '' + } + generatedDescription = tokenKind ? `${resourceType} ${tokenKind} token` : '' + if (emptyString(description)) { + description = generatedDescription + } + } + + function selectUserToken(user: boolean) { + if (user !== useUserToken) { + scopes = user ? (registryEntry()?.user_scopes ?? []) : instanceScopes + } + useUserToken = user + } + /** * Client credentials for resource-level OAuth */ @@ -293,6 +325,7 @@ function resetClientCredentialsState() { supportsClientCredentials = false useClientCredentials = false + useUserToken = false authCodeUnavailable = false ccInstanceConfigured = false ccBringYourOwn = false @@ -612,16 +645,18 @@ value = data.res.access_token! valueToken = data.res responseExtra = data.extra ?? {} + fillGeneratedDescription() step = 4 // `fillPath` decides the path as surely as express does, so neither stops here. if (fillPath || express) { - path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}` + path = fillPath ?? `u/${$userStore?.username}/${defaultName}_${new Date().getTime()}` next() } } } async function getScopesAndParams() { + useUserToken = false if (!connects?.includes(connectClient)) { // No instance OAuth client (registry-declared CC-only provider): // defaults come from the static registry instead. @@ -752,9 +787,10 @@ ...tokenResponse, grant_type: 'client_credentials' // Mark this token as client_credentials } + fillGeneratedDescription() step = 4 if (fillPath || express) { - path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}` + path = fillPath ?? `u/${$userStore?.username}/${defaultName}_${new Date().getTime()}` next() } } catch (error) { @@ -770,7 +806,11 @@ * Opens popup for user to authenticate with OAuth provider */ const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin) - url.searchParams.append('scopes', scopes.join('+')) + // An empty `scopes` still overrides the instance's bot scopes. + url.searchParams.append('scopes', useUserToken ? '' : scopes.join('+')) + if (useUserToken) { + url.searchParams.append('user_scope', scopes.join(',')) + } if (extra_params.length > 0) { extra_params.forEach(([key, value]) => url.searchParams.append(key, value)) } @@ -1561,6 +1601,26 @@ {/if} + {#if registryEntry()?.user_scopes && !useClientCredentials} +
+

Connect as

+
+ selectUserToken(false)} + /> + selectUserToken(true)} + /> +
+
+ {/if} +

Scopes

@@ -1577,7 +1637,7 @@