feat: initial oauth2 implementation
This commit is contained in:
53
Cargo.lock
generated
53
Cargo.lock
generated
@@ -2384,7 +2384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
|
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3806,7 +3806,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"hermit-abi 0.5.1",
|
"hermit-abi 0.5.1",
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3902,6 +3902,21 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "jwt-simple"
|
name = "jwt-simple"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -5370,6 +5385,17 @@ dependencies = [
|
|||||||
"futures-io",
|
"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]]
|
[[package]]
|
||||||
name = "pkcs1"
|
name = "pkcs1"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -6289,12 +6315,14 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"bson",
|
"bson",
|
||||||
|
"chrono",
|
||||||
"deadqueue",
|
"deadqueue",
|
||||||
"decancer",
|
"decancer",
|
||||||
"futures",
|
"futures",
|
||||||
"indexmap 1.9.3",
|
"indexmap 1.9.3",
|
||||||
"isahc",
|
"isahc",
|
||||||
"iso8601-timestamp",
|
"iso8601-timestamp",
|
||||||
|
"jsonwebtoken",
|
||||||
"linkify 0.8.1",
|
"linkify 0.8.1",
|
||||||
"log",
|
"log",
|
||||||
"lru 0.11.1",
|
"lru 0.11.1",
|
||||||
@@ -6345,6 +6373,7 @@ dependencies = [
|
|||||||
"nanoid",
|
"nanoid",
|
||||||
"num_enum 0.5.11",
|
"num_enum 0.5.11",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"pkce",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"redis-kiss",
|
"redis-kiss",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -6899,7 +6928,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.4.15",
|
"linux-raw-sys 0.4.15",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6912,7 +6941,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.9.4",
|
"linux-raw-sys 0.9.4",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7641,6 +7670,18 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
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]]
|
[[package]]
|
||||||
name = "simplecss"
|
name = "simplecss"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -8036,7 +8077,7 @@ dependencies = [
|
|||||||
"getrandom 0.3.3",
|
"getrandom 0.3.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.0.7",
|
"rustix 1.0.7",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9233,7 +9274,7 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ port = 14025
|
|||||||
use_tls = false
|
use_tls = false
|
||||||
use_starttls = false
|
use_starttls = false
|
||||||
|
|
||||||
|
[api.security]
|
||||||
|
token_secret = "trolt"
|
||||||
|
|
||||||
[files.s3]
|
[files.s3]
|
||||||
# S3 protocol endpoint
|
# S3 protocol endpoint
|
||||||
endpoint = "http://127.0.0.1:14009"
|
endpoint = "http://127.0.0.1:14009"
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ voso_legacy_token = ""
|
|||||||
trust_cloudflare = false
|
trust_cloudflare = false
|
||||||
# easypwned endpoint
|
# easypwned endpoint
|
||||||
easypwned = ""
|
easypwned = ""
|
||||||
|
# Secret used to encode and decode tokens
|
||||||
|
token_secret = ""
|
||||||
|
|
||||||
[api.security.captcha]
|
[api.security.captcha]
|
||||||
# hCaptcha configuration
|
# hCaptcha configuration
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ pub struct ApiSecurity {
|
|||||||
pub captcha: ApiSecurityCaptcha,
|
pub captcha: ApiSecurityCaptcha,
|
||||||
pub trust_cloudflare: bool,
|
pub trust_cloudflare: bool,
|
||||||
pub easypwned: String,
|
pub easypwned: String,
|
||||||
|
pub token_secret: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ linkify = { optional = true, version = "0.8.1" }
|
|||||||
url-escape = { optional = true, version = "0.1.1" }
|
url-escape = { optional = true, version = "0.1.1" }
|
||||||
validator = { version = "0.16", features = ["derive"] }
|
validator = { version = "0.16", features = ["derive"] }
|
||||||
isahc = { optional = true, version = "1.7", features = ["json"] }
|
isahc = { optional = true, version = "1.7", features = ["json"] }
|
||||||
|
jsonwebtoken = "9.3.1"
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
# Serialisation
|
# Serialisation
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ auto_derived_partial!(
|
|||||||
#[serde(skip_serializing_if = "String::is_empty", default)]
|
#[serde(skip_serializing_if = "String::is_empty", default)]
|
||||||
pub privacy_policy_url: String,
|
pub privacy_policy_url: String,
|
||||||
|
|
||||||
|
/// Oauth2 bot settings
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub oauth2: Option<BotOauth2>,
|
||||||
|
|
||||||
/// Enum of bot flags
|
/// Enum of bot flags
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub flags: Option<i32>,
|
pub flags: Option<i32>,
|
||||||
@@ -42,11 +46,28 @@ auto_derived_partial!(
|
|||||||
"PartialBot"
|
"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<String>,
|
||||||
|
/// Allowed redirects for the authorisation
|
||||||
|
#[serde(default)]
|
||||||
|
pub redirects: Vec<String>
|
||||||
|
},
|
||||||
|
"PartialBotOauth2"
|
||||||
|
);
|
||||||
|
|
||||||
auto_derived!(
|
auto_derived!(
|
||||||
/// Optional fields on bot object
|
/// Optional fields on bot object
|
||||||
pub enum FieldsBot {
|
pub enum FieldsBot {
|
||||||
Token,
|
Token,
|
||||||
InteractionsURL,
|
InteractionsURL,
|
||||||
|
Oauth2,
|
||||||
|
Oauth2Secret,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,11 +84,23 @@ impl Default for Bot {
|
|||||||
interactions_url: Default::default(),
|
interactions_url: Default::default(),
|
||||||
terms_of_service_url: Default::default(),
|
terms_of_service_url: Default::default(),
|
||||||
privacy_policy_url: Default::default(),
|
privacy_policy_url: Default::default(),
|
||||||
|
oauth2: Default::default(),
|
||||||
flags: 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)]
|
#[allow(clippy::disallowed_methods)]
|
||||||
impl Bot {
|
impl Bot {
|
||||||
/// Create a new bot
|
/// Create a new bot
|
||||||
@@ -124,6 +157,14 @@ impl Bot {
|
|||||||
FieldsBot::Token => self.token = nanoid::nanoid!(64),
|
FieldsBot::Token => self.token = nanoid::nanoid!(64),
|
||||||
FieldsBot::InteractionsURL => {
|
FieldsBot::InteractionsURL => {
|
||||||
self.interactions_url = String::new();
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ impl IntoDocumentPath for FieldsBot {
|
|||||||
match self {
|
match self {
|
||||||
FieldsBot::InteractionsURL => Some("interactions_url"),
|
FieldsBot::InteractionsURL => Some("interactions_url"),
|
||||||
FieldsBot::Token => None,
|
FieldsBot::Token => None,
|
||||||
|
FieldsBot::Oauth2 => Some("oauth2"),
|
||||||
|
FieldsBot::Oauth2Secret => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use authifier::models::Session;
|
use authifier::models::Session;
|
||||||
|
use revolt_config::config;
|
||||||
use rocket::http::Status;
|
use rocket::http::Status;
|
||||||
use rocket::request::{self, FromRequest, Outcome, Request};
|
use rocket::request::{self, FromRequest, Outcome, Request};
|
||||||
|
|
||||||
|
use crate::util::oauth2;
|
||||||
use crate::{Database, User};
|
use crate::{Database, User};
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
@@ -19,12 +21,29 @@ impl<'r> FromRequest<'r> for User {
|
|||||||
.next()
|
.next()
|
||||||
.map(|x| x.to_string());
|
.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 Some(bot_token) = header_bot_token {
|
||||||
if let Ok(bot) = db.fetch_bot_by_token(&bot_token).await {
|
if let Ok(bot) = db.fetch_bot_by_token(&bot_token).await {
|
||||||
if let Ok(user) = db.fetch_user(&bot.id).await {
|
if let Ok(user) = db.fetch_user(&bot.id).await {
|
||||||
return Some(user);
|
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::<Session>().await {
|
} else if let Outcome::Success(session) = request.guard::<Session>().await {
|
||||||
if let Ok(user) = db.fetch_user(&session.user_id).await {
|
if let Ok(user) = db.fetch_user(&session.user_id).await {
|
||||||
return Some(user);
|
return Some(user);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ impl From<crate::Bot> for Bot {
|
|||||||
interactions_url: value.interactions_url,
|
interactions_url: value.interactions_url,
|
||||||
terms_of_service_url: value.terms_of_service_url,
|
terms_of_service_url: value.terms_of_service_url,
|
||||||
privacy_policy_url: value.privacy_policy_url,
|
privacy_policy_url: value.privacy_policy_url,
|
||||||
|
oauth2: value.oauth2.map(|oauth2| oauth2.into()),
|
||||||
flags: value.flags.unwrap_or_default() as u32,
|
flags: value.flags.unwrap_or_default() as u32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,8 @@ impl From<FieldsBot> for crate::FieldsBot {
|
|||||||
match value {
|
match value {
|
||||||
FieldsBot::InteractionsURL => crate::FieldsBot::InteractionsURL,
|
FieldsBot::InteractionsURL => crate::FieldsBot::InteractionsURL,
|
||||||
FieldsBot::Token => crate::FieldsBot::Token,
|
FieldsBot::Token => crate::FieldsBot::Token,
|
||||||
|
FieldsBot::Oauth2 => crate::FieldsBot::Oauth2,
|
||||||
|
FieldsBot::Oauth2Secret => crate::FieldsBot::Oauth2Secret,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +55,28 @@ impl From<crate::FieldsBot> for FieldsBot {
|
|||||||
match value {
|
match value {
|
||||||
crate::FieldsBot::InteractionsURL => FieldsBot::InteractionsURL,
|
crate::FieldsBot::InteractionsURL => FieldsBot::InteractionsURL,
|
||||||
crate::FieldsBot::Token => FieldsBot::Token,
|
crate::FieldsBot::Token => FieldsBot::Token,
|
||||||
|
crate::FieldsBot::Oauth2 => FieldsBot::Oauth2,
|
||||||
|
crate::FieldsBot::Oauth2Secret => FieldsBot::Oauth2Secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BotOauth2> for crate::BotOauth2 {
|
||||||
|
fn from(value: BotOauth2) -> Self {
|
||||||
|
crate::BotOauth2 {
|
||||||
|
public: value.public,
|
||||||
|
secret: value.secret,
|
||||||
|
redirects: value.redirects,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::BotOauth2> for BotOauth2 {
|
||||||
|
fn from(value: crate::BotOauth2) -> Self {
|
||||||
|
BotOauth2 {
|
||||||
|
public: value.public,
|
||||||
|
secret: value.secret,
|
||||||
|
redirects: value.redirects,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ pub mod idempotency;
|
|||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
pub mod reference;
|
pub mod reference;
|
||||||
pub mod test_fixtures;
|
pub mod test_fixtures;
|
||||||
|
pub mod oauth2;
|
||||||
151
crates/core/database/src/util/oauth2.rs
Normal file
151
crates/core/database/src/util/oauth2.rs
Normal file
@@ -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<CodeInfo> {
|
||||||
|
// 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<v0::OAuth2CodeChallangeMethod>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<v0::OAuth2CodeChallangeMethod>,
|
||||||
|
) -> Result<String, Error> {
|
||||||
|
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<Claims, Error> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
use super::User;
|
use super::User;
|
||||||
|
|
||||||
|
#[cfg(feature = "validator")]
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
auto_derived!(
|
auto_derived!(
|
||||||
/// Bot
|
/// Bot
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -48,6 +51,13 @@ auto_derived!(
|
|||||||
)]
|
)]
|
||||||
pub privacy_policy_url: String,
|
pub privacy_policy_url: String,
|
||||||
|
|
||||||
|
/// Oauth2 bot settings
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "serde",
|
||||||
|
serde(skip_serializing_if = "Option::is_none", default)
|
||||||
|
)]
|
||||||
|
pub oauth2: Option<BotOauth2>,
|
||||||
|
|
||||||
/// Enum of bot flags
|
/// Enum of bot flags
|
||||||
#[cfg_attr(
|
#[cfg_attr(
|
||||||
feature = "serde",
|
feature = "serde",
|
||||||
@@ -60,6 +70,8 @@ auto_derived!(
|
|||||||
pub enum FieldsBot {
|
pub enum FieldsBot {
|
||||||
Token,
|
Token,
|
||||||
InteractionsURL,
|
InteractionsURL,
|
||||||
|
Oauth2,
|
||||||
|
Oauth2Secret,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flags that may be attributed to a bot
|
/// Flags that may be attributed to a bot
|
||||||
@@ -101,7 +113,7 @@ auto_derived!(
|
|||||||
|
|
||||||
/// Bot Details
|
/// Bot Details
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
#[cfg_attr(feature = "validator", derive(validator::Validate))]
|
#[cfg_attr(feature = "validator", derive(Validate))]
|
||||||
pub struct DataCreateBot {
|
pub struct DataCreateBot {
|
||||||
/// Bot username
|
/// Bot username
|
||||||
#[cfg_attr(
|
#[cfg_attr(
|
||||||
@@ -113,7 +125,7 @@ auto_derived!(
|
|||||||
|
|
||||||
/// New Bot Details
|
/// New Bot Details
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
#[cfg_attr(feature = "validator", derive(validator::Validate))]
|
#[cfg_attr(feature = "validator", derive(Validate))]
|
||||||
pub struct DataEditBot {
|
pub struct DataEditBot {
|
||||||
/// Bot username
|
/// Bot username
|
||||||
#[cfg_attr(
|
#[cfg_attr(
|
||||||
@@ -131,11 +143,24 @@ auto_derived!(
|
|||||||
/// Interactions URL
|
/// Interactions URL
|
||||||
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2048)))]
|
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2048)))]
|
||||||
pub interactions_url: Option<String>,
|
pub interactions_url: Option<String>,
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "validator", validate)]
|
||||||
|
pub oauth2: Option<DataEditBotOauth2>,
|
||||||
|
|
||||||
/// Fields to remove from bot object
|
/// Fields to remove from bot object
|
||||||
#[cfg_attr(feature = "serde", serde(default))]
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
pub remove: Vec<FieldsBot>,
|
pub remove: Vec<FieldsBot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
#[cfg_attr(feature = "validator", derive(Validate))]
|
||||||
|
pub struct DataEditBotOauth2 {
|
||||||
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
|
pub public: Option<bool>,
|
||||||
|
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 10)))]
|
||||||
|
pub redirects: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Where we are inviting a bot to
|
/// Where we are inviting a bot to
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum InviteBotDestination {
|
pub enum InviteBotDestination {
|
||||||
@@ -170,3 +195,19 @@ auto_derived!(
|
|||||||
pub user: User,
|
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<String>,
|
||||||
|
/// Allowed redirects for the authorisation
|
||||||
|
#[serde(default)]
|
||||||
|
pub redirects: Vec<String>
|
||||||
|
},
|
||||||
|
"PartialBotOauth2"
|
||||||
|
);
|
||||||
@@ -7,6 +7,7 @@ mod embeds;
|
|||||||
mod emojis;
|
mod emojis;
|
||||||
mod files;
|
mod files;
|
||||||
mod messages;
|
mod messages;
|
||||||
|
mod oauth2;
|
||||||
mod policy_changes;
|
mod policy_changes;
|
||||||
mod safety_reports;
|
mod safety_reports;
|
||||||
mod server_bans;
|
mod server_bans;
|
||||||
@@ -24,6 +25,7 @@ pub use embeds::*;
|
|||||||
pub use emojis::*;
|
pub use emojis::*;
|
||||||
pub use files::*;
|
pub use files::*;
|
||||||
pub use messages::*;
|
pub use messages::*;
|
||||||
|
pub use oauth2::*;
|
||||||
pub use policy_changes::*;
|
pub use policy_changes::*;
|
||||||
pub use safety_reports::*;
|
pub use safety_reports::*;
|
||||||
pub use server_bans::*;
|
pub use server_bans::*;
|
||||||
|
|||||||
101
crates/core/models/src/v0/oauth2.rs
Normal file
101
crates/core/models/src/v0/oauth2.rs
Normal file
@@ -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<String>,
|
||||||
|
pub code_challenge: Option<String>,
|
||||||
|
pub code_challenge_method: Option<OAuth2CodeChallangeMethod>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "rocket", derive(rocket::FromForm))]
|
||||||
|
pub struct OAuth2TokenExchangeForm {
|
||||||
|
pub grant_type: OAuth2GrantType,
|
||||||
|
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
|
||||||
|
pub code: String,
|
||||||
|
pub code_verifier: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<OAuth2Scope> {
|
||||||
|
match str {
|
||||||
|
"identify" => Some(OAuth2Scope::Identify),
|
||||||
|
"full" => Some(OAuth2Scope::Full),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scopes_from_str(string: &str) -> Option<Vec<OAuth2Scope>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,5 +82,8 @@ revolt-presence = { path = "../core/presence" }
|
|||||||
revolt-result = { path = "../core/result", features = ["rocket", "okapi"] }
|
revolt-result = { path = "../core/result", features = ["rocket", "okapi"] }
|
||||||
revolt-permissions = { path = "../core/permissions", features = ["schemas"] }
|
revolt-permissions = { path = "../core/permissions", features = ["schemas"] }
|
||||||
|
|
||||||
|
# oauth2
|
||||||
|
pkce = "0.2.0"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
vergen = "7.5.0"
|
vergen = "7.5.0"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use revolt_database::BotOauth2;
|
||||||
use revolt_database::{util::reference::Reference, Database, PartialBot, User};
|
use revolt_database::{util::reference::Reference, Database, PartialBot, User};
|
||||||
use revolt_models::v0::{self, DataEditBot};
|
use revolt_models::v0::{self, DataEditBot};
|
||||||
use revolt_result::{create_error, Result};
|
use revolt_result::{create_error, Result};
|
||||||
@@ -37,7 +38,8 @@ pub async fn edit_bot(
|
|||||||
if data.public.is_none()
|
if data.public.is_none()
|
||||||
&& data.analytics.is_none()
|
&& data.analytics.is_none()
|
||||||
&& data.interactions_url.is_none()
|
&& data.interactions_url.is_none()
|
||||||
&& data.remove.is_empty()
|
&& data.oauth2.is_none()
|
||||||
|
&& data.remove.is_none()
|
||||||
{
|
{
|
||||||
return Ok(Json(v0::BotWithUserResponse {
|
return Ok(Json(v0::BotWithUserResponse {
|
||||||
bot: bot.into(),
|
bot: bot.into(),
|
||||||
@@ -49,17 +51,36 @@ pub async fn edit_bot(
|
|||||||
public,
|
public,
|
||||||
analytics,
|
analytics,
|
||||||
interactions_url,
|
interactions_url,
|
||||||
|
oauth2,
|
||||||
remove,
|
remove,
|
||||||
..
|
..
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
let partial = PartialBot {
|
let mut partial = PartialBot {
|
||||||
public,
|
public,
|
||||||
analytics,
|
analytics,
|
||||||
interactions_url,
|
interactions_url,
|
||||||
..Default::default()
|
..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(
|
bot.update(
|
||||||
db,
|
db,
|
||||||
partial,
|
partial,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mod bots;
|
|||||||
mod channels;
|
mod channels;
|
||||||
mod customisation;
|
mod customisation;
|
||||||
mod invites;
|
mod invites;
|
||||||
|
mod oauth2;
|
||||||
mod onboard;
|
mod onboard;
|
||||||
mod policy;
|
mod policy;
|
||||||
mod push;
|
mod push;
|
||||||
@@ -31,6 +32,7 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
|||||||
"/channels" => channels::routes(),
|
"/channels" => channels::routes(),
|
||||||
"/servers" => servers::routes(),
|
"/servers" => servers::routes(),
|
||||||
"/invites" => invites::routes(),
|
"/invites" => invites::routes(),
|
||||||
|
"/oauth2" => oauth2::routes(),
|
||||||
"/custom" => customisation::routes(),
|
"/custom" => customisation::routes(),
|
||||||
"/safety" => safety::routes(),
|
"/safety" => safety::routes(),
|
||||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||||
@@ -52,6 +54,7 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
|||||||
"/channels" => channels::routes(),
|
"/channels" => channels::routes(),
|
||||||
"/servers" => servers::routes(),
|
"/servers" => servers::routes(),
|
||||||
"/invites" => invites::routes(),
|
"/invites" => invites::routes(),
|
||||||
|
"/oauth2" => oauth2::routes(),
|
||||||
"/custom" => customisation::routes(),
|
"/custom" => customisation::routes(),
|
||||||
"/safety" => safety::routes(),
|
"/safety" => safety::routes(),
|
||||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||||
@@ -74,6 +77,7 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
|||||||
"/channels" => channels::routes(),
|
"/channels" => channels::routes(),
|
||||||
"/servers" => servers::routes(),
|
"/servers" => servers::routes(),
|
||||||
"/invites" => invites::routes(),
|
"/invites" => invites::routes(),
|
||||||
|
"/oauth2" => oauth2::routes(),
|
||||||
"/custom" => customisation::routes(),
|
"/custom" => customisation::routes(),
|
||||||
"/safety" => safety::routes(),
|
"/safety" => safety::routes(),
|
||||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||||
@@ -94,6 +98,7 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
|||||||
"/channels" => channels::routes(),
|
"/channels" => channels::routes(),
|
||||||
"/servers" => servers::routes(),
|
"/servers" => servers::routes(),
|
||||||
"/invites" => invites::routes(),
|
"/invites" => invites::routes(),
|
||||||
|
"/oauth2" => oauth2::routes(),
|
||||||
"/custom" => customisation::routes(),
|
"/custom" => customisation::routes(),
|
||||||
"/safety" => safety::routes(),
|
"/safety" => safety::routes(),
|
||||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||||
|
|||||||
102
crates/delta/src/routes/oauth2/authorize_auth.rs
Normal file
102
crates/delta/src/routes/oauth2/authorize_auth.rs
Normal file
@@ -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="<info>")]
|
||||||
|
pub async fn auth(
|
||||||
|
db: &State<Database>,
|
||||||
|
user: User,
|
||||||
|
info: Form<v0::OAuth2AuthorizationForm>,
|
||||||
|
) -> Result<Json<v0::OAuth2AuthorizeAuthResponse>> {
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
30
crates/delta/src/routes/oauth2/authorize_info.rs
Normal file
30
crates/delta/src/routes/oauth2/authorize_info.rs
Normal file
@@ -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="<info>")]
|
||||||
|
pub async fn info(
|
||||||
|
db: &State<Database>,
|
||||||
|
user: User,
|
||||||
|
info: Form<v0::OAuth2AuthorizationForm>,
|
||||||
|
) -> Result<Json<v0::OAuth2AuthorizeInfoResponse>> {
|
||||||
|
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
|
||||||
|
}))
|
||||||
|
}
|
||||||
20
crates/delta/src/routes/oauth2/mod.rs
Normal file
20
crates/delta/src/routes/oauth2/mod.rs
Normal file
@@ -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<Route>, OpenApi) {
|
||||||
|
openapi_get_routes_spec![
|
||||||
|
authorize_auth::auth,
|
||||||
|
authorize_info::info,
|
||||||
|
token::token,
|
||||||
|
]
|
||||||
|
}
|
||||||
88
crates/delta/src/routes/oauth2/token.rs
Normal file
88
crates/delta/src/routes/oauth2/token.rs
Normal file
@@ -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 = "<info>")]
|
||||||
|
pub async fn token(
|
||||||
|
db: &State<Database>,
|
||||||
|
info: Form<v0::OAuth2TokenExchangeForm>,
|
||||||
|
) -> Result<Json<v0::OAuth2TokenExchangeResponse>> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user