From 660e646b2b0bff7ac4063be286239e91fca92683 Mon Sep 17 00:00:00 2001 From: Zomatree Date: Wed, 2 Jul 2025 03:45:43 +0100 Subject: [PATCH] feat: initial oauth2 token revoking --- .../src/models/authorized_bots/mod.rs | 5 ++ .../src/models/authorized_bots/model.rs | 27 ++++++++ .../src/models/authorized_bots/ops.rs | 27 ++++++++ .../src/models/authorized_bots/ops/mongodb.rs | 61 +++++++++++++++++++ .../models/authorized_bots/ops/reference.rs | 38 ++++++++++++ crates/core/database/src/models/mod.rs | 3 + crates/core/database/src/util/bridge/v0.rs | 40 ++++++++++++ crates/core/models/src/v0/authorized_bots.rs | 27 ++++++++ crates/core/models/src/v0/mod.rs | 2 + crates/daemons/crond/src/main.rs | 5 +- crates/daemons/crond/src/tasks/mod.rs | 1 + .../crond/src/tasks/prune_authorized_bots.rs | 24 ++++++++ .../delta/src/routes/oauth2/authorize_auth.rs | 16 ++++- .../src/routes/oauth2/authorized_bots.rs | 20 ++++++ crates/delta/src/routes/oauth2/mod.rs | 4 ++ crates/delta/src/routes/oauth2/revoke.rs | 50 +++++++++++++++ crates/delta/src/routes/oauth2/token.rs | 15 +++-- crates/delta/src/routes/users/mod.rs | 1 + .../src/routes/users/oauth2_connections.rs | 0 19 files changed, 358 insertions(+), 8 deletions(-) create mode 100644 crates/core/database/src/models/authorized_bots/mod.rs create mode 100644 crates/core/database/src/models/authorized_bots/model.rs create mode 100644 crates/core/database/src/models/authorized_bots/ops.rs create mode 100644 crates/core/database/src/models/authorized_bots/ops/mongodb.rs create mode 100644 crates/core/database/src/models/authorized_bots/ops/reference.rs create mode 100644 crates/core/models/src/v0/authorized_bots.rs create mode 100644 crates/daemons/crond/src/tasks/prune_authorized_bots.rs create mode 100644 crates/delta/src/routes/oauth2/authorized_bots.rs create mode 100644 crates/delta/src/routes/oauth2/revoke.rs create mode 100644 crates/delta/src/routes/users/oauth2_connections.rs diff --git a/crates/core/database/src/models/authorized_bots/mod.rs b/crates/core/database/src/models/authorized_bots/mod.rs new file mode 100644 index 00000000..4d801b73 --- /dev/null +++ b/crates/core/database/src/models/authorized_bots/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod ops; + +pub use model::*; +pub use ops::*; diff --git a/crates/core/database/src/models/authorized_bots/model.rs b/crates/core/database/src/models/authorized_bots/model.rs new file mode 100644 index 00000000..6d341994 --- /dev/null +++ b/crates/core/database/src/models/authorized_bots/model.rs @@ -0,0 +1,27 @@ +use iso8601_timestamp::Timestamp; + +auto_derived! ( + /// Unique id of the user and bot + pub struct AuthorizedBotId { + /// User id + pub user: String, + + /// Bot Id + pub bot: String, + } + + pub struct AuthorizedBot { + /// Unique Id + #[serde(rename = "_id")] + pub id: AuthorizedBotId, + + /// When the authorized oauth2 bot connection was created at + pub created_at: Timestamp, + + /// If and when the authorized oauth2 bot connection was revoked at + pub deauthorized_at: Option, + + /// Scopes the bot has access to + pub scope: String + } +); \ No newline at end of file diff --git a/crates/core/database/src/models/authorized_bots/ops.rs b/crates/core/database/src/models/authorized_bots/ops.rs new file mode 100644 index 00000000..3cea932f --- /dev/null +++ b/crates/core/database/src/models/authorized_bots/ops.rs @@ -0,0 +1,27 @@ +use revolt_result::Result; + +use crate::{AuthorizedBot, AuthorizedBotId}; + +mod mongodb; +mod reference; + +#[async_trait] +pub trait AbstractAuthorizedBots: Sync + Send { + /// Insert emoji into database. + async fn insert_authorized_bot(&self, authorised_bot: &AuthorizedBot) -> Result<()>; + + /// Fetch an emoji by its id + async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result; + + /// Fetch all authorized bots for a user + async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result>; + + /// Deletes an authorized bot + async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()>; + + /// Deauthorizes a bot access to a user's information + async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result; + + /// Fetches all deauthorized bots + async fn fetch_deauthorized_authorized_bots(&self) -> Result>; +} diff --git a/crates/core/database/src/models/authorized_bots/ops/mongodb.rs b/crates/core/database/src/models/authorized_bots/ops/mongodb.rs new file mode 100644 index 00000000..876bd7d6 --- /dev/null +++ b/crates/core/database/src/models/authorized_bots/ops/mongodb.rs @@ -0,0 +1,61 @@ +use bson::{Document, to_bson}; +use revolt_result::Result; +use iso8601_timestamp::Timestamp; + +use crate::{MongoDb, AuthorizedBot, AuthorizedBotId}; + +use super::AbstractAuthorizedBots; + +static COL: &str = "authorized_bots"; + +#[async_trait] +impl AbstractAuthorizedBots for MongoDb { + /// Insert emoji into database. + async fn insert_authorized_bot(&self, authorised_bot: &AuthorizedBot) -> Result<()> { + query!(self, insert_one, COL, &authorised_bot).map(|_| ()) + } + + /// Fetch an emoji by its id + async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result { + query!(self, find_one, COL, doc! { "id.user": &id.user, "id.bot": &id.bot })?.ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch an emoji by its id + async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result> { + query!(self, find, COL, doc! { "id.user": &user_id }) + } + + /// Deletes an authori + async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> { + query!(self, delete_one, COL, doc! { "id.user": &id.user, "id.bot": &id.bot }).map(|_| ()) + } + + async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result { + self.col::(COL) + .find_one_and_update( + doc! { + "id.user": &id.user, + "id.bot": &id.bot + }, + doc! { + "$set": { + "unauthorized_at": to_bson(&Timestamp::now_utc()).unwrap() + } + } + ) + .await + .map_err(|_| create_database_error!("find_one_and_update", COL)) + .and_then(|opt| opt.ok_or_else(|| create_database_error!("find_one_and_update", COL))) + } + + async fn fetch_deauthorized_authorized_bots(&self) -> Result> { + query!( + self, + find, + COL, + doc! { + "deauthorized_at": { "$exists": true } + } + ) + } +} \ No newline at end of file diff --git a/crates/core/database/src/models/authorized_bots/ops/reference.rs b/crates/core/database/src/models/authorized_bots/ops/reference.rs new file mode 100644 index 00000000..46360032 --- /dev/null +++ b/crates/core/database/src/models/authorized_bots/ops/reference.rs @@ -0,0 +1,38 @@ +use bson::Document; +use revolt_result::Result; + +use crate::{ReferenceDb, AuthorizedBot, AuthorizedBotId}; + +use super::AbstractAuthorizedBots; + +static COL: &str = "authorized_bots"; + +#[async_trait] +impl AbstractAuthorizedBots for ReferenceDb { + /// Insert emoji into database. + async fn insert_authorized_bot(&self, authorised_bot: &AuthorizedBot) -> Result<()> { + todo!() + } + + /// Fetch an emoji by its id + async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result { + todo!() + } + + async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result> { + todo!() + } + + /// Deletes an authori + async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> { + todo!() + } + + async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result { + todo!() + } + + async fn fetch_deauthorized_authorized_bots(&self) -> Result> { + todo!() + } +} \ No newline at end of file diff --git a/crates/core/database/src/models/mod.rs b/crates/core/database/src/models/mod.rs index 93bd556c..69391a1b 100644 --- a/crates/core/database/src/models/mod.rs +++ b/crates/core/database/src/models/mod.rs @@ -1,4 +1,5 @@ mod admin_migrations; +mod authorized_bots; mod bots; mod channel_invites; mod channel_unreads; @@ -19,6 +20,7 @@ mod user_settings; mod users; pub use admin_migrations::*; +pub use authorized_bots::*; pub use bots::*; pub use channel_invites::*; pub use channel_unreads::*; @@ -46,6 +48,7 @@ use crate::MongoDb; pub trait AbstractDatabase: Sync + Send + + authorized_bots::AbstractAuthorizedBots + admin_migrations::AbstractMigrations + bots::AbstractBots + channels::AbstractChannels diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index 0613cb99..ebf8b9da 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -1454,3 +1454,43 @@ impl From for crate::FieldsMessage { } } } + +impl From for crate::AuthorizedBotId { + fn from(value: AuthorizedBotId) -> Self { + crate::AuthorizedBotId { + user: value.user, + bot: value.bot + } + } +} + +impl From for AuthorizedBotId { + fn from(value: crate::AuthorizedBotId) -> Self { + AuthorizedBotId { + user: value.user, + bot: value.bot + } + } +} + +impl From for crate::AuthorizedBot { + fn from(value: AuthorizedBot) -> Self { + crate::AuthorizedBot { + id: value.id.into(), + created_at: value.created_at, + deauthorized_at: value.deauthorized_at, + scope: value.scope + } + } +} + +impl From for AuthorizedBot { + fn from(value: crate::AuthorizedBot) -> Self { + AuthorizedBot { + id: value.id.into(), + created_at: value.created_at, + deauthorized_at: value.deauthorized_at, + scope: value.scope + } + } +} \ No newline at end of file diff --git a/crates/core/models/src/v0/authorized_bots.rs b/crates/core/models/src/v0/authorized_bots.rs new file mode 100644 index 00000000..88b74f31 --- /dev/null +++ b/crates/core/models/src/v0/authorized_bots.rs @@ -0,0 +1,27 @@ +use iso8601_timestamp::Timestamp; + +auto_derived!( + /// Unique id of the user and bot + pub struct AuthorizedBotId { + /// User id + pub user: String, + + /// Bot Id + pub bot: String, + } + + pub struct AuthorizedBot { + /// Unique Id + #[serde(rename = "_id")] + pub id: AuthorizedBotId, + + /// When the authorized oauth2 bot connection was created at + pub created_at: Timestamp, + + /// If and when the authorized oauth2 bot connection was revoked at + pub deauthorized_at: Option, + + /// Scopes the bot has access to + pub scope: String + } +); \ No newline at end of file diff --git a/crates/core/models/src/v0/mod.rs b/crates/core/models/src/v0/mod.rs index e1910d29..2e7dd92f 100644 --- a/crates/core/models/src/v0/mod.rs +++ b/crates/core/models/src/v0/mod.rs @@ -1,3 +1,4 @@ +mod authorized_bots; mod bots; mod channel_invites; mod channel_unreads; @@ -16,6 +17,7 @@ mod servers; mod user_settings; mod users; +pub use authorized_bots::*; pub use bots::*; pub use channel_invites::*; pub use channel_unreads::*; diff --git a/crates/daemons/crond/src/main.rs b/crates/daemons/crond/src/main.rs index 40af6a2b..53fe5cd6 100644 --- a/crates/daemons/crond/src/main.rs +++ b/crates/daemons/crond/src/main.rs @@ -4,6 +4,8 @@ use revolt_result::Result; use tasks::{file_deletion, prune_dangling_files}; use tokio::try_join; +use crate::tasks::prune_authorized_bots; + pub mod tasks; #[tokio::main] @@ -13,7 +15,8 @@ async fn main() -> Result<()> { let db = DatabaseInfo::Auto.connect().await.expect("database"); try_join!( file_deletion::task(db.clone()), - prune_dangling_files::task(db) + prune_dangling_files::task(db.clone()), + prune_authorized_bots::task(db.clone()), ) .map(|_| ()) } diff --git a/crates/daemons/crond/src/tasks/mod.rs b/crates/daemons/crond/src/tasks/mod.rs index 2a668f14..eba5198a 100644 --- a/crates/daemons/crond/src/tasks/mod.rs +++ b/crates/daemons/crond/src/tasks/mod.rs @@ -1,2 +1,3 @@ pub mod file_deletion; pub mod prune_dangling_files; +pub mod prune_authorized_bots; \ No newline at end of file diff --git a/crates/daemons/crond/src/tasks/prune_authorized_bots.rs b/crates/daemons/crond/src/tasks/prune_authorized_bots.rs new file mode 100644 index 00000000..e8774623 --- /dev/null +++ b/crates/daemons/crond/src/tasks/prune_authorized_bots.rs @@ -0,0 +1,24 @@ +use revolt_database::{iso8601_timestamp::{Duration, Timestamp}, util::oauth2::TokenType, Database}; +use revolt_result::Result; +use tokio::time::sleep; + +use log::info; + +pub async fn task(db: Database) -> Result<()> { + let lifetime = Duration::new(TokenType::Access.lifetime().num_seconds(), 0); + + loop { + let deauthorized_bots = db.fetch_deauthorized_authorized_bots().await?; + + for bot in deauthorized_bots { + if let Some(deauthorized_at) = &bot.deauthorized_at { + if deauthorized_at.saturating_add(lifetime) < Timestamp::now_utc() { + db.delete_authorized_bot(&bot.id).await?; + }; + }; + }; + + // 1 hour + sleep(std::time::Duration::from_secs(60 * 60)).await; + } +} \ No newline at end of file diff --git a/crates/delta/src/routes/oauth2/authorize_auth.rs b/crates/delta/src/routes/oauth2/authorize_auth.rs index e086f693..a83cc3c9 100644 --- a/crates/delta/src/routes/oauth2/authorize_auth.rs +++ b/crates/delta/src/routes/oauth2/authorize_auth.rs @@ -1,6 +1,7 @@ +use iso8601_timestamp::Timestamp; use revolt_config::config; use revolt_database::{ - util::{oauth2, reference::Reference}, Database, User + util::{oauth2, reference::Reference}, AuthorizedBot, AuthorizedBotId, Database, User }; use revolt_models::v0; use revolt_result::{create_error, Result}; @@ -49,7 +50,7 @@ pub async fn auth( return Err(create_error!(InvalidOperation)); }; - oauth2::encode_token( + let token = oauth2::encode_token( &config.api.security.token_secret, oauth2::TokenType::Auth, user.id.clone(), @@ -58,7 +59,16 @@ pub async fn auth( info.scope.clone(), info.code_challenge_method, ) - .map_err(|_| create_error!(InternalError))? + .map_err(|_| create_error!(InternalError))?; + + db.insert_authorized_bot(&AuthorizedBot { + id: AuthorizedBotId { bot: info.client_id.clone(), user: user.id.clone() }, + created_at: Timestamp::now_utc(), + deauthorized_at: None, + scope: info.scope.clone() + }).await?; + + token }, // authorization code v0::OAuth2ResponseType::Token => { diff --git a/crates/delta/src/routes/oauth2/authorized_bots.rs b/crates/delta/src/routes/oauth2/authorized_bots.rs new file mode 100644 index 00000000..de0198a5 --- /dev/null +++ b/crates/delta/src/routes/oauth2/authorized_bots.rs @@ -0,0 +1,20 @@ +use revolt_models::v0; +use rocket::{serde::json::Json, State}; +use revolt_database::{Database, User}; +use revolt_result::Result; + + +#[openapi(tag = "OAuth2")] +#[get("/authorized_bots")] +pub async fn authorized_bots( + db: &State, + user: User +) -> Result>> { + let authorized_bots = db.fetch_users_authorized_bots(&user.id).await?; + + Ok(Json(authorized_bots + .into_iter() + .map(|bot| bot.into()) + .collect() + )) +} \ No newline at end of file diff --git a/crates/delta/src/routes/oauth2/mod.rs b/crates/delta/src/routes/oauth2/mod.rs index e600a77b..c6ea7ecc 100644 --- a/crates/delta/src/routes/oauth2/mod.rs +++ b/crates/delta/src/routes/oauth2/mod.rs @@ -3,6 +3,8 @@ use rocket::Route; mod authorize_auth; mod authorize_info; +mod authorized_bots; +mod revoke; mod token; // TODO @@ -15,6 +17,8 @@ pub fn routes() -> (Vec, OpenApi) { openapi_get_routes_spec![ authorize_auth::auth, authorize_info::info, + authorized_bots::authorized_bots, + revoke::revoke, token::token, ] } diff --git a/crates/delta/src/routes/oauth2/revoke.rs b/crates/delta/src/routes/oauth2/revoke.rs new file mode 100644 index 00000000..b60ddbbb --- /dev/null +++ b/crates/delta/src/routes/oauth2/revoke.rs @@ -0,0 +1,50 @@ +use revolt_config::config; +use revolt_models::v0; +use rocket::{serde::json::Json, State}; +use revolt_database::{util::oauth2::{self, TokenType}, AuthorizedBotId, Database, User}; +use revolt_result::{create_error, Result}; + +/// Revoke an OAuth2 token +/// +/// This can either take user authorization and a client_id, +/// or an OAuth2 token. +#[openapi(tag = "OAuth2")] +#[post("/revoke?&")] +pub async fn revoke( + db: &State, + user: Option, + token: Option<&str>, + client_id: Option<&str> +) -> Result> { + let config = config().await; + + let (user_id, bot_id) = match (user, client_id, token) { + (Some(user), Some(client_id), None) => { + (user.id.clone(), client_id.to_string()) + }, + (_, None, Some(token)) => { + let Ok(claims) = oauth2::decode_token(&config.api.security.token_secret, token) else { + return Err(create_error!(NotAuthenticated)) + }; + + if claims.r#type == TokenType::Auth { + return Err(create_error!(InvalidOperation)) + } + + (claims.sub, claims.client_id) + }, + _ => return Err(create_error!(InvalidOperation)) + }; + + let id = AuthorizedBotId { user: user_id, bot: bot_id }; + + let authorized_bot = db.fetch_authorized_bot(&id).await?; + + if authorized_bot.deauthorized_at.is_some() { + return Err(create_error!(InvalidOperation)) + } + + db.deauthorize_authorized_bot(&id) + .await + .map(|bot| Json(bot.into())) +} \ No newline at end of file diff --git a/crates/delta/src/routes/oauth2/token.rs b/crates/delta/src/routes/oauth2/token.rs index 14c93d6b..1b735c24 100644 --- a/crates/delta/src/routes/oauth2/token.rs +++ b/crates/delta/src/routes/oauth2/token.rs @@ -1,11 +1,11 @@ use chrono::Utc; +use iso8601_timestamp::Timestamp; use revolt_config::config; use revolt_database::{ util::{ oauth2, reference::Reference, - }, - Database, + }, AuthorizedBot, AuthorizedBotId, Database }; use revolt_models::v0; use revolt_result::{create_error, Result}; @@ -64,14 +64,21 @@ pub async fn token( let token = oauth2::encode_token( &config.api.security.token_secret, oauth2::TokenType::Access, - claims.sub, - claims.client_id, + claims.sub.clone(), + claims.client_id.clone(), claims.redirect_uri, claims.scope.clone(), None, ) .map_err(|_| create_error!(InternalError))?; + db.insert_authorized_bot(&AuthorizedBot { + id: AuthorizedBotId { bot: claims.client_id.clone(), user: claims.sub.clone() }, + created_at: Timestamp::now_utc(), + deauthorized_at: None, + scope: claims.scope.clone() + }).await?; + Ok(Json(v0::OAuth2TokenExchangeResponse { access_token: token, token_type: "OAuth2".to_string(), diff --git a/crates/delta/src/routes/users/mod.rs b/crates/delta/src/routes/users/mod.rs index ba7445ac..53eb8980 100644 --- a/crates/delta/src/routes/users/mod.rs +++ b/crates/delta/src/routes/users/mod.rs @@ -12,6 +12,7 @@ mod fetch_user; mod fetch_user_flags; mod find_mutual; mod get_default_avatar; +mod oauth2_connections; mod open_dm; mod remove_friend; mod send_friend_request; diff --git a/crates/delta/src/routes/users/oauth2_connections.rs b/crates/delta/src/routes/users/oauth2_connections.rs new file mode 100644 index 00000000..e69de29b