feat: let slack connects issue a user token via user_scope (#11452)

* feat: let slack connects issue a user token via user_scope

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: keep trailing newline in ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: bump ee-repo-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: name the provider in slack token descriptions and refresh them on reconnect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop the generated slack description when connecting another provider

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop the slack scope pin migration, slack ignores scope on refresh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: clear the generated slack description on client-credentials connects too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to ff4d04f17721d84fb9a1f655af50ff7e4e7dcbc7

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

Previous ee-repo-ref: 83298dcf23574d5ed05e6c767bf1d9b067ab7a95

New ee-repo-ref: ff4d04f17721d84fb9a1f655af50ff7e4e7dcbc7

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-10-01 14:53:22 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 Ruben Fiszel windmill-internal-app[bot]
parent 227e934210
commit d518aa5557
4 changed files with 150 additions and 12 deletions
+1 -1
View File
@@ -1 +1 @@
b469efc9ceddfaaa1040e4cb2c18993822f92c78
ff4d04f17721d84fb9a1f655af50ff7e4e7dcbc7
+26 -1
View File
@@ -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",
+59 -6
View File
@@ -68,6 +68,7 @@ pub struct ClientWithScopes {
pub allowed_domains: Option<Vec<String>>,
pub userinfo_url: Option<String>,
pub grant_types: Vec<String>,
pub token_response_path: Option<String>,
/// 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<String>,
/// 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<Vec<String>>,
/// 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<HashMap<String, String>>,
pub extra_params_callback: Option<HashMap<String, String>>,
pub req_body_auth: Option<bool>,
/// 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<String>,
#[serde(default = "default_grant_types")]
pub grant_types: Vec<String>,
/// Optional URL overrides for the provider's sandbox environment. When
@@ -793,6 +800,7 @@ pub async fn exchange_token(
extra_params_callback: Option<&HashMap<String, String>>,
http_client: &reqwest::Client,
scopes: Option<&[String]>,
token_response_path: Option<&str>,
) -> Result<TokenResponse, Error> {
let token_json = match grant_type {
"authorization_code" | "" => {
@@ -831,12 +839,24 @@ pub async fn exchange_token(
}
};
let token = serde_json::from_value::<TokenResponse>(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<TokenResponse, Error> {
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::<TokenResponse>(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()),
@@ -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 @@
</div>
{/if}
{#if registryEntry()?.user_scopes && !useClientCredentials}
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis mb-1">Connect as</h3>
<div class="flex flex-col gap-2" role="radiogroup" aria-label="Connect as">
<RadioCard
label="The app (bot token)"
description="Acts as the app's bot user, in the channels it is added to."
selected={!useUserToken}
onSelect={() => selectUserToken(false)}
/>
<RadioCard
label="Yourself (user token)"
description="Acts as you, with access to what you can see: history, DMs and search."
selected={useUserToken}
onSelect={() => selectUserToken(true)}
/>
</div>
</div>
{/if}
<div class="flex flex-col gap-1">
<h3 class="text-xs font-semibold text-emphasis">Scopes</h3>
<OauthScopes bind:scopes options={scopeOptions} />
@@ -1577,7 +1637,7 @@
<Label label="Path">
<Path
initialPath=""
namePlaceholder={resourceType}
namePlaceholder={defaultName}
bind:error={pathError}
bind:path
kind="resource"