shoutdotdev/src/slack_auth.rs

321 lines
11 KiB
Rust

use std::borrow::Cow;
use anyhow::{Context as _, Result};
use axum::{
extract::{Path, Query, State},
response::{IntoResponse, Redirect, Response},
routing::{get, post},
Router,
};
use axum_extra::extract::Form;
use diesel::prelude::*;
use oauth2::{
basic::BasicClient, reqwest::async_http_client, AuthUrl, AuthorizationCode, ClientId,
ClientSecret, CsrfToken, RedirectUrl, TokenResponse, TokenUrl,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::{
app_error::AppError,
app_state::{AppState, DbConn, ReqwestClient},
channels::{self, BackendConfig, Channel, OAuthTokens, SlackBackendConfig},
guards,
settings::{Settings, SlackSettings},
users::CurrentUser,
};
/// Creates a new OAuth2 client to be stored in global application state.
pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient> {
Ok(BasicClient::new(
ClientId::new(settings.slack.client_id.clone()),
Some(ClientSecret::new(settings.slack.client_secret.clone())),
AuthUrl::new(settings.slack.auth_url.clone())
.context("failed to create new authorization server URL")?,
Some(
TokenUrl::new(format!("{}/oauth.v2.access", settings.slack.api_root))
.context("failed to create new token endpoint URL")?,
),
))
}
/// Creates a router which can be nested within the higher level app router.
pub fn new_router() -> Router<AppState> {
Router::new()
.route(
"/teams/{team_id}/channels/{channel_id}/slack-auth/login",
post(start_login),
)
.route(
"/teams/{team_id}/channels/{channel_id}/slack-auth/callback",
get(callback),
)
.route(
"/teams/{team_id}/channels/{channel_id}/slack-auth/revoke",
post(revoke),
)
}
#[derive(Deserialize)]
struct StartLoginFormBody {
csrf_token: String,
}
/// HTTP get handler for /login
async fn start_login(
State(app_state): State<AppState>,
State(Settings {
base_path,
frontend_host,
..
}): State<Settings>,
DbConn(db_conn): DbConn,
CurrentUser(current_user): CurrentUser,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
Form(form): Form<StartLoginFormBody>,
) -> Result<Response, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let channel = db_conn
.interact(move |conn| -> Result<Channel, AppError> {
Channel::all()
.filter(Channel::with_id(&channel_id))
.filter(Channel::with_team(&team_id))
.first(conn)
.optional()?
.ok_or(AppError::NotFound(
"channel with that ID and team not found".to_owned(),
))
})
.await
.unwrap()?;
let csrf_token = CsrfToken::new_random();
let SlackBackendConfig {
conversation_id, ..
} = channel
.backend_config
.try_into()
.map_err(|_| anyhow::anyhow!("channel does not have a Slack backend"))?;
let slack_config = SlackBackendConfig {
conversation_id,
oauth_state: Some(csrf_token.clone()),
oauth_tokens: None,
};
const SCOPE_CHANNELS_READ: &str = "channels:read";
const SCOPE_CHAT_WRITE_PUBLIC: &str = "chat:write.public";
let (auth_url, _csrf_token) = app_state
.slack_oauth_client
.authorize_url(|| csrf_token)
.add_scopes([
oauth2::Scope::new(SCOPE_CHANNELS_READ.to_owned()),
oauth2::Scope::new(SCOPE_CHAT_WRITE_PUBLIC.to_owned()),
])
.set_redirect_uri(Cow::Owned(
RedirectUrl::new(format!(
"{}{}/en/teams/{}/channels/{}/slack-auth/callback",
frontend_host, base_path, team_id, channel_id
))
.context("failed to create redirection URL")?,
))
.url();
db_conn
.interact(move |conn| -> Result<()> {
diesel::update(channels::table.filter(Channel::with_id(&channel.id)))
.set(channels::dsl::backend_config.eq(Into::<BackendConfig>::into(slack_config)))
.execute(conn)
.map(|_| ())
.map_err(Into::into)
})
.await
.unwrap()?;
Ok(Redirect::to(auth_url.as_ref()).into_response())
}
#[derive(Debug, Deserialize)]
struct AuthRequestQuery {
code: String,
/// CSRF token
state: String,
}
/// HTTP get handler for /callback
async fn callback(
Query(query): Query<AuthRequestQuery>,
State(app_state): State<AppState>,
State(Settings {
base_path,
frontend_host,
..
}): State<Settings>,
DbConn(db_conn): DbConn,
CurrentUser(current_user): CurrentUser,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, AppError> {
guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let channel = db_conn
.interact(move |conn| -> Result<Channel, AppError> {
Channel::all()
.filter(Channel::with_id(&channel_id))
.filter(Channel::with_team(&team_id))
.first(conn)
.optional()?
.ok_or(AppError::NotFound(
"channel with that ID and team not found".to_owned(),
))
})
.await
.unwrap()?;
let slack_data: SlackBackendConfig = channel
.backend_config
.try_into()
.map_err(|_| anyhow::anyhow!("channel does not have a Slack backend"))?;
let true_csrf_token = slack_data.oauth_state.ok_or(AppError::BadRequest(
"No active Slack auth flow.".to_owned(),
))?;
if true_csrf_token.secret() != &query.state {
tracing::debug!("oauth csrf tokens did not match");
return Err(AppError::Forbidden(
"Slack OAuth CSRF tokens do not match.".to_owned(),
));
}
tracing::debug!("exchanging authorization code");
let response = app_state
.slack_oauth_client
.exchange_code(AuthorizationCode::new(query.code))
.set_redirect_uri(Cow::Owned(
RedirectUrl::new(format!(
"{}{}/en/teams/{}/channels/{}/slack-auth/callback",
frontend_host, base_path, team_id, channel_id
))
.context("failed to create redirection URL")?,
))
.request_async(async_http_client)
.await
.context("failed to exchange slack oauth code")?;
let slack_data = SlackBackendConfig {
conversation_id: slack_data.conversation_id,
oauth_state: None,
oauth_tokens: Some(OAuthTokens {
access_token: response.access_token().to_owned(),
refresh_token: response.refresh_token().map(|value| value.to_owned()),
}),
};
db_conn
.interact(move |conn| -> Result<()> {
let n_rows = diesel::update(channels::table.filter(Channel::with_id(&channel_id)))
.set(channels::dsl::backend_config.eq(BackendConfig::from(slack_data)))
.execute(conn)?;
tracing::debug!("updated {} rows", n_rows);
assert!(n_rows == 1);
Ok(())
})
.await
.unwrap()?;
tracing::debug!("successfully authenticated");
Ok(Redirect::to(&format!(
"{}/en/teams/{}/channels/{}",
base_path, team_id, channel_id
)))
}
#[derive(Deserialize)]
struct RevokeFormBody {
csrf_token: String,
}
async fn revoke(
State(Settings {
base_path,
slack: SlackSettings { api_root, .. },
..
}): State<Settings>,
State(ReqwestClient(reqwest_client)): State<ReqwestClient>,
DbConn(db_conn): DbConn,
CurrentUser(current_user): CurrentUser,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
Form(form): Form<RevokeFormBody>,
) -> Result<Response, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let channel = db_conn
.interact(move |conn| -> Result<Channel, AppError> {
Channel::all()
.filter(Channel::with_id(&channel_id))
.filter(Channel::with_team(&team_id))
.first(conn)
.optional()?
.ok_or(AppError::NotFound(
"channel with that ID and team not found".to_owned(),
))
})
.await
.unwrap()?;
let slack_data: SlackBackendConfig = channel
.backend_config
.try_into()
.map_err(|_| anyhow::anyhow!("channel does not have a Slack backend"))?;
if let Some(OAuthTokens { access_token, .. }) = slack_data.oauth_tokens {
#[derive(Deserialize)]
struct ApiResponse {
revoked: Option<bool>,
error: Option<String>,
}
tracing::debug!("revoking slack access token via slack api");
let response: ApiResponse = reqwest_client
.get(format!("{}/auth.revoke", api_root))
.bearer_auth(access_token.secret())
.send()
.await?
.error_for_status()?
.json()
.await?;
if response.revoked == Some(true) {
tracing::debug!("access token revoked successfully; updating backend config");
let slack_data = SlackBackendConfig {
conversation_id: slack_data.conversation_id,
oauth_state: None,
oauth_tokens: None,
};
db_conn
.interact(move |conn| -> Result<()> {
let n_rows =
diesel::update(channels::table.filter(Channel::with_id(&channel_id)))
.set(channels::dsl::backend_config.eq(BackendConfig::from(slack_data)))
.execute(conn)?;
tracing::debug!("updated {} rows", n_rows);
assert!(n_rows == 1);
Ok(())
})
.await
.unwrap()?;
tracing::debug!("backend config successfully updated");
Ok(Redirect::to(&format!(
"{}/en/teams/{}/channels/{}",
base_path, team_id, channel_id
))
.into_response())
} else if let Some(message) = response.error {
Err(anyhow::anyhow!("error while revoking access token: {}", message).into())
} else {
Err(anyhow::anyhow!("unknown error while revoking access token").into())
}
} else {
Err(AppError::BadRequest(
"Channel is not currently authenticated with Slack credentials.".to_owned(),
))
}
}