Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions examples/auth_slack.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
#![expect(
clippy::print_stdout,
clippy::exit,
clippy::expect_used,
reason = "CLI examples can be more lax"
)]
//! End-to-end Slack OAuth v2 token acquisition example.
//!
//! Slack's OAuth v2 deviates from RFC 6749 in several ways:
//!
//! - **Nested token response**: The access token lives inside an `authed_user`
//! sub-object, not at the top level. This example uses
//! [`CliTokenClientBuilder::token_response_type`] with a custom
//! [`From<SlackV2TokenResponse> for TokenResponseFields`] to extract the
//! standard fields from the nested structure.
//!
//! - **Comma-delimited scopes**: Slack returns granted scopes separated by
//! commas instead of the RFC 6749 §3.3 space delimiter. The `From` impl
//! normalizes commas to spaces so loopauth's scope resolution works
//! correctly.
//!
//! - **`user_scope` parameter**: Slack uses a non-standard `user_scope` query
//! parameter on the authorization URL (rather than the standard `scope`
//! parameter). [`ExtraAuthParams`] handles this via `on_auth_url`.
//!
//! - **HTTPS redirect required**: Slack requires `https://` redirect URIs,
//! even on localhost. This example uses [`TlsCertificate::ensure_localhost`]
//! with a fixed port.
//!
//! # Required environment variables
//!
//! | Variable | Description |
//! |--------------------------|---------------------------------------------|
//! | `LOOPAUTH_CLIENT_ID` | OAuth 2.0 client ID from api.slack.com |
//! | `LOOPAUTH_CLIENT_SECRET` | OAuth 2.0 client secret |
//! | `LOOPAUTH_TLS_DIR` | Directory for managed TLS certs (via mkcert)|
//!
//! # Optional environment variables
//!
//! | Variable | Description | Default |
//! |-------------------|--------------------------------|-----------------------------------------------------------|
//! | `LOOPAUTH_SCOPES` | Comma-separated user scopes | `channels:history,channels:read,groups:history,groups:read`|
//! | `LOOPAUTH_PORT` | Port for the HTTPS loopback | `8443` |
//!
//! # Setup
//!
//! 1. Go to <https://api.slack.com/apps> and create a new app.
//! 2. Under **OAuth & Permissions**, add `https://127.0.0.1:8443/callback`
//! as a redirect URL.
//! 3. Under **User Token Scopes**, add the scopes you need.
//! 4. Copy the **Client ID** and **Client Secret** from **Basic Information**.
//! 5. Install [`mkcert`](https://github.com/FiloSottile/mkcert) and run
//! `mkcert -install` once to trust the local CA.
//!
//! ```sh
//! LOOPAUTH_CLIENT_ID=... \
//! LOOPAUTH_CLIENT_SECRET=... \
//! LOOPAUTH_TLS_DIR=~/.config/loopauth-slack/tls \
//! cargo run --example auth_slack
//! ```

use loopauth::{CliTokenClient, TlsCertificate, TokenResponseFields};

const SLACK_AUTH_URL: &str = "https://slack.com/oauth/v2/authorize";
const SLACK_TOKEN_URL: &str = "https://slack.com/api/oauth.v2.access";
const DEFAULT_SCOPES: &str = "channels:history,channels:read,groups:history,groups:read";
const DEFAULT_PORT: u16 = 8443;
const FAILURE_EXIT_CODE: i32 = 1;
const SIGINT_EXIT_CODE: i32 = 130;

// Slack nests user tokens inside `authed_user` rather than at the top level.
// We deserialize into this shape and then convert to `TokenResponseFields`.

#[derive(serde::Deserialize)]
struct SlackV2TokenResponse {
authed_user: SlackAuthedUser,
}

#[derive(serde::Deserialize)]
struct SlackAuthedUser {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<u64>,
scope: Option<String>,
}

impl From<SlackV2TokenResponse> for TokenResponseFields {
fn from(resp: SlackV2TokenResponse) -> Self {
// Slack returns scopes comma-separated; RFC 6749 §3.3 uses spaces.
let scope = resp.authed_user.scope.map(|s| s.replace(',', " "));

Self::new(resp.authed_user.access_token)
.with_refresh_token(resp.authed_user.refresh_token)
.with_expires_in(resp.authed_user.expires_in)
.with_token_type(Some("Bearer".to_string()))
.with_scope(scope)
}
}

#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();

let client_id = require_env("LOOPAUTH_CLIENT_ID");
let client_secret = require_env("LOOPAUTH_CLIENT_SECRET");
let user_scopes =
std::env::var("LOOPAUTH_SCOPES").unwrap_or_else(|_| DEFAULT_SCOPES.to_string());
let port: u16 = std::env::var("LOOPAUTH_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT);

let auth_url = url::Url::parse(SLACK_AUTH_URL).expect("Slack auth URL is valid");
let token_url = url::Url::parse(SLACK_TOKEN_URL).expect("Slack token URL is valid");

// Slack requires HTTPS redirect URIs, even on localhost.
let tls_dir = require_env("LOOPAUTH_TLS_DIR");
tracing::info!("using managed TLS certificates in {tls_dir}");
let cert = TlsCertificate::ensure_localhost(&tls_dir).unwrap_or_else(|e| {
tracing::error!("TLS certificate setup failed: {e}");
if matches!(e, loopauth::TlsCertificateError::MkcertNotFound) {
println!("\n{}", TlsCertificate::SETUP_GUIDE_MANAGED);
}
std::process::exit(FAILURE_EXIT_CODE);
});

let client = CliTokenClient::builder()
.client_id(client_id)
.client_secret(client_secret)
.auth_url(auth_url)
.token_url(token_url)
// Parse Slack's nested `authed_user` response into standard fields.
.token_response_type::<SlackV2TokenResponse>()
.use_https_with(cert)
.require_port(port)
// Slack uses `user_scope` instead of the standard `scope` parameter.
.on_auth_url(move |params| {
params.append("user_scope", &user_scopes);
})
.on_url(|url| {
tracing::info!("opening: {url}");
tracing::info!("waiting for browser callback... (Ctrl+C to cancel)");
})
.build();

tracing::info!("starting Slack OAuth v2 authorization flow");

match client.run_authorization_flow().await {
Ok(tokens) => {
println!("\n=== Authentication successful ===");
println!("access_token : {}", tokens.access_token());

if let Some(rt) = tokens.refresh_token() {
println!("refresh_token: {rt}");
}

let scopes: Vec<String> = tokens.scopes().iter().map(ToString::to_string).collect();
if !scopes.is_empty() {
println!("scopes : {}", scopes.join(", "));
}

if let Some(expires) = tokens.expires_at()
&& let Ok(remaining) = expires.duration_since(std::time::SystemTime::now())
{
println!("expires in : {}s", remaining.as_secs());
}
}
Err(loopauth::AuthError::Cancelled) => {
tracing::info!("cancelled");
std::process::exit(SIGINT_EXIT_CODE);
}
Err(e) => {
tracing::error!("authentication failed: {e}");
std::process::exit(FAILURE_EXIT_CODE);
}
}
}

fn require_env(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| {
tracing::error!("{name} ENV var not set");
std::process::exit(FAILURE_EXIT_CODE);
})
}
5 changes: 5 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ run-auth-discovery-example:
run-auth-jira-example:
cargo run --example auth_jira

# Run auth example for Slack OAuth v2
[group('integration-test')]
run-auth-slack-example:
cargo run --example auth_slack

[group('cargo')]
verify-publish:
cargo publish --dry-run --allow-dirty
Loading
Loading