diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 9cdf2257ad..8fc044d450 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -44,6 +44,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/nvidia.yaml"), include_str!("../../../providers/openai.yaml"), include_str!("../../../providers/pypi.yaml"), + include_str!("../../../providers/slack.yaml"), ]; #[derive(Debug, thiserror::Error)] @@ -784,16 +785,19 @@ impl ProviderTypeProfile { /// Returns the credential suitable for `--from-gcloud-adc` bootstrap, if any. /// - /// A credential qualifies when its refresh strategy is `Oauth2RefreshToken` - /// and its material declares the three gcloud ADC keys (`client_id`, - /// `client_secret`, `refresh_token`). + /// A credential qualifies when its refresh strategy is `Oauth2RefreshToken`, + /// its token endpoint is the Google token endpoint that `gcloud` ADC + /// refresh tokens are redeemable at, and its material declares the three + /// gcloud ADC keys (`client_id`, `client_secret`, `refresh_token`). #[must_use] pub fn adc_credential(&self) -> Option<&CredentialProfile> { const ADC_MATERIAL_KEYS: &[&str] = &["client_id", "client_secret", "refresh_token"]; + const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; self.credentials.iter().find(|cred| { cred.refresh.as_ref().is_some_and(|refresh| { refresh.strategy == ProviderCredentialRefreshStrategy::Oauth2RefreshToken + && refresh.token_url == GOOGLE_TOKEN_URL && ADC_MATERIAL_KEYS .iter() .all(|key| refresh.material.iter().any(|m| m.name == *key)) @@ -3255,7 +3259,10 @@ mod tests { use std::collections::HashMap; use openshell_core::mcp::{DEFAULT_MCP_PROTOCOL_VERSION, McpProtocolVersion}; - use openshell_core::proto::{ProviderCredentialTokenGrantType, ProviderProfileCategory}; + use openshell_core::proto::{ + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrantType, + ProviderProfileCategory, + }; use super::{ DiscoveryProfile, EndpointProfile, L7AllowProfile, L7QueryMatcherProfile, ProfileError, @@ -3541,6 +3548,52 @@ endpoints: assert_eq!(adc.env_vars[0], "GOOGLE_VERTEX_AI_TOKEN"); } + #[test] + fn slack_profile_declares_rotating_user_token() { + let profile = builtin_profile("slack"); + let proto = profile.to_proto(); + + assert_eq!(proto.category, ProviderProfileCategory::Messaging as i32); + assert_eq!(profile.credentials.len(), 1); + let credential = &profile.credentials[0]; + assert_eq!(credential.name, "user_token"); + assert_eq!(credential.env_vars, vec!["SLACK_USER_TOKEN", "SLACK_TOKEN"]); + assert!(credential.required); + + let refresh = credential + .refresh + .as_ref() + .expect("slack user token should declare refresh metadata"); + assert_eq!( + refresh.strategy, + ProviderCredentialRefreshStrategy::Oauth2RefreshToken + ); + assert_eq!(refresh.token_url, "https://slack.com/api/oauth.v2.access"); + assert!(refresh.scopes.is_empty(), "no scope parameter on refresh"); + assert_eq!(refresh.max_lifetime_seconds, 43_200); + assert!( + refresh.refresh_before_seconds < refresh.max_lifetime_seconds, + "lead time must leave a positive refresh interval" + ); + let material = refresh + .material + .iter() + .map(|entry| (entry.name.as_str(), entry.required, entry.secret)) + .collect::>(); + assert_eq!( + material, + vec![ + ("client_id", true, false), + ("client_secret", true, true), + ("refresh_token", true, true), + ] + ); + assert!( + profile.adc_credential().is_none(), + "slack material is not gcloud ADC material" + ); + } + #[test] fn adc_credential_returns_none_for_profiles_without_adc() { let profile = builtin_profile("github"); @@ -5442,7 +5495,7 @@ binaries: let refresh = access_key.refresh.as_ref().unwrap(); assert_eq!( refresh.strategy, - openshell_core::proto::ProviderCredentialRefreshStrategy::AwsStsAssumeRole + ProviderCredentialRefreshStrategy::AwsStsAssumeRole ); assert!( refresh @@ -5544,7 +5597,7 @@ binaries: #[test] fn is_gateway_mintable_strategy_includes_aws_sts() { assert!(super::is_gateway_mintable_strategy( - openshell_core::proto::ProviderCredentialRefreshStrategy::AwsStsAssumeRole + ProviderCredentialRefreshStrategy::AwsStsAssumeRole )); } diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 764c0bbfde..f9a8cbcfeb 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -5881,7 +5881,8 @@ mod tests { "google-vertex-ai", "nvidia", "openai", - "pypi" + "pypi", + "slack" ] ); diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index bba28c3843..a9b3eaa59d 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -1571,12 +1571,28 @@ async fn request_token( let body = read_bounded_oauth_error_body(response).await; return Err(classify_oauth_token_error(status, &body, grant_kind)); } - let token = response.json::().await.map_err(|_| { + let body = response.bytes().await.map_err(|_| { RefreshFailure::investigate( - Status::failed_precondition("token endpoint returned invalid JSON"), + Status::failed_precondition("token endpoint returned an unreadable body"), "oauth_invalid_success_response", ) })?; + let token = match serde_json::from_slice::(&body) { + Ok(token) => token, + // RFC 6749 section 5.2 puts token endpoint errors on 4xx, but some + // issuers (Slack's oauth.v2.access, for example) answer HTTP 200 with + // an `error` body. Classify those like any other token error instead + // of leaving the state in a permanent invalid-success retry loop. + Err(_) if serde_json::from_slice::(&body).is_ok() => { + return Err(classify_oauth_token_error(status, &body, grant_kind)); + } + Err(_) => { + return Err(RefreshFailure::investigate( + Status::failed_precondition("token endpoint returned invalid JSON"), + "oauth_invalid_success_response", + )); + } + }; if token.access_token.trim().is_empty() { return Err(RefreshFailure::investigate( Status::failed_precondition("token endpoint returned empty access_token"), @@ -1640,6 +1656,16 @@ fn classify_oauth_token_error( ); }; + // Issuer-specific vocabulary is only consulted for errors that arrived in + // a 2xx envelope (see `request_token`); RFC 6749 error responses keep the + // RFC-only table below so no existing 4xx classification changes. + if status.is_success() + && let Some(failure) = + classify_issuer_token_error(error_response.error.as_str(), grant_kind) + { + return failure; + } + match error_response.error.as_str() { "invalid_grant" if grant_kind == OAuthGrantKind::UserRefreshToken => { let subtype = error_response @@ -1733,6 +1759,79 @@ fn classify_oauth_token_error( } } +/// Issuer-specific token endpoint error names (Slack's oauth.v2.access uses +/// these instead of the RFC 6749 vocabulary). Each group folds onto the +/// closest RFC failure code so status consumers keep a stable vocabulary; the +/// issuer's own name is preserved as the provider error subtype. +const ISSUER_INVALID_GRANT_ERRORS: &[&str] = &[ + "invalid_refresh_token", + "token_revoked", + "token_expired", + "account_inactive", + "invalid_auth", +]; +const ISSUER_INVALID_CLIENT_ERRORS: &[&str] = &["invalid_client_id", "bad_client_secret"]; +const ISSUER_UNSUPPORTED_GRANT_TYPE_ERRORS: &[&str] = &["invalid_grant_type"]; +const ISSUER_RETRYABLE_ERRORS: &[&str] = &[ + "ratelimited", + "accesslimited", + "request_timeout", + "service_unavailable", + "internal_error", + "fatal_error", +]; + +fn known_issuer_error(names: &'static [&'static str], error: &str) -> Option<&'static str> { + names.iter().copied().find(|name| *name == error) +} + +fn classify_issuer_token_error(error: &str, grant_kind: OAuthGrantKind) -> Option { + if let Some(name) = known_issuer_error(ISSUER_INVALID_GRANT_ERRORS, error) { + return Some(if grant_kind == OAuthGrantKind::UserRefreshToken { + RefreshFailure::reauthorize( + Status::failed_precondition(format!( + "OAuth refresh grant is no longer usable ({name}); user reauthorization is required" + )), + "oauth_invalid_grant", + Some(name), + ) + } else { + RefreshFailure::fix_configuration_with_subtype( + Status::failed_precondition(format!( + "OAuth token endpoint rejected the non-interactive grant ({name})" + )), + "oauth_invalid_grant", + name, + ) + }); + } + if let Some(name) = known_issuer_error(ISSUER_INVALID_CLIENT_ERRORS, error) { + return Some(RefreshFailure::fix_configuration_with_subtype( + Status::failed_precondition(format!( + "OAuth token endpoint rejected the client configuration ({name})" + )), + "oauth_invalid_client", + name, + )); + } + if let Some(name) = known_issuer_error(ISSUER_UNSUPPORTED_GRANT_TYPE_ERRORS, error) { + return Some(RefreshFailure::fix_configuration_with_subtype( + Status::failed_precondition(format!( + "OAuth token endpoint rejected the configured grant type ({name})" + )), + "oauth_unsupported_grant_type", + name, + )); + } + if ISSUER_RETRYABLE_ERRORS.contains(&error) { + return Some(RefreshFailure::retryable( + Status::unavailable("OAuth token endpoint reported a temporary failure"), + "oauth_token_endpoint_retryable", + )); + } + None +} + pub fn refresh_scopes(state: &StoredProviderCredentialRefreshState) -> Vec { if !state.scopes.is_empty() { return state.scopes.clone(); @@ -2476,6 +2575,346 @@ mod tests { ); } + #[test] + fn issuer_invalid_grant_errors_map_onto_oauth_invalid_grant_by_grant_kind() { + for name in [ + "invalid_refresh_token", + "token_revoked", + "token_expired", + "account_inactive", + "invalid_auth", + ] { + let body = format!(r#"{{"ok":false,"error":"{name}","needed":"chat:write"}}"#); + + let user_failure = classify_oauth_token_error( + reqwest::StatusCode::OK, + body.as_bytes(), + OAuthGrantKind::UserRefreshToken, + ); + assert_eq!( + user_failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize, + "{name}" + ); + assert_eq!(user_failure.failure_code, "oauth_invalid_grant"); + assert_eq!(user_failure.provider_error_subtype, Some(name)); + assert_eq!(user_failure.retry_schedule, RefreshRetrySchedule::Parked); + assert!(!user_failure.status.message().contains("chat:write")); + + let service_failure = classify_oauth_token_error( + reqwest::StatusCode::OK, + body.as_bytes(), + OAuthGrantKind::NonInteractive, + ); + assert_eq!( + service_failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration, + "{name}" + ); + assert_eq!(service_failure.failure_code, "oauth_invalid_grant"); + assert_eq!(service_failure.provider_error_subtype, Some(name)); + assert_eq!( + service_failure.retry_schedule, + RefreshRetrySchedule::Configuration + ); + } + } + + #[test] + fn issuer_error_names_on_rfc_error_responses_stay_unrecognized() { + let failure = classify_oauth_token_error( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":"token_expired"}"#, + OAuthGrantKind::UserRefreshToken, + ); + assert_eq!( + failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate + ); + assert_eq!(failure.failure_code, "oauth_unrecognized_error"); + assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Short); + } + + #[test] + fn issuer_client_and_transient_errors_keep_rfc_failure_codes() { + let client_failure = classify_oauth_token_error( + reqwest::StatusCode::OK, + br#"{"ok":false,"error":"bad_client_secret"}"#, + OAuthGrantKind::UserRefreshToken, + ); + assert_eq!( + client_failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::FixConfiguration + ); + assert_eq!(client_failure.failure_code, "oauth_invalid_client"); + assert_eq!( + client_failure.provider_error_subtype, + Some("bad_client_secret") + ); + + let grant_type_failure = classify_oauth_token_error( + reqwest::StatusCode::OK, + br#"{"ok":false,"error":"invalid_grant_type"}"#, + OAuthGrantKind::UserRefreshToken, + ); + assert_eq!( + grant_type_failure.failure_code, + "oauth_unsupported_grant_type" + ); + assert_eq!( + grant_type_failure.provider_error_subtype, + Some("invalid_grant_type") + ); + + let transient_failure = classify_oauth_token_error( + reqwest::StatusCode::OK, + br#"{"ok":false,"error":"ratelimited"}"#, + OAuthGrantKind::UserRefreshToken, + ); + assert_eq!( + transient_failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Retry + ); + assert_eq!( + transient_failure.failure_code, + "oauth_token_endpoint_retryable" + ); + assert_eq!( + transient_failure.retry_schedule, + RefreshRetrySchedule::Short + ); + + let unknown_failure = classify_oauth_token_error( + reqwest::StatusCode::OK, + br#"{"ok":false,"error":"not_a_known_error"}"#, + OAuthGrantKind::UserRefreshToken, + ); + assert_eq!( + unknown_failure.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate + ); + assert_eq!(unknown_failure.failure_code, "oauth_unrecognized_error"); + } + + #[tokio::test] + async fn oauth_error_body_returned_with_http_200_is_classified() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "ok": false, + "error": "invalid_refresh_token", + "needed": "provider-controlled detail", + "provided": "provider-controlled detail" + }))) + .mount(&mock_server) + .await; + + let store = test_store().await; + let provider = provider("rotated-out-grant", "slack"); + store.put_message(&provider).await.unwrap(); + let state = new_refresh_state( + &provider, + "default", + "SLACK_USER_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ("client_secret".to_string(), "client-secret".to_string()), + ("refresh_token".to_string(), "xoxe-1-used".to_string()), + ]), + secret_material_keys: vec![ + "client_secret".to_string(), + "refresh_token".to_string(), + ], + expires_at_ms: 0, + token_url: format!("{}/token", mock_server.uri()), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 43_200, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + put_refresh_state(&store, &state).await.unwrap(); + + let err = refresh_provider_credential( + &store, + "default", + &test_credentials(), + None, + "rotated-out-grant", + "SLACK_USER_TOKEN", + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + let stored = get_refresh_state(&store, "default", provider.object_id(), "SLACK_USER_TOKEN") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, "reauthorization_required"); + assert_eq!(stored.next_refresh_at_ms, i64::MAX); + assert_eq!( + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::Reauthorize as i32 + ); + assert_eq!(stored.failure_code, "oauth_invalid_grant"); + assert_eq!(stored.provider_error_subtype, "invalid_refresh_token"); + assert!(!stored.last_error.contains("provider-controlled detail")); + } + + #[tokio::test] + async fn success_body_without_token_or_error_still_reports_invalid_success_response() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true })), + ) + .mount(&mock_server) + .await; + + let store = test_store().await; + let provider = provider("odd-issuer", "slack"); + store.put_message(&provider).await.unwrap(); + let state = new_refresh_state( + &provider, + "default", + "SLACK_USER_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ("refresh_token".to_string(), "xoxe-1-current".to_string()), + ]), + secret_material_keys: vec!["refresh_token".to_string()], + expires_at_ms: 0, + token_url: format!("{}/token", mock_server.uri()), + scopes: Vec::new(), + refresh_before_seconds: 30, + max_lifetime_seconds: 60, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + put_refresh_state(&store, &state).await.unwrap(); + + refresh_provider_credential( + &store, + "default", + &test_credentials(), + None, + "odd-issuer", + "SLACK_USER_TOKEN", + ) + .await + .unwrap_err(); + + let stored = get_refresh_state(&store, "default", provider.object_id(), "SLACK_USER_TOKEN") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, "investigation_required"); + assert_eq!(stored.failure_code, "oauth_invalid_success_response"); + assert_eq!( + stored.recovery_action, + ProviderCredentialRefreshRecoveryAction::Investigate as i32 + ); + assert!(stored.next_refresh_at_ms < i64::MAX); + } + + #[tokio::test] + async fn oauth2_refresh_token_accepts_slack_shaped_success_body() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains("grant_type=refresh_token")) + .and(body_string_contains("client_id=client-id")) + .and(body_string_contains("client_secret=client-secret")) + .and(body_string_contains("refresh_token=xoxe-1-old")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "ok": true, + "access_token": "xoxe.xoxp-1-rotated", + "refresh_token": "xoxe-1-new", + "expires_in": 43_200, + "token_type": "user", + "scope": "users:read" + }))) + .mount(&mock_server) + .await; + + let store = test_store().await; + let provider = provider("my-slack", "slack"); + store.put_message(&provider).await.unwrap(); + let state = new_refresh_state( + &provider, + "default", + "SLACK_USER_TOKEN", + NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken, + material: HashMap::from([ + ("client_id".to_string(), "client-id".to_string()), + ("client_secret".to_string(), "client-secret".to_string()), + ("refresh_token".to_string(), "xoxe-1-old".to_string()), + ]), + secret_material_keys: vec![ + "client_secret".to_string(), + "refresh_token".to_string(), + ], + expires_at_ms: 0, + token_url: format!("{}/token", mock_server.uri()), + scopes: Vec::new(), + refresh_before_seconds: 21_600, + max_lifetime_seconds: 43_200, + additional_output_keys: HashMap::new(), + }, + ) + .unwrap(); + put_refresh_state(&store, &state).await.unwrap(); + let credentials = test_credentials(); + + let before_ms = current_time_ms(); + let refreshed = refresh_provider_credential( + &store, + "default", + &credentials, + None, + "my-slack", + "SLACK_USER_TOKEN", + ) + .await + .unwrap(); + assert_eq!(refreshed.status, "refreshed"); + assert!( + refreshed.expires_at_ms >= before_ms + 43_000 * 1000, + "12h lifetime must not be clamped when the profile cap allows it" + ); + assert!( + refreshed.next_refresh_at_ms >= before_ms + 21_000 * 1000, + "next refresh is scheduled refresh_before_seconds ahead of expiry" + ); + + let stored_state = + get_refresh_state(&store, "default", provider.object_id(), "SLACK_USER_TOKEN") + .await + .unwrap() + .unwrap(); + assert_eq!( + credentials + .resolve_refresh_material( + refresh_material_scope(&stored_state), + &stored_state.secret_material_handles, + ) + .await + .unwrap() + .get("refresh_token"), + Some(&"xoxe-1-new".to_string()) + ); + } + #[tokio::test] async fn oauth_invalid_grant_persists_terminal_reauthorization_status() { let mock_server = MockServer::start().await; diff --git a/docs/providers/profiles.mdx b/docs/providers/profiles.mdx index c310c02b15..aaa0a45f0c 100644 --- a/docs/providers/profiles.mdx +++ b/docs/providers/profiles.mdx @@ -243,6 +243,7 @@ Built-in provider profiles currently include: | `nvidia` | `inference` | `NVIDIA_API_KEY` | | `openai` | `inference` | `OPENAI_API_KEY` | | `pypi` | `data` | None | +| `slack` | `messaging` | `SLACK_USER_TOKEN`, `SLACK_TOKEN` | Export a built-in profile as YAML: @@ -771,7 +772,11 @@ openshell provider refresh status my-graph ``` The status table includes `RECOVERY` and `FAILURE_CODE`. Clients should use the -structured recovery action rather than parsing `LAST_ERROR`. For example, +structured recovery action rather than parsing `LAST_ERROR`. Token endpoints +that return an OAuth error body with an HTTP 2xx status (Slack's +`oauth.v2.access`, for example) are classified the same way as RFC 6749 +error responses; issuer-specific error names are folded onto the closest +`FAILURE_CODE` and preserved in the provider error subtype. For example, `oauth_invalid_grant` with recovery action `reauthorize` means the user must complete OAuth authorization again; `oauth_invalid_client` with `fix_configuration` means changing the user login alone will not repair the diff --git a/docs/providers/slack.mdx b/docs/providers/slack.mdx new file mode 100644 index 0000000000..13da064652 --- /dev/null +++ b/docs/providers/slack.mdx @@ -0,0 +1,105 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Slack" +sidebar-title: "Slack" +description: "Give sandboxes governed Slack Web API access with gateway-managed user token rotation." +keywords: "Slack, OAuth2, Token Rotation, Credentials, Provider Profile, Sandbox" +--- + +The `slack` provider injects a Slack user token into sandboxes as a stable +`SLACK_USER_TOKEN` placeholder and keeps the real token short lived. Slack +apps with token rotation enabled issue access tokens that expire after +12 hours together with a single-use refresh token. The gateway stores the +refresh material, refreshes the access token through `oauth.v2.access` +before it expires, and swaps the value behind the placeholder without +restarting sandbox processes. + + +Token rotation is an app-level setting in Slack and cannot be turned off +once enabled. Apps without rotation issue non-expiring tokens; for those, +create the provider with the static token and skip the refresh +configuration below. + + +## Prerequisites + +- A Slack app with token rotation enabled and the user scopes your workload + needs. +- The app's client ID and client secret. Slack requires both on every + refresh, even for user tokens. +- A completed OAuth authorization for the user, which returns an access + token, a refresh token, and `expires_in` under `authed_user`. + +| Variable | Value | +|---|---| +| `SLACK_CLIENT_ID` | Slack app client ID. | +| `SLACK_CLIENT_SECRET` | Slack app client secret. | +| `SLACK_USER_TOKEN` | Current user access token (`xoxe.xoxp-...`). | +| `SLACK_REFRESH_TOKEN` | Current refresh token (`xoxe-1-...`). | +| `SLACK_USER_TOKEN_EXPIRES_AT` | Absolute expiry of the current access token (RFC3339 or epoch milliseconds). | + + +Slack refresh tokens are single use. Every refresh returns a new refresh +token and invalidates the previous one, so only the gateway should refresh a +rotating grant. Do not reuse the refresh token you hand to the gateway +elsewhere, and avoid manual `refresh rotate` calls that could race the +gateway's own scheduled refresh. + + +## Create the Provider + +```shell +openshell provider create \ + --name my-slack \ + --type slack \ + --credential SLACK_USER_TOKEN="$SLACK_USER_TOKEN" +``` + +## Configure Refresh + +```shell +openshell provider refresh configure my-slack \ + --credential-key SLACK_USER_TOKEN \ + --strategy oauth2-refresh-token \ + --material client_id="$SLACK_CLIENT_ID" \ + --secret-material-env client_secret=SLACK_CLIENT_SECRET \ + --secret-material-env refresh_token=SLACK_REFRESH_TOKEN \ + --credential-expires-at "$SLACK_USER_TOKEN_EXPIRES_AT" +``` + +The profile owns the token endpoint, the 43200-second lifetime cap, and a +21600-second refresh lead time. The gateway refreshes the token on its own +schedule; confirm the configuration took effect with: + +```shell +openshell provider refresh status my-slack +``` + +## Failure Handling + +Slack reports token endpoint errors with an HTTP 200 status and an `error` +field. The gateway classifies those like standard OAuth errors: + +| Slack error | `FAILURE_CODE` | Recovery | +|---|---|---| +| `invalid_refresh_token`, `token_revoked`, `token_expired`, `account_inactive`, `invalid_auth` | `oauth_invalid_grant` | `reauthorize`: the user must authorize the app again. | +| `invalid_client_id`, `bad_client_secret` | `oauth_invalid_client` | `fix_configuration`: correct the client material. | +| `ratelimited` and other transient Slack errors | `oauth_token_endpoint_retryable` | `retry`: the gateway retries automatically. | + +The Slack error name is recorded as `provider_error_subtype` in the refresh +status API and echoed in the `LAST_ERROR` text of the status table. + +## Use the Provider in a Sandbox + +```shell +openshell sandbox create --name slack-sandbox --provider my-slack +openshell sandbox exec slack-sandbox -- \ + curl -s https://slack.com/api/auth.test \ + -H "Authorization: Bearer $SLACK_USER_TOKEN" +``` + +The sandbox sees only the placeholder; the sandbox proxy substitutes the +current access token for requests to `slack.com`. The profile allows read +methods only; add `rules` for the specific Slack Web API paths your workload +needs, including any write methods such as `chat.postMessage`. diff --git a/providers/slack.yaml b/providers/slack.yaml new file mode 100644 index 0000000000..b96cdf7502 --- /dev/null +++ b/providers/slack.yaml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: slack +display_name: Slack +description: Slack Web API access with gateway-managed user token rotation +category: messaging + +credentials: + # Rotating user token (xoxe.xoxp-...). Slack apps with token rotation + # enabled issue 12-hour access tokens together with a single-use refresh + # token on every rotation; the gateway refreshes through oauth.v2.access. + # Configure with `openshell provider refresh configure`. + - name: user_token + description: Slack user access token + env_vars: [SLACK_USER_TOKEN, SLACK_TOKEN] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_refresh_token + token_url: https://slack.com/api/oauth.v2.access + # Slack access tokens live exactly 43200 seconds. The cap must match, + # otherwise the default 3600-second cap would schedule refreshes far + # too often and burn single-use refresh tokens. + refresh_before_seconds: 21600 + max_lifetime_seconds: 43200 + material: + - name: client_id + description: Slack app client ID + required: true + - name: client_secret + description: Slack app client secret (required by Slack on refresh) + required: true + secret: true + - name: refresh_token + description: Slack rotating refresh token (xoxe-1-...) + required: true + secret: true + +discovery: + credentials: [user_token] + +endpoints: + # Slack's read methods accept GET, so the read-only preset covers reads + # while keeping write methods denied until a policy explicitly allows them. + - host: slack.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce