diff --git a/Cargo.lock b/Cargo.lock index bf4db3ba..6afd69be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2384,7 +2384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3806,7 +3806,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi 0.5.1", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3902,6 +3902,21 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem 3.0.5", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "jwt-simple" version = "0.11.9" @@ -5370,6 +5385,17 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkce" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228dbe2aebd82de09c914fe28d36d3170ed5192e8d52b9c070ee0794519c2d3" +dependencies = [ + "base64 0.21.7", + "rand 0.8.5", + "sha2", +] + [[package]] name = "pkcs1" version = "0.4.1" @@ -6289,12 +6315,14 @@ dependencies = [ "axum", "base64 0.21.7", "bson", + "chrono", "deadqueue", "decancer", "futures", "indexmap 1.9.3", "isahc", "iso8601-timestamp", + "jsonwebtoken", "linkify 0.8.1", "log", "lru 0.11.1", @@ -6345,6 +6373,7 @@ dependencies = [ "nanoid", "num_enum 0.5.11", "once_cell", + "pkce", "rand 0.8.5", "redis-kiss", "regex", @@ -6899,7 +6928,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6912,7 +6941,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7641,6 +7670,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.12", + "time", +] + [[package]] name = "simplecss" version = "0.2.2" @@ -8036,7 +8077,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.7", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -9233,7 +9274,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Revolt.toml b/Revolt.toml index 33e04434..657d6b74 100644 --- a/Revolt.toml +++ b/Revolt.toml @@ -40,6 +40,9 @@ port = 14025 use_tls = false use_starttls = false +[api.security] +token_secret = "trolt" + [files.s3] # S3 protocol endpoint endpoint = "http://127.0.0.1:14009" diff --git a/crates/core/config/Revolt.toml b/crates/core/config/Revolt.toml index e3a04f2a..f43edb59 100644 --- a/crates/core/config/Revolt.toml +++ b/crates/core/config/Revolt.toml @@ -56,6 +56,8 @@ voso_legacy_token = "" trust_cloudflare = false # easypwned endpoint easypwned = "" +# Secret used to encode and decode tokens +token_secret = "" [api.security.captcha] # hCaptcha configuration diff --git a/crates/core/config/src/lib.rs b/crates/core/config/src/lib.rs index 6c4d58de..2c54296e 100644 --- a/crates/core/config/src/lib.rs +++ b/crates/core/config/src/lib.rs @@ -190,6 +190,7 @@ pub struct ApiSecurity { pub captcha: ApiSecurityCaptcha, pub trust_cloudflare: bool, pub easypwned: String, + pub token_secret: String, } #[derive(Deserialize, Debug, Clone)] diff --git a/crates/core/database/Cargo.toml b/crates/core/database/Cargo.toml index 02e55411..cd7fe362 100644 --- a/crates/core/database/Cargo.toml +++ b/crates/core/database/Cargo.toml @@ -53,6 +53,8 @@ linkify = { optional = true, version = "0.8.1" } url-escape = { optional = true, version = "0.1.1" } validator = { version = "0.16", features = ["derive"] } isahc = { optional = true, version = "1.7", features = ["json"] } +jsonwebtoken = "9.3.1" +chrono = "0.4" # Serialisation serde_json = "1" diff --git a/crates/core/database/src/models/bots/model.rs b/crates/core/database/src/models/bots/model.rs index 24c56bb1..d145163c 100644 --- a/crates/core/database/src/models/bots/model.rs +++ b/crates/core/database/src/models/bots/model.rs @@ -35,6 +35,10 @@ auto_derived_partial!( #[serde(skip_serializing_if = "String::is_empty", default)] pub privacy_policy_url: String, + /// Oauth2 bot settings + #[serde(skip_serializing_if = "Option::is_none", default)] + pub oauth2: Option, + /// Enum of bot flags #[serde(skip_serializing_if = "Option::is_none")] pub flags: Option, @@ -42,11 +46,28 @@ auto_derived_partial!( "PartialBot" ); +auto_derived_partial!( + pub struct BotOauth2 { + /// Whether the oauth2 client is public and should not receive a secret key + #[serde(default)] + pub public: bool, + /// Secret key used for authorisation, not provided if the client is public + #[serde(default)] + pub secret: Option, + /// Allowed redirects for the authorisation + #[serde(default)] + pub redirects: Vec + }, + "PartialBotOauth2" +); + auto_derived!( /// Optional fields on bot object pub enum FieldsBot { Token, InteractionsURL, + Oauth2, + Oauth2Secret, } ); @@ -63,11 +84,23 @@ impl Default for Bot { interactions_url: Default::default(), terms_of_service_url: Default::default(), privacy_policy_url: Default::default(), + oauth2: Default::default(), flags: Default::default(), } } } +#[allow(clippy::derivable_impls)] +impl Default for BotOauth2 { + fn default() -> Self { + Self { + public: false, + secret: Some(nanoid::nanoid!(64)), + redirects: Default::default() + } + } +} + #[allow(clippy::disallowed_methods)] impl Bot { /// Create a new bot @@ -124,6 +157,14 @@ impl Bot { FieldsBot::Token => self.token = nanoid::nanoid!(64), FieldsBot::InteractionsURL => { self.interactions_url = String::new(); + }, + FieldsBot::Oauth2 => self.oauth2 = None, + FieldsBot::Oauth2Secret => { + if let Some(oauth2) = &mut self.oauth2 { + if !oauth2.public { + oauth2.secret = Some(nanoid::nanoid!(64)) + } + } } } } diff --git a/crates/core/database/src/models/bots/ops/mongodb.rs b/crates/core/database/src/models/bots/ops/mongodb.rs index b141e8c5..48caf81c 100644 --- a/crates/core/database/src/models/bots/ops/mongodb.rs +++ b/crates/core/database/src/models/bots/ops/mongodb.rs @@ -87,6 +87,8 @@ impl IntoDocumentPath for FieldsBot { match self { FieldsBot::InteractionsURL => Some("interactions_url"), FieldsBot::Token => None, + FieldsBot::Oauth2 => Some("oauth2"), + FieldsBot::Oauth2Secret => None, } } } diff --git a/crates/core/database/src/models/users/rocket.rs b/crates/core/database/src/models/users/rocket.rs index 57b634b1..5b12bde5 100644 --- a/crates/core/database/src/models/users/rocket.rs +++ b/crates/core/database/src/models/users/rocket.rs @@ -1,7 +1,9 @@ use authifier::models::Session; +use revolt_config::config; use rocket::http::Status; use rocket::request::{self, FromRequest, Outcome, Request}; +use crate::util::oauth2; use crate::{Database, User}; #[rocket::async_trait] @@ -19,12 +21,29 @@ impl<'r> FromRequest<'r> for User { .next() .map(|x| x.to_string()); + let header_oauth_token = request + .headers() + .get("x-oauth2-token") + .next() + .map(|x| x.to_string()); + if let Some(bot_token) = header_bot_token { if let Ok(bot) = db.fetch_bot_by_token(&bot_token).await { if let Ok(user) = db.fetch_user(&bot.id).await { return Some(user); } } + } else if let Some(header_oauth_token) = header_oauth_token { + let config = config().await; + + let claims = oauth2::decode_token(&config.api.security.token_secret, &header_oauth_token).ok()?; + + if oauth2::scopes_can_access_route(&claims.scope, request) { + if let Ok(user) = db.fetch_user(&claims.sub).await { + return Some(user) + } + } + } else if let Outcome::Success(session) = request.guard::().await { if let Ok(user) = db.fetch_user(&session.user_id).await { return Some(user); diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index d667094f..f7b62e99 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -33,6 +33,7 @@ impl From for Bot { interactions_url: value.interactions_url, terms_of_service_url: value.terms_of_service_url, privacy_policy_url: value.privacy_policy_url, + oauth2: value.oauth2.map(|oauth2| oauth2.into()), flags: value.flags.unwrap_or_default() as u32, } } @@ -43,6 +44,8 @@ impl From for crate::FieldsBot { match value { FieldsBot::InteractionsURL => crate::FieldsBot::InteractionsURL, FieldsBot::Token => crate::FieldsBot::Token, + FieldsBot::Oauth2 => crate::FieldsBot::Oauth2, + FieldsBot::Oauth2Secret => crate::FieldsBot::Oauth2Secret, } } } @@ -52,6 +55,28 @@ impl From for FieldsBot { match value { crate::FieldsBot::InteractionsURL => FieldsBot::InteractionsURL, crate::FieldsBot::Token => FieldsBot::Token, + crate::FieldsBot::Oauth2 => FieldsBot::Oauth2, + crate::FieldsBot::Oauth2Secret => FieldsBot::Oauth2Secret, + } + } +} + +impl From for crate::BotOauth2 { + fn from(value: BotOauth2) -> Self { + crate::BotOauth2 { + public: value.public, + secret: value.secret, + redirects: value.redirects, + } + } +} + +impl From for BotOauth2 { + fn from(value: crate::BotOauth2) -> Self { + BotOauth2 { + public: value.public, + secret: value.secret, + redirects: value.redirects, } } } diff --git a/crates/core/database/src/util/mod.rs b/crates/core/database/src/util/mod.rs index 1baf7d8b..1d372384 100644 --- a/crates/core/database/src/util/mod.rs +++ b/crates/core/database/src/util/mod.rs @@ -4,3 +4,4 @@ pub mod idempotency; pub mod permissions; pub mod reference; pub mod test_fixtures; +pub mod oauth2; \ No newline at end of file diff --git a/crates/core/database/src/util/oauth2.rs b/crates/core/database/src/util/oauth2.rs new file mode 100644 index 00000000..9fe5a83c --- /dev/null +++ b/crates/core/database/src/util/oauth2.rs @@ -0,0 +1,151 @@ +use chrono::{TimeDelta, Utc}; +use jsonwebtoken::{decode, encode, errors::Error, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Serialize, Deserialize}; +use revolt_models::v0; + +#[cfg(feature = "rocket")] +use rocket::{http::Method, Request}; + +// use redis_kiss::{get_connection, redis::Pipeline, AsyncCommands}; +// use revolt_result::Result; + +// pub async fn add_access_token(code: &str, redirect_uri: &str, client_id: &str) -> Result<()> { +// let conn = get_connection() +// .await +// .map_err(|_| create_error!(InternalError))?; + +// // 10 mins +// Pipeline::new() +// .pset_ex(format!("oauth:{code}:client_id"), client_id, 600000) +// .pset_ex(format!("oauth:{code}:redirect_uri"), redirect_uri, 600000) +// .query_async::<_, ()>(&mut conn.into_inner()) +// .await +// .map_err(|_| create_error!(InternalError))?; + +// Ok(()) +// } + +// pub struct CodeInfo { +// pub redirect_uri: String, +// pub client_id: String +// } + +// pub async fn get_access_token_info(code: &str) -> Result { +// let conn = get_connection() +// .await +// .map_err(|_| create_error!(InternalError))?; + +// let (redirect_uri, client_id) = Pipeline::new() +// .get(format!("oauth:{code}:client_id")) +// .get(format!("oauth:{code}:redirect_uri")) +// .query_async::<_, (String, String)>(&mut conn.into_inner()) +// .await +// .map_err(|_| create_error!(InternalError))?; + +// Ok(CodeInfo { redirect_uri, client_id }) +// } + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum TokenType { + Auth, + Access, + Refresh +} + +impl TokenType { + pub fn lifetime(self) -> TimeDelta { + match self { + TokenType::Access => TimeDelta::weeks(1), + TokenType::Auth => TimeDelta::minutes(5), + TokenType::Refresh => TimeDelta::weeks(4), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, + pub exp: i64, + + pub r#type: TokenType, + pub client_id: String, + pub scope: String, + pub redirect_uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code_challange_method: Option, +} + +#[allow(clippy::too_many_arguments)] +pub fn encode_token( + token_secret: &str, + token_type: TokenType, + user_id: String, + client_id: String, + redirect_uri: String, + scope: String, + code_challange_method: Option, +) -> Result { + let exp = Utc::now() + .checked_add_signed(token_type.lifetime()) + .unwrap() + .timestamp(); + + let claims = Claims { + sub: user_id, + exp, + + r#type: token_type, + client_id, + scope, + redirect_uri, + code_challange_method + }; + + let encoding_key = EncodingKey::from_secret(token_secret.as_bytes()); + + encode(&Header::default(), &claims, &encoding_key) +} + +pub fn decode_token(token_secret: &str, code: &str) -> Result { + let decoding_key = DecodingKey::from_secret(token_secret.as_bytes()); + + let data = decode(code, &decoding_key, &Validation::new(jsonwebtoken::Algorithm::HS256))?; + + Ok(data.claims) +} + +#[cfg(feature = "rocket")] +pub fn scope_can_access_route(scope: &str, request: &Request<'_>) -> bool { + // TODO: figure out why request.segments(0..) is skipping the first segment + let mut segments = request.uri().path().segments(); + + if segments.get(0) == Some("0.8") { + segments.next(); // skip first segment + }; + + match scope { + "identify" => { + println!("{:?}", request.method() == Method::Get); + println!("{:?}", segments.get(0)); + println!("{:?}", segments.get(1)); + + request.method() == Method::Get && + segments.get(0) == Some("users") && + segments.get(1) == Some("@me") + }, + "full" => true, + _ => false + } +} + +#[cfg(feature = "rocket")] +pub fn scopes_can_access_route(scopes: &str, request: &Request<'_>) -> bool { + println!("{scopes}"); + for scope in scopes.split(' ') { + if scope_can_access_route(scope, request) { + return true + } + } + + false +} \ No newline at end of file diff --git a/crates/core/models/src/v0/bots.rs b/crates/core/models/src/v0/bots.rs index 70c66c46..9c38eb23 100644 --- a/crates/core/models/src/v0/bots.rs +++ b/crates/core/models/src/v0/bots.rs @@ -1,5 +1,8 @@ use super::User; +#[cfg(feature = "validator")] +use validator::Validate; + auto_derived!( /// Bot #[derive(Default)] @@ -48,6 +51,13 @@ auto_derived!( )] pub privacy_policy_url: String, + /// Oauth2 bot settings + #[cfg_attr( + feature = "serde", + serde(skip_serializing_if = "Option::is_none", default) + )] + pub oauth2: Option, + /// Enum of bot flags #[cfg_attr( feature = "serde", @@ -60,6 +70,8 @@ auto_derived!( pub enum FieldsBot { Token, InteractionsURL, + Oauth2, + Oauth2Secret, } /// Flags that may be attributed to a bot @@ -101,7 +113,7 @@ auto_derived!( /// Bot Details #[derive(Default)] - #[cfg_attr(feature = "validator", derive(validator::Validate))] + #[cfg_attr(feature = "validator", derive(Validate))] pub struct DataCreateBot { /// Bot username #[cfg_attr( @@ -113,7 +125,7 @@ auto_derived!( /// New Bot Details #[derive(Default)] - #[cfg_attr(feature = "validator", derive(validator::Validate))] + #[cfg_attr(feature = "validator", derive(Validate))] pub struct DataEditBot { /// Bot username #[cfg_attr( @@ -131,11 +143,24 @@ auto_derived!( /// Interactions URL #[cfg_attr(feature = "validator", validate(length(min = 1, max = 2048)))] pub interactions_url: Option, + + #[cfg_attr(feature = "validator", validate)] + pub oauth2: Option, + /// Fields to remove from bot object #[cfg_attr(feature = "serde", serde(default))] pub remove: Vec, } + #[derive(Default)] + #[cfg_attr(feature = "validator", derive(Validate))] + pub struct DataEditBotOauth2 { + #[cfg_attr(feature = "serde", serde(default))] + pub public: Option, + #[cfg_attr(feature = "validator", validate(length(min = 1, max = 10)))] + pub redirects: Option>, + } + /// Where we are inviting a bot to #[serde(untagged)] pub enum InviteBotDestination { @@ -170,3 +195,19 @@ auto_derived!( pub user: User, } ); + +auto_derived_partial!( + /// Bot Oauth2 information + pub struct BotOauth2 { + /// Whether the oauth client is public and should not receive a secret key + #[serde(default)] + pub public: bool, + /// Secret key used for authorisation, not provided if the client is public + #[serde(default)] + pub secret: Option, + /// Allowed redirects for the authorisation + #[serde(default)] + pub redirects: Vec + }, + "PartialBotOauth2" +); \ 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 0468493b..e1910d29 100644 --- a/crates/core/models/src/v0/mod.rs +++ b/crates/core/models/src/v0/mod.rs @@ -7,6 +7,7 @@ mod embeds; mod emojis; mod files; mod messages; +mod oauth2; mod policy_changes; mod safety_reports; mod server_bans; @@ -24,6 +25,7 @@ pub use embeds::*; pub use emojis::*; pub use files::*; pub use messages::*; +pub use oauth2::*; pub use policy_changes::*; pub use safety_reports::*; pub use server_bans::*; diff --git a/crates/core/models/src/v0/oauth2.rs b/crates/core/models/src/v0/oauth2.rs new file mode 100644 index 00000000..9bc5aacb --- /dev/null +++ b/crates/core/models/src/v0/oauth2.rs @@ -0,0 +1,101 @@ +use crate::v0::{Bot, User}; + +auto_derived!( + pub struct OAuth2AuthorizeAuthResponse { + /// Redirect URI which will contain the access token + pub redirect_uri: String, + } + + pub struct OAuth2AuthorizeInfoResponse { + pub bot: Bot, + pub user: User, + } + + #[derive(Copy)] + #[cfg_attr(feature = "serde", serde(rename = "lowercase"))] + #[cfg_attr(feature = "rocket", derive(rocket::FromFormField))] + pub enum OAuth2ResponseType { + #[cfg_attr(feature = "rocket", field(value = "code"))] + Code, + #[cfg_attr(feature = "rocket", field(value = "token"))] + Token, + } + + #[derive(Copy)] + #[cfg_attr(feature = "rocket", derive(rocket::FromFormField))] + pub enum OAuth2GrantType { + #[cfg_attr(feature = "rocket", field(value = "code"))] + #[cfg_attr(feature = "serde", serde(rename = "code"))] + AuthorizationCode, + #[cfg_attr(feature = "rocket", field(value = "implicit"))] + #[cfg_attr(feature = "serde", serde(rename = "implicit"))] + Implicit, + } + + #[derive(Copy)] + #[cfg_attr(feature = "rocket", derive(rocket::FromFormField))] + pub enum OAuth2CodeChallangeMethod { + #[cfg_attr(feature = "rocket", field(value = "plain"))] + #[cfg_attr(feature = "serde", serde(rename = "plain"))] + Plain, + S256 + } + + #[cfg_attr(feature = "rocket", derive(rocket::FromForm))] + pub struct OAuth2AuthorizationForm { + pub client_id: String, + pub scope: String, + pub redirect_uri: String, + pub response_type: OAuth2ResponseType, + pub state: Option, + pub code_challenge: Option, + pub code_challenge_method: Option, + } + + #[cfg_attr(feature = "rocket", derive(rocket::FromForm))] + pub struct OAuth2TokenExchangeForm { + pub grant_type: OAuth2GrantType, + + pub client_id: String, + pub client_secret: Option, + + pub code: String, + pub code_verifier: Option, + } + + pub struct OAuth2TokenExchangeResponse { + pub access_token: String, + pub token_type: String, + pub scope: String, + } + + pub enum OAuth2Scope { + Identify, + Full, + } +); + +impl OAuth2Scope { + #[allow(clippy::should_implement_trait)] + pub fn from_str(str: &str) -> Option { + match str { + "identify" => Some(OAuth2Scope::Identify), + "full" => Some(OAuth2Scope::Full), + _ => None, + } + } + + pub fn scopes_from_str(string: &str) -> Option> { + let mut scopes = Vec::new(); + + for scope in string.split(' ') { + if let Some(scope) = OAuth2Scope::from_str(scope) { + scopes.push(scope); + } else { + return None; + } + } + + Some(scopes) + } +} diff --git a/crates/delta/Cargo.toml b/crates/delta/Cargo.toml index d07b44bb..698c1b03 100644 --- a/crates/delta/Cargo.toml +++ b/crates/delta/Cargo.toml @@ -82,5 +82,8 @@ revolt-presence = { path = "../core/presence" } revolt-result = { path = "../core/result", features = ["rocket", "okapi"] } revolt-permissions = { path = "../core/permissions", features = ["schemas"] } +# oauth2 +pkce = "0.2.0" + [build-dependencies] vergen = "7.5.0" diff --git a/crates/delta/src/routes/bots/edit.rs b/crates/delta/src/routes/bots/edit.rs index 6c454e13..6338dd59 100644 --- a/crates/delta/src/routes/bots/edit.rs +++ b/crates/delta/src/routes/bots/edit.rs @@ -1,3 +1,4 @@ +use revolt_database::BotOauth2; use revolt_database::{util::reference::Reference, Database, PartialBot, User}; use revolt_models::v0::{self, DataEditBot}; use revolt_result::{create_error, Result}; @@ -37,7 +38,8 @@ pub async fn edit_bot( if data.public.is_none() && data.analytics.is_none() && data.interactions_url.is_none() - && data.remove.is_empty() + && data.oauth2.is_none() + && data.remove.is_none() { return Ok(Json(v0::BotWithUserResponse { bot: bot.into(), @@ -49,17 +51,36 @@ pub async fn edit_bot( public, analytics, interactions_url, + oauth2, remove, .. } = data; - let partial = PartialBot { + let mut partial = PartialBot { public, analytics, interactions_url, ..Default::default() }; + if let Some(edit_oauth2) = oauth2 { + let mut oauth2 = bot.oauth2.clone().unwrap_or_default(); + + if let Some(public) = edit_oauth2.public { + if oauth2.public && !public { + oauth2.secret = Some(nanoid::nanoid!(64)) + } else if !oauth2.public && public { + oauth2.secret = None; + }; + + oauth2.public = public; + } + + oauth2.redirects = edit_oauth2.redirects.unwrap_or(oauth2.redirects); + + partial.oauth2 = Some(oauth2) + } + bot.update( db, partial, diff --git a/crates/delta/src/routes/mod.rs b/crates/delta/src/routes/mod.rs index 9643a365..e7c22001 100644 --- a/crates/delta/src/routes/mod.rs +++ b/crates/delta/src/routes/mod.rs @@ -8,6 +8,7 @@ mod bots; mod channels; mod customisation; mod invites; +mod oauth2; mod onboard; mod policy; mod push; @@ -31,6 +32,7 @@ pub fn mount(config: Settings, mut rocket: Rocket) -> Rocket { "/channels" => channels::routes(), "/servers" => servers::routes(), "/invites" => invites::routes(), + "/oauth2" => oauth2::routes(), "/custom" => customisation::routes(), "/safety" => safety::routes(), "/auth/account" => rocket_authifier::routes::account::routes(), @@ -52,6 +54,7 @@ pub fn mount(config: Settings, mut rocket: Rocket) -> Rocket { "/channels" => channels::routes(), "/servers" => servers::routes(), "/invites" => invites::routes(), + "/oauth2" => oauth2::routes(), "/custom" => customisation::routes(), "/safety" => safety::routes(), "/auth/account" => rocket_authifier::routes::account::routes(), @@ -74,6 +77,7 @@ pub fn mount(config: Settings, mut rocket: Rocket) -> Rocket { "/channels" => channels::routes(), "/servers" => servers::routes(), "/invites" => invites::routes(), + "/oauth2" => oauth2::routes(), "/custom" => customisation::routes(), "/safety" => safety::routes(), "/auth/account" => rocket_authifier::routes::account::routes(), @@ -94,6 +98,7 @@ pub fn mount(config: Settings, mut rocket: Rocket) -> Rocket { "/channels" => channels::routes(), "/servers" => servers::routes(), "/invites" => invites::routes(), + "/oauth2" => oauth2::routes(), "/custom" => customisation::routes(), "/safety" => safety::routes(), "/auth/account" => rocket_authifier::routes::account::routes(), diff --git a/crates/delta/src/routes/oauth2/authorize_auth.rs b/crates/delta/src/routes/oauth2/authorize_auth.rs new file mode 100644 index 00000000..68d94baa --- /dev/null +++ b/crates/delta/src/routes/oauth2/authorize_auth.rs @@ -0,0 +1,102 @@ +use revolt_config::config; +use revolt_database::{ + util::{oauth2, reference::Reference}, + Database, User, +}; +use revolt_models::v0; +use revolt_result::{create_error, Result}; +use rocket::{form::Form, serde::json::Json, State}; +use redis_kiss::AsyncCommands; + +/// # OAuth2 Authorization Auth +/// +/// Generates an OAuth2 code to be passed to the redirect URI. +#[openapi(tag = "OAuth2")] +#[post("/authorize", data="")] +pub async fn auth( + db: &State, + user: User, + info: Form, +) -> Result> { + let bot = Reference::from_unchecked(info.client_id.clone()) + .as_bot(db) + .await?; + + let Some(oauth2) = &bot.oauth2 else { + return Err(create_error!(InvalidOperation)); + }; + + if !oauth2.redirects.contains(&info.redirect_uri) || v0::OAuth2Scope::scopes_from_str(&info.scope).is_none() { + return Err(create_error!(InvalidOperation)); + }; + + let config = config().await; + + let code = match info.response_type { + // implicit + v0::OAuth2ResponseType::Code => { + if info.state.is_some() || info.code_challenge.is_some() || info.code_challenge_method.is_some() { + return Err(create_error!(InvalidOperation)); + }; + + oauth2::encode_token( + &config.api.security.token_secret, + oauth2::TokenType::Auth, + user.id.clone(), + info.client_id.clone(), + info.redirect_uri.clone(), + info.scope.clone(), + info.code_challenge_method, + ) + .map_err(|_| create_error!(InternalError))? + }, + // authorization code + v0::OAuth2ResponseType::Token => { + if let Some(((state, code_challange), code_challange_method)) = info.state.as_ref().zip(info.code_challenge.as_ref()).zip(info.code_challenge_method) { + let is_valid = match code_challange_method { + v0::OAuth2CodeChallangeMethod::Plain => state == code_challange, + v0::OAuth2CodeChallangeMethod::S256 => &pkce::code_challenge(state.as_bytes()) == code_challange, + }; + + if !is_valid || state.len() <= 43 || state.len() >= 128 { + return Err(create_error!(InvalidOperation)) + } + + } else if !oauth2.public { + return Err(create_error!(InvalidOperation)) + }; + + let code = oauth2::encode_token( + &config.api.security.token_secret, + oauth2::TokenType::Access, + user.id.clone(), + info.client_id.clone(), + info.redirect_uri.clone(), + info.scope.clone(), + info.code_challenge_method, + ) + .map_err(|_| create_error!(InternalError))?; + + if let Some(code_challenge) = info.code_challenge.as_ref() { + let mut conn = redis_kiss::get_connection() + .await + .map_err(|_| create_error!(InternalError))?; + + conn.pset_ex::<_, _, ()>( + format!("oauth2:{code}:code_challenge"), + code_challenge, + oauth2::TokenType::Access.lifetime().num_milliseconds() as usize + ) + .await + .map_err(|_| create_error!(InternalError))?; + }; + + code + + }, + }; + + let redirect_uri = format!("{}/?code={code}", &info.redirect_uri); + + Ok(Json(v0::OAuth2AuthorizeAuthResponse { redirect_uri })) +} diff --git a/crates/delta/src/routes/oauth2/authorize_info.rs b/crates/delta/src/routes/oauth2/authorize_info.rs new file mode 100644 index 00000000..a6631cfe --- /dev/null +++ b/crates/delta/src/routes/oauth2/authorize_info.rs @@ -0,0 +1,30 @@ +use revolt_database::{util::reference::Reference, Database, User}; +use revolt_models::v0; +use revolt_result::{create_error, Result}; +use rocket::{form::Form, serde::json::Json, State}; + +/// # Authorize OAuth Information +/// +/// Fetches the information needed for displaying on the OAuth grant page +#[openapi(tag = "OAuth2")] +#[get("/authorize", data="")] +pub async fn info( + db: &State, + user: User, + info: Form, +) -> Result> { + let bot = Reference::from_unchecked(info.client_id.to_string()).as_bot(db).await?; + + let Some(oauth2) = &bot.oauth2 else { + return Err(create_error!(InvalidOperation)); + }; + + if !oauth2.redirects.contains(&info.redirect_uri) || v0::OAuth2Scope::scopes_from_str(&info.scope).is_none() { + return Err(create_error!(InvalidOperation)); + }; + + Ok(Json(v0::OAuth2AuthorizeInfoResponse { + bot: bot.into(), + user: user.into(db, None).await + })) +} \ No newline at end of file diff --git a/crates/delta/src/routes/oauth2/mod.rs b/crates/delta/src/routes/oauth2/mod.rs new file mode 100644 index 00000000..e600a77b --- /dev/null +++ b/crates/delta/src/routes/oauth2/mod.rs @@ -0,0 +1,20 @@ +use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi; +use rocket::Route; + +mod authorize_auth; +mod authorize_info; +mod token; + +// TODO +// Scopes: +// - identity +// - servers +// - full + +pub fn routes() -> (Vec, OpenApi) { + openapi_get_routes_spec![ + authorize_auth::auth, + authorize_info::info, + token::token, + ] +} diff --git a/crates/delta/src/routes/oauth2/token.rs b/crates/delta/src/routes/oauth2/token.rs new file mode 100644 index 00000000..9dffbcbe --- /dev/null +++ b/crates/delta/src/routes/oauth2/token.rs @@ -0,0 +1,88 @@ +use chrono::Utc; +use revolt_config::config; +use revolt_database::{ + util::{ + oauth2, + reference::Reference, + }, + Database, +}; +use revolt_models::v0; +use revolt_result::{create_error, Result}; +use rocket::{form::Form, serde::json::Json, State}; +use redis_kiss::AsyncCommands; + +/// # OAuth Token Exchange +/// +/// +#[openapi(tag = "OAuth2")] +#[post("/token", data = "")] +pub async fn token( + db: &State, + info: Form, +) -> Result> { + let bot = Reference::from_unchecked(info.client_id.clone()) + .as_bot(db) + .await?; + + let config = config().await; + + let claims = oauth2::decode_token(&config.api.security.token_secret, &info.code) + .map_err(|_| create_error!(NotAuthenticated))?; + + if let Some((client_secret, bot_secret)) = info.client_secret.as_ref().zip(bot.oauth2.as_ref().and_then(|oauth2| oauth2.secret.as_ref())) { + if client_secret != bot_secret { + return Err(create_error!(NotAuthenticated)) + } + } else if let Some((code_verifier, method)) = info.code_verifier.as_ref().zip(claims.code_challange_method) { + let mut conn = redis_kiss::get_connection() + .await + .map_err(|_| create_error!(InternalError))?; + + let server_code_challenge = conn.get::<_, String>(format!("oauth2:{}:code_challenge", &info.code)) + .await + .map_err(|_| create_error!(InternalError))?; + + let is_valid = match method { + v0::OAuth2CodeChallangeMethod::Plain => &server_code_challenge == code_verifier, + v0::OAuth2CodeChallangeMethod::S256 => pkce::code_challenge(code_verifier.as_bytes()) == server_code_challenge, + }; + + if !is_valid { + return Err(create_error!(NotAuthenticated)) + } + } + + if claims.client_id != info.client_id || claims.exp < Utc::now().timestamp() { + return Err(create_error!(NotAuthenticated)); + }; + + match info.grant_type { + v0::OAuth2GrantType::AuthorizationCode => { + if claims.r#type != oauth2::TokenType::Auth { + return Err(create_error!(InvalidOperation)) + } + + let token = oauth2::encode_token( + &config.api.security.token_secret, + oauth2::TokenType::Access, + claims.sub, + claims.client_id, + claims.redirect_uri, + claims.scope.clone(), + None, + ) + .map_err(|_| create_error!(InternalError))?; + + Ok(Json(v0::OAuth2TokenExchangeResponse { + access_token: token, + token_type: "OAuth2".to_string(), + scope: claims.scope, + })) + }, + v0::OAuth2GrantType::Implicit => { + Err(create_error!(InvalidOperation)) + } + } +} + \ No newline at end of file