mirror of
https://github.com/stoatchat/stoatchat.git
synced 2026-07-06 11:16:00 +00:00
Compare commits
13 Commits
v0.9.3
...
feat/oauth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6043ba6004 | ||
|
|
67773d3e43 | ||
|
|
14ea180683 | ||
|
|
83c15404a5 | ||
|
|
9d4bcb5e3d | ||
|
|
f895ca7b23 | ||
|
|
ef65223c89 | ||
|
|
660e646b2b | ||
|
|
5a5f84f207 | ||
|
|
11eee02cfe | ||
|
|
1e5b27ff9e | ||
|
|
3ae25fbcfe | ||
|
|
9e05e5be7e |
53
Cargo.lock
generated
53
Cargo.lock
generated
@@ -2397,7 +2397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3958,6 +3958,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"
|
||||
@@ -4308,9 +4323,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
@@ -5437,6 +5452,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"
|
||||
@@ -6393,12 +6419,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",
|
||||
@@ -6449,6 +6477,7 @@ dependencies = [
|
||||
"nanoid",
|
||||
"num_enum 0.5.11",
|
||||
"once_cell",
|
||||
"pkce",
|
||||
"rand 0.8.5",
|
||||
"redis-kiss",
|
||||
"regex",
|
||||
@@ -7035,15 +7064,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.2"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7813,6 +7842,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.16",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simplecss"
|
||||
version = "0.2.2"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -17,9 +17,11 @@ use redis_kiss::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
|
||||
use revolt_config::report_internal_error;
|
||||
use revolt_database::{
|
||||
events::{client::EventV1, server::ClientMessage},
|
||||
util::oauth2,
|
||||
iso8601_timestamp::Timestamp,
|
||||
Database, User, UserHint,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_presence::{create_session, delete_session};
|
||||
|
||||
use async_std::{
|
||||
@@ -88,23 +90,51 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
|
||||
return;
|
||||
};
|
||||
|
||||
// Presume the token is a proper token first
|
||||
let (user, session_id) = match User::from_token(db, token, UserHint::Any).await {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
write
|
||||
.send(config.encode(&EventV1::Error { data: err }))
|
||||
Ok((user, session_id)) => {
|
||||
db.update_session_last_seen(&session_id, Timestamp::now_utc())
|
||||
.await
|
||||
.ok();
|
||||
return;
|
||||
|
||||
(user, session_id)
|
||||
},
|
||||
Err(err) => {
|
||||
let revolt_config = revolt_config::config().await;
|
||||
|
||||
// If it fails to find the user from the token see if its an OAuth2 token
|
||||
let res = match oauth2::decode_token(&revolt_config.api.security.token_secret, token) {
|
||||
// Check if the OAuth2 token is allowed to establish an events websocket
|
||||
Ok(claims) => if !claims.scopes.contains(&v0::OAuth2Scope::Events) {
|
||||
// TODO: maybe a last_seen system for OAuth2 as well
|
||||
db.fetch_user(&claims.sub).await.map(|user| (user, claims.jti))
|
||||
} else {
|
||||
Err(create_error!(MissingScope { scope: v0::OAuth2Scope::Events.to_string() }))
|
||||
},
|
||||
// If its expired return an error
|
||||
Err(e) => if e.into_kind() == oauth2::JWTErrorKind::ExpiredSignature {
|
||||
Err(create_error!(ExpiredToken))
|
||||
} else {
|
||||
// Finally re-return the error from User::from_token if everything else fails to avoid a confusing error
|
||||
Err(err)
|
||||
}
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
write
|
||||
.send(config.encode(&EventV1::Error { data: err }))
|
||||
.await
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
info!("User {addr:?} authenticated as @{}", user.username);
|
||||
|
||||
db.update_session_last_seen(&session_id, Timestamp::now_utc())
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Create local state.
|
||||
let mut state = State::from(user, session_id);
|
||||
let user_id = state.cache.user_id.clone();
|
||||
|
||||
@@ -56,6 +56,8 @@ voso_legacy_token = ""
|
||||
trust_cloudflare = false
|
||||
# easypwned endpoint
|
||||
easypwned = ""
|
||||
# Secret used to encode and decode tokens
|
||||
token_secret = ""
|
||||
# Tenor API Key
|
||||
tenor_key = ""
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ pub struct ApiSecurity {
|
||||
pub captcha: ApiSecurityCaptcha,
|
||||
pub trust_cloudflare: bool,
|
||||
pub easypwned: String,
|
||||
pub token_secret: String,
|
||||
pub tenor_key: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,13 +5,14 @@ use futures::lock::Mutex;
|
||||
use crate::{
|
||||
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member,
|
||||
MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot,
|
||||
User, UserSettings, Webhook,
|
||||
User, UserSettings, Webhook, AuthorizedBotId, AuthorizedBot,
|
||||
};
|
||||
|
||||
database_derived!(
|
||||
/// Reference implementation
|
||||
#[derive(Default)]
|
||||
pub struct ReferenceDb {
|
||||
pub authorized_bots: Arc<Mutex<HashMap<AuthorizedBotId, AuthorizedBot>>>,
|
||||
pub bots: Arc<Mutex<HashMap<String, Bot>>>,
|
||||
pub channels: Arc<Mutex<HashMap<String, Channel>>>,
|
||||
pub channel_invites: Arc<Mutex<HashMap<String, Invite>>>,
|
||||
|
||||
5
crates/core/database/src/models/authorized_bots/mod.rs
Normal file
5
crates/core/database/src/models/authorized_bots/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod model;
|
||||
mod ops;
|
||||
|
||||
pub use model::*;
|
||||
pub use ops::*;
|
||||
30
crates/core/database/src/models/authorized_bots/model.rs
Normal file
30
crates/core/database/src/models/authorized_bots/model.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use iso8601_timestamp::Timestamp;
|
||||
|
||||
use crate::OAuth2Scope;
|
||||
|
||||
auto_derived! (
|
||||
/// Unique id of the user and bot
|
||||
#[derive(Hash)]
|
||||
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<Timestamp>,
|
||||
|
||||
/// Scopes the bot has access to
|
||||
pub scope: Vec<OAuth2Scope>,
|
||||
}
|
||||
);
|
||||
27
crates/core/database/src/models/authorized_bots/ops.rs
Normal file
27
crates/core/database/src/models/authorized_bots/ops.rs
Normal file
@@ -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, authorized_bot: &AuthorizedBot) -> Result<()>;
|
||||
|
||||
/// Fetch an authorized bot by its id
|
||||
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot>;
|
||||
|
||||
/// Fetch a users authorized bot by its id
|
||||
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>>;
|
||||
|
||||
/// Deletes an authorized bot
|
||||
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()>;
|
||||
|
||||
/// Deauthorizes an authorized bot
|
||||
async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot>;
|
||||
|
||||
// Fetches all authorized bots which have been deauthorized
|
||||
async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use bson::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 an authorized bot into database.
|
||||
async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()> {
|
||||
query!(self, insert_one, COL, &authorized_bot).map(|_| ())
|
||||
}
|
||||
|
||||
/// Fetch an authorized bot by its id
|
||||
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
|
||||
query!(
|
||||
self,
|
||||
find_one,
|
||||
COL,
|
||||
doc! {
|
||||
"_id.user": &id.user,
|
||||
"_id.bot": &id.bot
|
||||
}
|
||||
)?.ok_or_else(|| create_error!(NotFound))
|
||||
}
|
||||
|
||||
/// Fetch a users authorized bot by its id
|
||||
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>> {
|
||||
query!(self, find, COL, doc! { "_id.user": &user_id })
|
||||
}
|
||||
|
||||
/// Deletes an authorized bot
|
||||
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> {
|
||||
query!(self, delete_one, COL, doc! { "_id.user": &id.user, "_id.bot": &id.bot }).map(|_| ())
|
||||
}
|
||||
|
||||
/// Deauthorizes an authorized bot
|
||||
async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
|
||||
self.col::<AuthorizedBot>(COL)
|
||||
.find_one_and_update(
|
||||
doc! {
|
||||
"_id.user": &id.user,
|
||||
"_id.bot": &id.bot
|
||||
},
|
||||
doc! {
|
||||
"$set": {
|
||||
"deauthorized_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)))
|
||||
}
|
||||
|
||||
// Fetches all authorized bots which have been deauthorized
|
||||
async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>> {
|
||||
query!(
|
||||
self,
|
||||
find,
|
||||
COL,
|
||||
doc! {
|
||||
"deauthorized_at": { "$exists": true }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use revolt_result::Result;
|
||||
use iso8601_timestamp::Timestamp;
|
||||
|
||||
use crate::{ReferenceDb, AuthorizedBot, AuthorizedBotId};
|
||||
|
||||
use super::AbstractAuthorizedBots;
|
||||
|
||||
#[async_trait]
|
||||
impl AbstractAuthorizedBots for ReferenceDb {
|
||||
/// Insert an authorized bot into database.
|
||||
async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()> {
|
||||
let mut authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
if authorized_bots.contains_key(&authorized_bot.id) {
|
||||
Err(create_database_error!("insert", "authorized_bots"))
|
||||
} else {
|
||||
authorized_bots.insert(authorized_bot.id.clone(), authorized_bot.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch an authorized bot by its id
|
||||
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
|
||||
let authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
authorized_bots.get(id).cloned().ok_or_else(|| create_error!(NotFound))
|
||||
}
|
||||
|
||||
/// Fetch a users authorized bot by its id
|
||||
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>> {
|
||||
let authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
Ok(authorized_bots
|
||||
.values()
|
||||
.filter(|authorized_bot| authorized_bot.id.user == user_id)
|
||||
.cloned()
|
||||
.collect()
|
||||
)
|
||||
}
|
||||
|
||||
/// Deletes an authorized bot
|
||||
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> {
|
||||
let mut authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
if authorized_bots.remove(id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(create_error!(NotFound))
|
||||
}
|
||||
}
|
||||
|
||||
/// Deauthorizes an authorized bot
|
||||
async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
|
||||
let mut authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
if let Some(authorized_bot) = authorized_bots.get_mut(id) {
|
||||
authorized_bot.deauthorized_at = Some(Timestamp::now_utc());
|
||||
|
||||
Ok(authorized_bot.clone())
|
||||
} else {
|
||||
Err(create_error!(NotFound))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches all authorized bots which have been deauthorized
|
||||
async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>> {
|
||||
let authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
Ok(authorized_bots
|
||||
.values()
|
||||
.filter(|authorized_bot| authorized_bot.deauthorized_at.is_some())
|
||||
.cloned()
|
||||
.collect()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use revolt_result::Result;
|
||||
use ulid::Ulid;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{events::client::EventV1, BotInformation, Database, PartialUser, User};
|
||||
|
||||
@@ -35,6 +36,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<BotOauth2>,
|
||||
|
||||
/// Enum of bot flags
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flags: Option<i32>,
|
||||
@@ -42,11 +47,52 @@ auto_derived_partial!(
|
||||
"PartialBot"
|
||||
);
|
||||
|
||||
auto_derived!(
|
||||
#[derive(Copy, Hash)]
|
||||
pub enum OAuth2Scope {
|
||||
#[serde(rename = "read:identify")]
|
||||
ReadIdentify,
|
||||
#[serde(rename = "read:servers")]
|
||||
ReadServers,
|
||||
#[serde(rename = "write:files")]
|
||||
WriteFiles,
|
||||
#[serde(rename = "events")]
|
||||
Events,
|
||||
#[serde(rename = "full")]
|
||||
Full,
|
||||
}
|
||||
|
||||
pub struct OAuth2ScopeReasoning {
|
||||
pub allow: String,
|
||||
pub deny: String
|
||||
}
|
||||
);
|
||||
|
||||
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>,
|
||||
/// Mapping of allowed scopes and the reasonings
|
||||
#[serde(default)]
|
||||
pub allowed_scopes: HashMap<OAuth2Scope, OAuth2ScopeReasoning>,
|
||||
},
|
||||
"PartialBotOauth2"
|
||||
);
|
||||
|
||||
auto_derived!(
|
||||
/// Optional fields on bot object
|
||||
pub enum FieldsBot {
|
||||
Token,
|
||||
InteractionsURL,
|
||||
Oauth2,
|
||||
Oauth2Secret,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -63,11 +109,24 @@ 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: Vec::new(),
|
||||
allowed_scopes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
impl Bot {
|
||||
/// Create a new bot
|
||||
@@ -124,6 +183,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use axum::{extract::{FromRef, FromRequestParts}, http::request::Parts};
|
||||
|
||||
use revolt_config::config;
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Error, Result};
|
||||
|
||||
use crate::{Database, User};
|
||||
use crate::{util::oauth2, Database, OAuth2Scope, User};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<S> FromRequestParts<S> for User
|
||||
@@ -23,6 +25,24 @@ where
|
||||
{
|
||||
let session = db.fetch_session_by_token(session_token).await?;
|
||||
db.fetch_user(&session.user_id).await
|
||||
} else if let Some(Ok(header_oauth_token)) = parts.headers.get("x-oauth2-token").map(|v| v.to_str()) {
|
||||
let config = config().await;
|
||||
|
||||
let claims = oauth2::decode_token(
|
||||
&config.api.security.token_secret,
|
||||
header_oauth_token,
|
||||
).map_err(|_| create_error!(NotAuthenticated))?;
|
||||
|
||||
let required_scope: v0::OAuth2Scope = parts.extensions.get::<OAuth2Scope>()
|
||||
.copied()
|
||||
.ok_or_else(|| create_error!(NotAuthenticated))?
|
||||
.into();
|
||||
|
||||
if claims.scopes.contains(&v0::OAuth2Scope::Full) || claims.scopes.contains(&required_scope) {
|
||||
db.fetch_user(&claims.sub).await
|
||||
} else {
|
||||
Err(create_error!(MissingScope { scope: required_scope.to_string() }))
|
||||
}
|
||||
} else {
|
||||
Err(create_error!(NotAuthenticated))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use authifier::models::Session;
|
||||
use revolt_config::config;
|
||||
use revolt_models::v0;
|
||||
use rocket::http::Status;
|
||||
use rocket::request::{self, FromRequest, Outcome, Request};
|
||||
|
||||
use crate::util::oauth2;
|
||||
use crate::{Database, User};
|
||||
|
||||
#[rocket::async_trait]
|
||||
@@ -19,12 +22,38 @@ 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()?;
|
||||
|
||||
// access the scope required for the route stored in the request local cache set via `OAuth2Scoped`
|
||||
let required_scope: v0::OAuth2Scope = request.local_cache(|| None::<crate::OAuth2Scope>)
|
||||
.as_ref()
|
||||
.copied()?
|
||||
.into();
|
||||
|
||||
if claims.scopes.contains(&v0::OAuth2Scope::Full) || claims.scopes.contains(&required_scope) {
|
||||
if let Ok(user) = db.fetch_user(&claims.sub).await {
|
||||
return Some(user);
|
||||
}
|
||||
}
|
||||
} else if let Outcome::Success(session) = request.guard::<Session>().await {
|
||||
if let Ok(user) = db.fetch_user(&session.user_id).await {
|
||||
return Some(user);
|
||||
|
||||
@@ -33,6 +33,7 @@ impl From<crate::Bot> 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<FieldsBot> 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,78 @@ impl From<crate::FieldsBot> 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<BotOauth2> for crate::BotOauth2 {
|
||||
fn from(value: BotOauth2) -> Self {
|
||||
crate::BotOauth2 {
|
||||
public: value.public,
|
||||
secret: value.secret,
|
||||
redirects: value.redirects,
|
||||
allowed_scopes: value.allowed_scopes
|
||||
.into_iter()
|
||||
.map(|(scope, value)| (scope.into(), value.into()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::BotOauth2> for BotOauth2 {
|
||||
fn from(value: crate::BotOauth2) -> Self {
|
||||
BotOauth2 {
|
||||
public: value.public,
|
||||
secret: value.secret,
|
||||
redirects: value.redirects,
|
||||
allowed_scopes: value.allowed_scopes
|
||||
.into_iter()
|
||||
.map(|(scope, value)| (scope.into(), value.into()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::OAuth2Scope> for OAuth2Scope {
|
||||
fn from(value: crate::OAuth2Scope) -> Self {
|
||||
match value {
|
||||
crate::OAuth2Scope::ReadIdentify => OAuth2Scope::ReadIdentify,
|
||||
crate::OAuth2Scope::ReadServers => OAuth2Scope::ReadServers,
|
||||
crate::OAuth2Scope::WriteFiles => OAuth2Scope::WriteFiles,
|
||||
crate::OAuth2Scope::Events => OAuth2Scope::Events,
|
||||
crate::OAuth2Scope::Full => OAuth2Scope::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OAuth2Scope> for crate::OAuth2Scope {
|
||||
fn from(value: OAuth2Scope) -> Self {
|
||||
match value {
|
||||
OAuth2Scope::ReadIdentify => crate::OAuth2Scope::ReadIdentify,
|
||||
OAuth2Scope::ReadServers => crate::OAuth2Scope::ReadServers,
|
||||
OAuth2Scope::WriteFiles => crate::OAuth2Scope::WriteFiles,
|
||||
OAuth2Scope::Events => crate::OAuth2Scope::Events,
|
||||
OAuth2Scope::Full => crate::OAuth2Scope::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::OAuth2ScopeReasoning> for OAuth2ScopeReasoning {
|
||||
fn from(value: crate::OAuth2ScopeReasoning) -> Self {
|
||||
OAuth2ScopeReasoning {
|
||||
allow: value.allow,
|
||||
deny: value.deny,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OAuth2ScopeReasoning> for crate::OAuth2ScopeReasoning {
|
||||
fn from(value: OAuth2ScopeReasoning) -> Self {
|
||||
crate::OAuth2ScopeReasoning {
|
||||
allow: value.allow,
|
||||
deny: value.deny,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1387,3 +1462,43 @@ impl From<FieldsMessage> for crate::FieldsMessage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthorizedBotId> for crate::AuthorizedBotId {
|
||||
fn from(value: AuthorizedBotId) -> Self {
|
||||
crate::AuthorizedBotId {
|
||||
user: value.user,
|
||||
bot: value.bot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::AuthorizedBotId> for AuthorizedBotId {
|
||||
fn from(value: crate::AuthorizedBotId) -> Self {
|
||||
AuthorizedBotId {
|
||||
user: value.user,
|
||||
bot: value.bot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthorizedBot> 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.into_iter().map(|scope| scope.into()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::AuthorizedBot> 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.into_iter().map(|scope| scope.into()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ pub mod idempotency;
|
||||
pub mod permissions;
|
||||
pub mod reference;
|
||||
pub mod test_fixtures;
|
||||
pub mod oauth2;
|
||||
17
crates/core/database/src/util/oauth2/axum.rs
Normal file
17
crates/core/database/src/util/oauth2/axum.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||
use revolt_result::{Error, Result};
|
||||
|
||||
use super::{OAuth2Scoped, scopes::OAuth2Scope};
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<S: Send + Sync, Scope: OAuth2Scope> FromRequestParts<S> for OAuth2Scoped<Scope> {
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self> {
|
||||
parts.extensions.insert(Scope::SCOPE);
|
||||
|
||||
Ok(OAuth2Scoped { _scope: PhantomData })
|
||||
}
|
||||
}
|
||||
120
crates/core/database/src/util/oauth2/mod.rs
Normal file
120
crates/core/database/src/util/oauth2/mod.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use jsonwebtoken::{decode, encode, errors::Error, DecodingKey, EncodingKey, Header, Validation};
|
||||
use redis_kiss::AsyncCommands;
|
||||
use revolt_models::v0;
|
||||
use revolt_result::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod scopes;
|
||||
pub use scopes::OAuth2Scoped;
|
||||
|
||||
#[cfg(feature = "rocket")]
|
||||
pub mod rocket;
|
||||
|
||||
#[cfg(feature = "axum")]
|
||||
pub mod axum;
|
||||
|
||||
pub use jsonwebtoken::errors::{Error as JWTError, ErrorKind as JWTErrorKind};
|
||||
use ulid::Ulid;
|
||||
|
||||
#[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 jti: String,
|
||||
pub sub: String,
|
||||
pub exp: i64,
|
||||
|
||||
pub r#type: TokenType,
|
||||
pub client_id: String,
|
||||
pub scopes: Vec<v0::OAuth2Scope>,
|
||||
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,
|
||||
scopes: Vec<v0::OAuth2Scope>,
|
||||
code_challange_method: Option<v0::OAuth2CodeChallangeMethod>,
|
||||
) -> Result<String, Error> {
|
||||
let now = Utc::now();
|
||||
|
||||
let exp = now.checked_add_signed(token_type.lifetime()).unwrap();
|
||||
|
||||
println!("{now:?} {exp:}");
|
||||
|
||||
let claims = Claims {
|
||||
jti: Ulid::new().to_string(),
|
||||
sub: user_id,
|
||||
exp: exp.timestamp(),
|
||||
|
||||
r#type: token_type,
|
||||
client_id,
|
||||
scopes,
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn add_code_challange(token: &str, code_challenge: &str) -> Result<()> {
|
||||
let mut conn = redis_kiss::get_connection()
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
conn.pset_ex::<_, _, ()>(
|
||||
format!("oauth2:{token}:code_challenge"),
|
||||
code_challenge,
|
||||
TokenType::Access.lifetime().num_milliseconds() as usize,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_code_challange(token: &str) -> Result<Option<String>> {
|
||||
let mut conn = redis_kiss::get_connection()
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
conn.get(format!("oauth2:{token}:code_challenge"))
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))
|
||||
}
|
||||
53
crates/core/database/src/util/oauth2/rocket.rs
Normal file
53
crates/core/database/src/util/oauth2/rocket.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::{convert::Infallible, marker::PhantomData};
|
||||
|
||||
use revolt_okapi::openapi3::{OAuthFlows, SecurityScheme, SecuritySchemeData};
|
||||
use revolt_rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput};
|
||||
use rocket::{request::{self, FromRequest}, Request};
|
||||
|
||||
use super::{OAuth2Scoped, scopes::OAuth2Scope};
|
||||
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r, Scope: OAuth2Scope> FromRequest<'r> for OAuth2Scoped<Scope> {
|
||||
type Error = Infallible;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
|
||||
request.local_cache(|| Some(Scope::SCOPE));
|
||||
|
||||
request::Outcome::Success(OAuth2Scoped { _scope: PhantomData })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r, Scope: OAuth2Scope> OpenApiFromRequest<'r> for OAuth2Scoped<Scope> {
|
||||
fn from_request_input(
|
||||
_gen: &mut revolt_rocket_okapi::r#gen::OpenApiGenerator,
|
||||
_name: String,
|
||||
_required: bool,
|
||||
) -> revolt_rocket_okapi::Result<revolt_rocket_okapi::request::RequestHeaderInput> {
|
||||
Ok(RequestHeaderInput::Security(
|
||||
"OAuth2".to_owned(),
|
||||
SecurityScheme {
|
||||
description: None,
|
||||
extensions: Default::default(),
|
||||
data: SecuritySchemeData::OAuth2 {
|
||||
flows: OAuthFlows::AuthorizationCode {
|
||||
authorization_url: "todo".to_string(),
|
||||
token_url: "todo".to_string(),
|
||||
refresh_url: Some("todo".to_string()),
|
||||
scopes: revolt_okapi::map! {
|
||||
"read:identify".to_string() => "".to_string(),
|
||||
"read:servers".to_string() => "".to_string(),
|
||||
"write:files".to_string() => "".to_string(),
|
||||
"events".to_string() => "".to_string(),
|
||||
"full".to_string() => "".to_string(),
|
||||
},
|
||||
extensions: Default::default()
|
||||
}
|
||||
}
|
||||
},
|
||||
revolt_okapi::map! {
|
||||
"OAuth2".to_owned() => vec![Scope::MODEL.to_string()]
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
29
crates/core/database/src/util/oauth2/scopes.rs
Normal file
29
crates/core/database/src/util/oauth2/scopes.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
pub struct OAuth2Scoped<Scope> {
|
||||
pub(crate) _scope: PhantomData<Scope>
|
||||
}
|
||||
|
||||
pub trait OAuth2Scope {
|
||||
const SCOPE: crate::OAuth2Scope;
|
||||
const MODEL: revolt_models::v0::OAuth2Scope;
|
||||
}
|
||||
|
||||
macro_rules! define_oauth2_scope {
|
||||
($struct_name:ident) => {
|
||||
pub struct $struct_name;
|
||||
|
||||
impl OAuth2Scope for $struct_name {
|
||||
const SCOPE: crate::OAuth2Scope = crate::OAuth2Scope::$struct_name;
|
||||
const MODEL: revolt_models::v0::OAuth2Scope = revolt_models::v0::OAuth2Scope::$struct_name;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// This must match the OAuth2Scope enum
|
||||
// TODO: automatically sync this
|
||||
define_oauth2_scope!(ReadIdentify);
|
||||
define_oauth2_scope!(ReadServers);
|
||||
define_oauth2_scope!(WriteFiles);
|
||||
define_oauth2_scope!(Events);
|
||||
define_oauth2_scope!(Full);
|
||||
34
crates/core/models/src/v0/authorized_bots.rs
Normal file
34
crates/core/models/src/v0/authorized_bots.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use iso8601_timestamp::Timestamp;
|
||||
|
||||
use crate::v0::{PublicBot, OAuth2Scope};
|
||||
|
||||
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<Timestamp>,
|
||||
|
||||
/// Scopes the bot has access to
|
||||
pub scope: Vec<OAuth2Scope>,
|
||||
}
|
||||
|
||||
pub struct AuthorizedBotsResponse {
|
||||
pub public_bot: PublicBot,
|
||||
pub authorized_bot: AuthorizedBot,
|
||||
}
|
||||
);
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::User;
|
||||
use super::{User, OAuth2Scope, OAuth2ScopeReasoning};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(feature = "validator")]
|
||||
use validator::Validate;
|
||||
|
||||
auto_derived!(
|
||||
/// Bot
|
||||
@@ -48,6 +52,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<BotOauth2>,
|
||||
|
||||
/// Enum of bot flags
|
||||
#[cfg_attr(
|
||||
feature = "serde",
|
||||
@@ -60,6 +71,8 @@ auto_derived!(
|
||||
pub enum FieldsBot {
|
||||
Token,
|
||||
InteractionsURL,
|
||||
Oauth2,
|
||||
Oauth2Secret,
|
||||
}
|
||||
|
||||
/// Flags that may be attributed to a bot
|
||||
@@ -101,7 +114,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 +126,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 +144,24 @@ auto_derived!(
|
||||
/// Interactions URL
|
||||
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2048)))]
|
||||
pub interactions_url: Option<String>,
|
||||
|
||||
#[cfg_attr(feature = "validator", validate)]
|
||||
pub oauth2: Option<DataEditBotOauth2>,
|
||||
|
||||
/// Fields to remove from bot object
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub remove: Vec<FieldsBot>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[cfg_attr(feature = "validator", derive(Validate))]
|
||||
pub struct DataEditBotOauth2 {
|
||||
pub public: Option<bool>,
|
||||
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 10)))]
|
||||
pub redirects: Option<Vec<String>>,
|
||||
pub allowed_scopes: Option<HashMap<OAuth2Scope, OAuth2ScopeReasoning>>
|
||||
}
|
||||
|
||||
/// Where we are inviting a bot to
|
||||
#[serde(untagged)]
|
||||
pub enum InviteBotDestination {
|
||||
@@ -170,3 +196,22 @@ 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<String>,
|
||||
/// Allowed redirects for the authorisation
|
||||
#[serde(default)]
|
||||
pub redirects: Vec<String>,
|
||||
/// Mapping of allowed scopes and the reasonings
|
||||
#[serde(default)]
|
||||
pub allowed_scopes: HashMap<OAuth2Scope, OAuth2ScopeReasoning>,
|
||||
},
|
||||
"PartialBotOauth2"
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
mod authorized_bots;
|
||||
mod bots;
|
||||
mod channel_invites;
|
||||
mod channel_unreads;
|
||||
@@ -7,6 +8,7 @@ mod embeds;
|
||||
mod emojis;
|
||||
mod files;
|
||||
mod messages;
|
||||
mod oauth2;
|
||||
mod policy_changes;
|
||||
mod safety_reports;
|
||||
mod server_bans;
|
||||
@@ -15,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::*;
|
||||
@@ -24,6 +27,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::*;
|
||||
|
||||
140
crates/core/models/src/v0/oauth2.rs
Normal file
140
crates/core/models/src/v0/oauth2.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use crate::v0::{PublicBot, User};
|
||||
use std::{collections::HashMap, fmt::Display};
|
||||
|
||||
auto_derived!(
|
||||
pub struct OAuth2AuthorizeAuthResponse {
|
||||
/// Redirect URI which will contain the access token
|
||||
pub redirect_uri: String,
|
||||
}
|
||||
|
||||
pub struct OAuth2ScopeReasoning {
|
||||
pub allow: String,
|
||||
pub deny: String
|
||||
}
|
||||
|
||||
pub struct OAuth2AuthorizeInfoResponse {
|
||||
pub bot: PublicBot,
|
||||
pub user: User,
|
||||
pub allowed_scopes: HashMap<OAuth2Scope, OAuth2ScopeReasoning>
|
||||
}
|
||||
|
||||
#[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 = "authorization_code"))]
|
||||
#[cfg_attr(feature = "serde", serde(rename = "authorization_code"))]
|
||||
AuthorizationCode,
|
||||
#[cfg_attr(feature = "rocket", field(value = "implicit"))]
|
||||
#[cfg_attr(feature = "serde", serde(rename = "implicit"))]
|
||||
Implicit,
|
||||
#[cfg_attr(feature = "rocket", field(value = "refresh_token"))]
|
||||
#[cfg_attr(feature = "serde", serde(rename = "refresh_token"))]
|
||||
RefreshToken
|
||||
}
|
||||
|
||||
#[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>,
|
||||
|
||||
/// Authorization code
|
||||
pub code: Option<String>,
|
||||
// Refresh token to generate new access token
|
||||
pub refresh_token: Option<String>,
|
||||
|
||||
pub code_verifier: Option<String>,
|
||||
}
|
||||
|
||||
pub struct OAuth2TokenExchangeResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub token_type: String,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Hash)]
|
||||
#[cfg_attr(feature = "serde", serde(rename = "snake_case"))]
|
||||
pub enum OAuth2Scope {
|
||||
#[cfg_attr(feature = "serde", serde(rename = "read:identify"))]
|
||||
ReadIdentify,
|
||||
#[cfg_attr(feature = "serde", serde(rename = "read:servers"))]
|
||||
ReadServers,
|
||||
#[cfg_attr(feature = "serde", serde(rename = "write:files"))]
|
||||
WriteFiles,
|
||||
#[cfg_attr(feature = "serde", serde(rename = "events"))]
|
||||
Events,
|
||||
#[cfg_attr(feature = "serde", serde(rename = "full"))]
|
||||
Full,
|
||||
}
|
||||
);
|
||||
|
||||
impl Display for OAuth2Scope {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
OAuth2Scope::ReadIdentify => "read:identify",
|
||||
OAuth2Scope::ReadServers => "read:servers",
|
||||
OAuth2Scope::WriteFiles => "write:files",
|
||||
OAuth2Scope::Events => "events",
|
||||
OAuth2Scope::Full => "full",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuth2Scope {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(str: &str) -> Option<OAuth2Scope> {
|
||||
match str {
|
||||
"read:identify" => Some(OAuth2Scope::ReadIdentify),
|
||||
"read:servers" => Some(OAuth2Scope::ReadServers),
|
||||
"events" => Some(OAuth2Scope::Events),
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,9 @@ impl IntoResponse for Error {
|
||||
ErrorType::InvalidFlagValue => StatusCode::BAD_REQUEST,
|
||||
ErrorType::FeatureDisabled { .. } => StatusCode::BAD_REQUEST,
|
||||
|
||||
ErrorType::MissingScope { .. } => StatusCode::UNAUTHORIZED,
|
||||
ErrorType::ExpiredToken => StatusCode::UNAUTHORIZED,
|
||||
|
||||
ErrorType::ProxyError => StatusCode::BAD_REQUEST,
|
||||
ErrorType::FileTooSmall => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
ErrorType::FileTooLarge { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
||||
@@ -51,7 +51,7 @@ impl std::error::Error for Error {}
|
||||
#[cfg_attr(feature = "serde", serde(tag = "type"))]
|
||||
#[cfg_attr(feature = "schemas", derive(JsonSchema))]
|
||||
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum ErrorType {
|
||||
/// This error was not labeled :(
|
||||
LabelMe,
|
||||
@@ -168,6 +168,12 @@ pub enum ErrorType {
|
||||
ImageProcessingFailed,
|
||||
NoEmbedData,
|
||||
|
||||
// ? OAuth2 Errors
|
||||
ExpiredToken,
|
||||
MissingScope {
|
||||
scope: String
|
||||
},
|
||||
|
||||
// ? Legacy errors
|
||||
VosoUnavailable,
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ impl<'r> Responder<'r, 'static> for Error {
|
||||
ErrorType::FailedValidation { .. } => Status::BadRequest,
|
||||
ErrorType::FeatureDisabled { .. } => Status::BadRequest,
|
||||
|
||||
ErrorType::MissingScope { .. } => Status::Unauthorized,
|
||||
ErrorType::ExpiredToken => Status::Unauthorized,
|
||||
|
||||
ErrorType::ProxyError => Status::BadRequest,
|
||||
ErrorType::FileTooSmall => Status::UnprocessableEntity,
|
||||
ErrorType::FileTooLarge { .. } => Status::UnprocessableEntity,
|
||||
|
||||
@@ -4,6 +4,8 @@ use revolt_result::Result;
|
||||
use tasks::{file_deletion, prune_dangling_files, prune_members};
|
||||
use tokio::try_join;
|
||||
|
||||
use crate::tasks::prune_authorized_bots;
|
||||
|
||||
pub mod tasks;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -14,6 +16,7 @@ async fn main() -> Result<()> {
|
||||
try_join!(
|
||||
file_deletion::task(db.clone()),
|
||||
prune_dangling_files::task(db.clone()),
|
||||
prune_authorized_bots::task(db.clone()),
|
||||
prune_members::task(db.clone())
|
||||
)
|
||||
.map(|_| ())
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod file_deletion;
|
||||
pub mod prune_dangling_files;
|
||||
pub mod prune_authorized_bots;
|
||||
pub mod prune_members;
|
||||
|
||||
22
crates/daemons/crond/src/tasks/prune_authorized_bots.rs
Normal file
22
crates/daemons/crond/src/tasks/prune_authorized_bots.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use revolt_database::{iso8601_timestamp::{Duration, Timestamp}, util::oauth2::TokenType, Database};
|
||||
use revolt_result::Result;
|
||||
use tokio::time::sleep;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -83,5 +83,8 @@ revolt-result = { path = "../core/result", features = ["rocket", "okapi"] }
|
||||
revolt-permissions = { path = "../core/permissions", features = ["schemas"] }
|
||||
revolt-ratelimits = { path = "../core/ratelimits", features = ["rocket"] }
|
||||
|
||||
# oauth2
|
||||
pkce = "0.2.0"
|
||||
|
||||
[build-dependencies]
|
||||
vergen = "7.5.0"
|
||||
|
||||
@@ -37,6 +37,7 @@ pub async fn edit_bot(
|
||||
if data.public.is_none()
|
||||
&& data.analytics.is_none()
|
||||
&& data.interactions_url.is_none()
|
||||
&& data.oauth2.is_none()
|
||||
&& data.remove.is_empty()
|
||||
{
|
||||
return Ok(Json(v0::BotWithUserResponse {
|
||||
@@ -49,17 +50,43 @@ 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);
|
||||
oauth2.allowed_scopes = edit_oauth2.allowed_scopes
|
||||
.map(|scopes|scopes
|
||||
.into_iter()
|
||||
.map(|(scope, value)| (scope.into(), value.into()))
|
||||
.collect()
|
||||
)
|
||||
.unwrap_or(oauth2.allowed_scopes);
|
||||
|
||||
partial.oauth2 = Some(oauth2)
|
||||
}
|
||||
|
||||
bot.update(
|
||||
db,
|
||||
partial,
|
||||
|
||||
@@ -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<Build>) -> Rocket<Build> {
|
||||
"/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<Build>) -> Rocket<Build> {
|
||||
"/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<Build>) -> Rocket<Build> {
|
||||
"/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<Build>) -> Rocket<Build> {
|
||||
"/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(),
|
||||
@@ -198,7 +203,13 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
"Sync",
|
||||
"Web Push"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OAuth2",
|
||||
"tags": [
|
||||
"OAuth2",
|
||||
]
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -370,6 +381,11 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
description: Some("Send messages from 3rd party services".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
Tag {
|
||||
name: "OAuth2".to_owned(),
|
||||
description: Some("Integrate Revolt into 3rd party applications".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
|
||||
111
crates/delta/src/routes/oauth2/authorize_auth.rs
Normal file
111
crates/delta/src/routes/oauth2/authorize_auth.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use revolt_config::config;
|
||||
use revolt_database::{
|
||||
util::{oauth2, reference::Reference}, AuthorizedBot, AuthorizedBotId, Database, User
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
/// # OAuth2 Authorization Auth
|
||||
///
|
||||
/// Generates an OAuth2 code to be passed to the redirect URI.
|
||||
#[openapi(tag = "OAuth2")]
|
||||
#[post("/authorize?<info..>")]
|
||||
pub async fn auth(
|
||||
db: &State<Database>,
|
||||
user: User,
|
||||
info: v0::OAuth2AuthorizationForm,
|
||||
) -> Result<Json<v0::OAuth2AuthorizeAuthResponse>> {
|
||||
if user.bot.is_some() {
|
||||
return Err(create_error!(IsBot));
|
||||
};
|
||||
|
||||
let bot = Reference::from_unchecked(&info.client_id)
|
||||
.as_bot(db)
|
||||
.await?;
|
||||
|
||||
let Some(oauth2) = &bot.oauth2 else {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
let Some(scopes) = v0::OAuth2Scope::scopes_from_str(&info.scope) else {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
if scopes.iter().any(|&scope| !oauth2.allowed_scopes.contains_key(&scope.into()))
|
||||
|| !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 token = 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));
|
||||
};
|
||||
|
||||
let token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Auth,
|
||||
user.id.clone(),
|
||||
info.client_id.clone(),
|
||||
info.redirect_uri.clone(),
|
||||
scopes.clone(),
|
||||
info.code_challenge_method,
|
||||
)
|
||||
.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: scopes.iter().map(|&scope| scope.into()).collect()
|
||||
}).await?;
|
||||
|
||||
token
|
||||
},
|
||||
// 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 token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Auth,
|
||||
user.id.clone(),
|
||||
info.client_id.clone(),
|
||||
info.redirect_uri.clone(),
|
||||
scopes.clone(),
|
||||
info.code_challenge_method,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
if let Some(code_challenge) = info.code_challenge.as_ref() {
|
||||
oauth2::add_code_challange(&token, code_challenge).await?;
|
||||
};
|
||||
|
||||
token
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
let redirect_uri = format!("{}/?code={token}", &info.redirect_uri);
|
||||
|
||||
Ok(Json(v0::OAuth2AuthorizeAuthResponse { redirect_uri }))
|
||||
}
|
||||
40
crates/delta/src/routes/oauth2/authorize_info.rs
Normal file
40
crates/delta/src/routes/oauth2/authorize_info.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use revolt_database::{util::reference::Reference, Database, User};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
/// # Authorize OAuth Information
|
||||
///
|
||||
/// Fetches the information needed for displaying on the OAuth grant page
|
||||
#[openapi(tag = "OAuth2")]
|
||||
#[get("/authorize?<info..>")]
|
||||
pub async fn info(
|
||||
db: &State<Database>,
|
||||
user: User,
|
||||
info: v0::OAuth2AuthorizationForm,
|
||||
) -> Result<Json<v0::OAuth2AuthorizeInfoResponse>> {
|
||||
if user.bot.is_some() {
|
||||
return Err(create_error!(IsBot));
|
||||
};
|
||||
|
||||
let bot = Reference::from_unchecked(&info.client_id).as_bot(db).await?;
|
||||
let bot_user = Reference::from_unchecked(&bot.id).as_user(db).await?;
|
||||
let public_bot = bot.clone().into_public_bot(bot_user);
|
||||
|
||||
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: public_bot,
|
||||
user: user.into(db, None).await,
|
||||
allowed_scopes: oauth2.allowed_scopes.clone()
|
||||
.into_iter()
|
||||
.map(|(scope, value)| (scope.into(), value.into()))
|
||||
.collect()
|
||||
}))
|
||||
}
|
||||
27
crates/delta/src/routes/oauth2/authorized_bots.rs
Normal file
27
crates/delta/src/routes/oauth2/authorized_bots.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
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<Database>,
|
||||
user: User
|
||||
) -> Result<Json<Vec<v0::AuthorizedBotsResponse>>> {
|
||||
let authorized_bots = db.fetch_users_authorized_bots(&user.id).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
|
||||
for authorized_bot in authorized_bots {
|
||||
let bot = db.fetch_bot(&authorized_bot.id.bot).await?;
|
||||
let bot_user = db.fetch_user(&authorized_bot.id.bot).await?;
|
||||
|
||||
response.push(v0::AuthorizedBotsResponse {
|
||||
authorized_bot: authorized_bot.into(),
|
||||
public_bot: bot.into_public_bot(bot_user)
|
||||
});
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
24
crates/delta/src/routes/oauth2/mod.rs
Normal file
24
crates/delta/src/routes/oauth2/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
|
||||
use rocket::Route;
|
||||
|
||||
mod authorize_auth;
|
||||
mod authorize_info;
|
||||
mod authorized_bots;
|
||||
mod revoke;
|
||||
mod token;
|
||||
|
||||
// TODO
|
||||
// Scopes:
|
||||
// - identity
|
||||
// - servers
|
||||
// - full
|
||||
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
authorize_auth::auth,
|
||||
authorize_info::info,
|
||||
authorized_bots::authorized_bots,
|
||||
revoke::revoke,
|
||||
token::token,
|
||||
]
|
||||
}
|
||||
50
crates/delta/src/routes/oauth2/revoke.rs
Normal file
50
crates/delta/src/routes/oauth2/revoke.rs
Normal file
@@ -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?<token>&<client_id>")]
|
||||
pub async fn revoke(
|
||||
db: &State<Database>,
|
||||
user: Option<User>,
|
||||
token: Option<&str>,
|
||||
client_id: Option<&str>
|
||||
) -> Result<Json<v0::AuthorizedBot>> {
|
||||
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()))
|
||||
}
|
||||
165
crates/delta/src/routes/oauth2/token.rs
Normal file
165
crates/delta/src/routes/oauth2/token.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use chrono::Utc;
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use revolt_config::config;
|
||||
use revolt_database::{
|
||||
util::{
|
||||
oauth2,
|
||||
reference::Reference,
|
||||
}, AuthorizedBot, AuthorizedBotId, Database
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, ErrorType, Result};
|
||||
use rocket::{form::Form, serde::json::Json, State};
|
||||
|
||||
/// # 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)
|
||||
.as_bot(db)
|
||||
.await?;
|
||||
|
||||
let config = config().await;
|
||||
|
||||
let (token, token_type) = match (&info.code, &info.refresh_token) {
|
||||
(Some(code), None) => {
|
||||
(code, oauth2::TokenType::Auth)
|
||||
},
|
||||
(None, Some(refresh_token)) => {
|
||||
(refresh_token, oauth2::TokenType::Refresh)
|
||||
},
|
||||
(Some(_), Some(_)) | (None, None) => {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
}
|
||||
};
|
||||
|
||||
let claims = oauth2::decode_token(&config.api.security.token_secret, token)
|
||||
.map_err(|_| create_error!(NotAuthenticated))?;
|
||||
|
||||
if claims.r#type != token_type || claims.client_id != info.client_id || claims.exp < Utc::now().timestamp() {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
};
|
||||
|
||||
if let Ok(authorized_bot) = db.fetch_authorized_bot(&AuthorizedBotId { user: claims.sub.clone(), bot: claims.client_id.clone() }).await {
|
||||
if authorized_bot.deauthorized_at.is_some() {
|
||||
return Err(create_error!(NotAuthenticated));
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: track used tokens and dont allow them to be used twice
|
||||
// - refresh token
|
||||
// - auth tokens (only last 5 mins but still should be considered)
|
||||
|
||||
match info.grant_type {
|
||||
v0::OAuth2GrantType::AuthorizationCode => {
|
||||
if claims.r#type != oauth2::TokenType::Auth {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
}
|
||||
|
||||
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 Some(server_code_challenge) = oauth2::get_code_challange(token).await? else {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
};
|
||||
|
||||
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))
|
||||
}
|
||||
} else {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
}
|
||||
|
||||
let token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Access,
|
||||
claims.sub.clone(),
|
||||
claims.client_id.clone(),
|
||||
claims.redirect_uri.clone(),
|
||||
claims.scopes.clone(),
|
||||
None,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
let refresh_token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Refresh,
|
||||
claims.sub.clone(),
|
||||
claims.client_id.clone(),
|
||||
claims.redirect_uri.clone(),
|
||||
claims.scopes.clone(),
|
||||
None,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
let authorized_bot_id = AuthorizedBotId { bot: claims.client_id.clone(), user: claims.sub.clone() };
|
||||
let auth_bot = db.fetch_authorized_bot(&authorized_bot_id).await;
|
||||
|
||||
if auth_bot.is_err_and(|err| err.error_type == ErrorType::NotFound) {
|
||||
db.insert_authorized_bot(&AuthorizedBot {
|
||||
id: authorized_bot_id,
|
||||
created_at: Timestamp::now_utc(),
|
||||
deauthorized_at: None,
|
||||
scope: claims.scopes.iter().map(|&scope| scope.into()).collect()
|
||||
}).await?;
|
||||
}
|
||||
|
||||
Ok(Json(v0::OAuth2TokenExchangeResponse {
|
||||
access_token: token,
|
||||
refresh_token: Some(refresh_token),
|
||||
token_type: "OAuth2".to_string(),
|
||||
scope: claims.scopes.iter().map(|scope| scope.to_string()).collect::<Vec<_>>().join(" "),
|
||||
}))
|
||||
},
|
||||
v0::OAuth2GrantType::RefreshToken => {
|
||||
if claims.r#type != oauth2::TokenType::Refresh {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
};
|
||||
|
||||
let token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Access,
|
||||
claims.sub.clone(),
|
||||
claims.client_id.clone(),
|
||||
claims.redirect_uri.clone(),
|
||||
claims.scopes.clone(),
|
||||
None,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
let refresh_token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Refresh,
|
||||
claims.sub.clone(),
|
||||
claims.client_id.clone(),
|
||||
claims.redirect_uri.clone(),
|
||||
claims.scopes.clone(),
|
||||
None,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
Ok(Json(v0::OAuth2TokenExchangeResponse {
|
||||
access_token: token,
|
||||
refresh_token: Some(refresh_token),
|
||||
token_type: "OAuth2".to_string(),
|
||||
scope: claims.scopes.iter().map(|scope| scope.to_string()).collect::<Vec<_>>().join(" "),
|
||||
}))
|
||||
}
|
||||
v0::OAuth2GrantType::Implicit => {
|
||||
// token is already an access token so this endpoint does not need to be called - in theory this should be unreachable
|
||||
Err(create_error!(InvalidOperation))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
server_create::create_server,
|
||||
server_delete::delete,
|
||||
server_fetch::fetch,
|
||||
server_fetch::fetch_server,
|
||||
server_edit::edit,
|
||||
server_ack::ack,
|
||||
channel_create::create_server_channel,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
util::{oauth2::{scopes, OAuth2Scoped}, permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, User,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
@@ -12,8 +12,9 @@ use rocket::{serde::json::Json, State};
|
||||
/// Fetch a server by its id.
|
||||
#[openapi(tag = "Server Information")]
|
||||
#[get("/<target>?<options..>")]
|
||||
pub async fn fetch(
|
||||
pub async fn fetch_server(
|
||||
db: &State<Database>,
|
||||
_oauth2_scope: OAuth2Scoped<scopes::ReadServers>,
|
||||
user: User,
|
||||
target: Reference<'_>,
|
||||
options: v0::OptionsFetchServer,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use revolt_database::User;
|
||||
use revolt_database::{User, util::oauth2::{OAuth2Scoped, scopes}};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::Result;
|
||||
use rocket::serde::json::Json;
|
||||
@@ -8,6 +8,9 @@ use rocket::serde::json::Json;
|
||||
/// Retrieve your user information.
|
||||
#[openapi(tag = "User Information")]
|
||||
#[get("/@me")]
|
||||
pub async fn fetch(user: User) -> Result<Json<v0::User>> {
|
||||
pub async fn fetch_self(
|
||||
_oauth2_scope: OAuth2Scoped<scopes::ReadIdentify>,
|
||||
user: User
|
||||
) -> Result<Json<v0::User>> {
|
||||
Ok(Json(user.into_self(false).await))
|
||||
}
|
||||
|
||||
54
crates/delta/src/routes/users/fetch_self_servers.rs
Normal file
54
crates/delta/src/routes/users/fetch_self_servers.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use revolt_database::{util::{oauth2::{scopes, OAuth2Scoped}, permissions::DatabasePermissionQuery}, Database, User};
|
||||
use revolt_models::v0;
|
||||
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
|
||||
use revolt_result::Result;
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
/// # Fetch current user's servers
|
||||
///
|
||||
/// Retrieve all servers the current user is in.
|
||||
#[openapi(tag = "User Information")]
|
||||
#[get("/@me/servers?<options..>")]
|
||||
pub async fn fetch_self_servers(
|
||||
db: &State<Database>,
|
||||
_oauth2_scope: OAuth2Scoped<scopes::ReadServers>,
|
||||
user: User,
|
||||
options: v0::OptionsFetchServer,
|
||||
) -> Result<Json<Vec<v0::FetchServerResponse>>> {
|
||||
let members = db.fetch_all_memberships(&user.id).await?;
|
||||
|
||||
let server_ids = members
|
||||
.iter()
|
||||
.map(|x| x.id.server.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut servers: Vec<v0::FetchServerResponse> = Vec::new();
|
||||
|
||||
for server in db.fetch_servers(&server_ids).await? {
|
||||
if let Some(true) = options.include_channels {
|
||||
let query = DatabasePermissionQuery::new(db, &user).server(&server);
|
||||
|
||||
let all_channels = db.fetch_channels(&server.channels).await?;
|
||||
let mut visible_channels: Vec<v0::Channel> = vec![];
|
||||
|
||||
for channel in all_channels {
|
||||
let mut channel_query = query.clone().channel(&channel);
|
||||
if calculate_channel_permissions(&mut channel_query)
|
||||
.await
|
||||
.has_channel_permission(ChannelPermission::ViewChannel)
|
||||
{
|
||||
visible_channels.push(channel.into());
|
||||
}
|
||||
}
|
||||
|
||||
servers.push(v0::FetchServerResponse::ServerWithChannels {
|
||||
server: server.into(),
|
||||
channels: visible_channels,
|
||||
});
|
||||
} else {
|
||||
servers.push(v0::FetchServerResponse::JustServer(server.into()))
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(servers))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod change_username;
|
||||
mod edit_user;
|
||||
mod fetch_dms;
|
||||
mod fetch_profile;
|
||||
mod fetch_self_servers;
|
||||
mod fetch_self;
|
||||
mod fetch_user;
|
||||
mod fetch_user_flags;
|
||||
@@ -20,7 +21,8 @@ mod unblock_user;
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
// User Information
|
||||
fetch_self::fetch,
|
||||
fetch_self::fetch_self,
|
||||
fetch_self_servers::fetch_self_servers,
|
||||
fetch_user::fetch,
|
||||
fetch_user_flags::fetch_user_flags,
|
||||
edit_user::edit,
|
||||
|
||||
Reference in New Issue
Block a user