diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 01991d712e..399e72f064 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -877,16 +877,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)) @@ -3468,7 +3471,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, @@ -3995,6 +4001,52 @@ endpoints: assert_eq!(adc.env_vars[0], "GOOGLE_VERTEX_AI_TOKEN"); } + #[test] + fn slack_profile_declares_rotating_user_token() { + let profile = example_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 = example_profile("github"); @@ -5925,7 +5977,7 @@ binaries: let refresh = access_key.refresh.as_ref().unwrap(); assert_eq!( refresh.strategy, - openshell_core::proto::ProviderCredentialRefreshStrategy::AwsStsAssumeRole + ProviderCredentialRefreshStrategy::AwsStsAssumeRole ); assert!( refresh @@ -6027,7 +6079,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 2141ecb4c2..009cb97fc6 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -6303,7 +6303,8 @@ mod tests { "nvidia", "openai", "openrouter", - "pypi" + "pypi", + "slack" ] ); diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index e8b39cbac4..fc2a73dd5b 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -1651,12 +1651,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"), @@ -1720,6 +1736,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 @@ -1813,6 +1839,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(); @@ -2614,6 +2713,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: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(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: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(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: Some(proto_duration(21_600)), + max_lifetime: Some(proto_duration(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/how-it-works/providers/overview.mdx b/docs/how-it-works/providers/overview.mdx index 908adf64d8..eab90ecdf2 100644 --- a/docs/how-it-works/providers/overview.mdx +++ b/docs/how-it-works/providers/overview.mdx @@ -449,7 +449,7 @@ openshell provider list-profiles The repository's [`providers/`](https://github.com/NVIDIA/OpenShell/tree/main/providers) directory -ships example profiles for GitHub, PyPI, and the major inference and agent +ships example profiles for GitHub, PyPI, Slack, and the major inference and agent providers. Each file's header states the credential environment variables it declares, the binaries it authorizes, and the endpoints it grants. Read it, adjust the binary paths for your image, and import your copy. diff --git a/docs/how-it-works/providers/profiles.mdx b/docs/how-it-works/providers/profiles.mdx index f28124c82c..3bce6304f0 100644 --- a/docs/how-it-works/providers/profiles.mdx +++ b/docs/how-it-works/providers/profiles.mdx @@ -827,7 +827,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/how-it-works/providers/slack.mdx b/docs/how-it-works/providers/slack.mdx new file mode 100644 index 0000000000..48781d85a6 --- /dev/null +++ b/docs/how-it-works/providers/slack.mdx @@ -0,0 +1,120 @@ +--- +# 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` example profile 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. + + +## Import the Example Profile + +The repository ships `providers/slack.yaml` as a reviewable example. Lint and +import it (drop `--global` to import into the current workspace only): + +```shell +openshell provider profile lint --url https://raw.githubusercontent.com/NVIDIA/OpenShell/main/providers/slack.yaml +openshell provider profile import --url https://raw.githubusercontent.com/NVIDIA/OpenShell/main/providers/slack.yaml --global +``` + +From a checkout, `-f providers/slack.yaml` works the same way. The example +grants read-only access to `slack.com` for `curl`; copy and adapt it if your +workload uses a different client binary or needs write methods such as +`chat.postMessage`, which require explicit `rules` or `access: read-write`. + +## 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 example allows read +methods only; add `rules` for the specific Slack Web API paths your workload +needs, including any write methods. diff --git a/docs/index.yml b/docs/index.yml index e4b3ff9245..3f79a8cef8 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -55,6 +55,8 @@ navigation: path: how-it-works/providers/aws.mdx - page: "Google" path: how-it-works/providers/google.mdx + - page: "Slack" + path: how-it-works/providers/slack.mdx - section: "Policies" slug: policies contents: diff --git a/providers/slack.yaml b/providers/slack.yaml new file mode 100644 index 0000000000..aefd2326fa --- /dev/null +++ b/providers/slack.yaml @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Example provider profile. OpenShell does not load it; import it explicitly: +# openshell provider profile lint -f providers/slack.yaml +# openshell provider profile import -f providers/slack.yaml --global +# +# Copy and edit this file rather than importing it unchanged. `binaries` is the +# least-privilege control that decides which processes may reach the endpoints +# below, so it has to name the paths in *your* image. +# +# Client binaries: curl (or your own Slack client; edit `binaries` to match). +# Reference layout: curl on PATH at /usr/bin or /usr/local/bin. +# Credential scope: a Slack user access token from an app with token rotation +# enabled, as SLACK_USER_TOKEN (alias SLACK_TOKEN), sent as a +# bearer authorization header to slack.com and nowhere else. +# Configure the refresh material (client_id, client_secret, +# refresh_token) with `openshell provider refresh configure`; +# it stays at the gateway and is never injected. Slack access +# tokens live 43200 s and refresh tokens are single use, so +# only the gateway should refresh them. +# Endpoint access: slack.com:443 read-only (GET/HEAD/OPTIONS). Slack's read +# methods accept GET with query-string arguments; the token +# travels in the injected authorization header, never as a +# query parameter. Writes such as chat.postMessage need an +# explicit `rules` entry or a copy with `access: read-write`. +# Smoke test: openshell sandbox create --provider -- \ +# curl -s https://slack.com/api/auth.test \ +# -H "Authorization: Bearer $SLACK_USER_TOKEN" + +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 + +binaries: + - /usr/bin/curl + - /usr/local/bin/curl