mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 16:02:12 +00:00
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:
co-authored by
Claude Opus 5.5
Ruben Fiszel
windmill-internal-app[bot]
parent
227e934210
commit
d518aa5557
@@ -1 +1 @@
|
||||
b469efc9ceddfaaa1040e4cb2c18993822f92c78
|
||||
ff4d04f17721d84fb9a1f655af50ff7e4e7dcbc7
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user