feat: initial work on refresh tokens

This commit is contained in:
Zomatree
2025-07-05 00:58:57 +01:00
parent 660e646b2b
commit ef65223c89
15 changed files with 210 additions and 79 deletions

View File

@@ -5,13 +5,14 @@ use futures::lock::Mutex;
use crate::{ use crate::{
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member, Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member,
MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot, MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot,
User, UserSettings, Webhook, User, UserSettings, Webhook, AuthorizedBotId, AuthorizedBot,
}; };
database_derived!( database_derived!(
/// Reference implementation /// Reference implementation
#[derive(Default)] #[derive(Default)]
pub struct ReferenceDb { pub struct ReferenceDb {
pub authorized_bots: Arc<Mutex<HashMap<AuthorizedBotId, AuthorizedBot>>>,
pub bots: Arc<Mutex<HashMap<String, Bot>>>, pub bots: Arc<Mutex<HashMap<String, Bot>>>,
pub channels: Arc<Mutex<HashMap<String, Channel>>>, pub channels: Arc<Mutex<HashMap<String, Channel>>>,
pub channel_invites: Arc<Mutex<HashMap<String, Invite>>>, pub channel_invites: Arc<Mutex<HashMap<String, Invite>>>,

View File

@@ -2,6 +2,7 @@ use iso8601_timestamp::Timestamp;
auto_derived! ( auto_derived! (
/// Unique id of the user and bot /// Unique id of the user and bot
#[derive(Hash)]
pub struct AuthorizedBotId { pub struct AuthorizedBotId {
/// User id /// User id
pub user: String, pub user: String,

View File

@@ -8,20 +8,20 @@ mod reference;
#[async_trait] #[async_trait]
pub trait AbstractAuthorizedBots: Sync + Send { pub trait AbstractAuthorizedBots: Sync + Send {
/// Insert emoji into database. /// Insert emoji into database.
async fn insert_authorized_bot(&self, authorised_bot: &AuthorizedBot) -> Result<()>; async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()>;
/// Fetch an emoji by its id /// Fetch an authorized bot by its id
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot>; async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot>;
/// Fetch all authorized bots for a user /// Fetch a users authorized bot by its id
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>>; async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>>;
/// Deletes an authorized bot /// Deletes an authorized bot
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()>; async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()>;
/// Deauthorizes a bot access to a user's information /// Deauthorizes an authorized bot
async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot>; async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot>;
/// Fetches all deauthorized bots // Fetches all authorized bots which have been deauthorized
async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>>; async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>>;
} }

View File

@@ -1,4 +1,4 @@
use bson::{Document, to_bson}; use bson::to_bson;
use revolt_result::Result; use revolt_result::Result;
use iso8601_timestamp::Timestamp; use iso8601_timestamp::Timestamp;
@@ -10,26 +10,27 @@ static COL: &str = "authorized_bots";
#[async_trait] #[async_trait]
impl AbstractAuthorizedBots for MongoDb { impl AbstractAuthorizedBots for MongoDb {
/// Insert emoji into database. /// Insert an authorized bot into database.
async fn insert_authorized_bot(&self, authorised_bot: &AuthorizedBot) -> Result<()> { async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()> {
query!(self, insert_one, COL, &authorised_bot).map(|_| ()) query!(self, insert_one, COL, &authorized_bot).map(|_| ())
} }
/// Fetch an emoji by its id /// Fetch an authorized bot by its id
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> { 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)) query!(self, find_one, COL, doc! { "id.user": &id.user, "id.bot": &id.bot })?.ok_or_else(|| create_error!(NotFound))
} }
/// Fetch an emoji by its id /// Fetch a users authorized bot by its id
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>> { async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>> {
query!(self, find, COL, doc! { "id.user": &user_id }) query!(self, find, COL, doc! { "id.user": &user_id })
} }
/// Deletes an authori /// Deletes an authorized bot
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> { async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> {
query!(self, delete_one, COL, doc! { "id.user": &id.user, "id.bot": &id.bot }).map(|_| ()) 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> { async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
self.col::<AuthorizedBot>(COL) self.col::<AuthorizedBot>(COL)
.find_one_and_update( .find_one_and_update(
@@ -39,7 +40,7 @@ impl AbstractAuthorizedBots for MongoDb {
}, },
doc! { doc! {
"$set": { "$set": {
"unauthorized_at": to_bson(&Timestamp::now_utc()).unwrap() "deauthorized_at": to_bson(&Timestamp::now_utc()).unwrap()
} }
} }
) )
@@ -48,6 +49,7 @@ impl AbstractAuthorizedBots for MongoDb {
.and_then(|opt| opt.ok_or_else(|| 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>> { async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>> {
query!( query!(
self, self,

View File

@@ -1,38 +1,76 @@
use bson::Document;
use revolt_result::Result; use revolt_result::Result;
use iso8601_timestamp::Timestamp;
use crate::{ReferenceDb, AuthorizedBot, AuthorizedBotId}; use crate::{ReferenceDb, AuthorizedBot, AuthorizedBotId};
use super::AbstractAuthorizedBots; use super::AbstractAuthorizedBots;
static COL: &str = "authorized_bots";
#[async_trait] #[async_trait]
impl AbstractAuthorizedBots for ReferenceDb { impl AbstractAuthorizedBots for ReferenceDb {
/// Insert emoji into database. /// Insert an authorized bot into database.
async fn insert_authorized_bot(&self, authorised_bot: &AuthorizedBot) -> Result<()> { async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()> {
todo!() 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 emoji by its id /// Fetch an authorized bot by its id
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> { async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
todo!() 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>> { async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>> {
todo!() 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 authori /// Deletes an authorized bot
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> { async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> {
todo!() 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> { async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
todo!() 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>> { async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>> {
todo!() let authorized_bots = self.authorized_bots.lock().await;
Ok(authorized_bots
.values()
.filter(|authorized_bot| authorized_bot.deauthorized_at.is_some())
.cloned()
.collect()
)
} }
} }

View File

@@ -1,5 +1,7 @@
use iso8601_timestamp::Timestamp; use iso8601_timestamp::Timestamp;
use crate::v0::PublicBot;
auto_derived!( auto_derived!(
/// Unique id of the user and bot /// Unique id of the user and bot
pub struct AuthorizedBotId { pub struct AuthorizedBotId {
@@ -24,4 +26,9 @@ auto_derived!(
/// Scopes the bot has access to /// Scopes the bot has access to
pub scope: String pub scope: String
} }
pub struct AuthorizedBotsResponse {
pub public_bot: PublicBot,
pub authorized_bot: AuthorizedBot
}
); );

View File

@@ -37,6 +37,9 @@ auto_derived!(
#[cfg_attr(feature = "rocket", field(value = "implicit"))] #[cfg_attr(feature = "rocket", field(value = "implicit"))]
#[cfg_attr(feature = "serde", serde(rename = "implicit"))] #[cfg_attr(feature = "serde", serde(rename = "implicit"))]
Implicit, Implicit,
#[cfg_attr(feature = "rocket", field(value = "refresh_token"))]
#[cfg_attr(feature = "serde", serde(rename = "refresh_token"))]
RefreshToken
} }
#[derive(Copy)] #[derive(Copy)]
@@ -66,12 +69,17 @@ auto_derived!(
pub client_id: String, pub client_id: String,
pub client_secret: Option<String>, pub client_secret: Option<String>,
pub code: 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 code_verifier: Option<String>,
} }
pub struct OAuth2TokenExchangeResponse { pub struct OAuth2TokenExchangeResponse {
pub access_token: String, pub access_token: String,
pub refresh_token: Option<String>,
pub token_type: String, pub token_type: String,
pub scope: String, pub scope: String,
} }

View File

@@ -51,7 +51,7 @@ impl std::error::Error for Error {}
#[cfg_attr(feature = "serde", serde(tag = "type"))] #[cfg_attr(feature = "serde", serde(tag = "type"))]
#[cfg_attr(feature = "schemas", derive(JsonSchema))] #[cfg_attr(feature = "schemas", derive(JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(ToSchema))] #[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[derive(Debug, Clone)] #[derive(Debug, Clone, Eq, PartialEq)]
pub enum ErrorType { pub enum ErrorType {
/// This error was not labeled :( /// This error was not labeled :(
LabelMe, LabelMe,

View File

@@ -2,8 +2,6 @@ use revolt_database::{iso8601_timestamp::{Duration, Timestamp}, util::oauth2::To
use revolt_result::Result; use revolt_result::Result;
use tokio::time::sleep; use tokio::time::sleep;
use log::info;
pub async fn task(db: Database) -> Result<()> { pub async fn task(db: Database) -> Result<()> {
let lifetime = Duration::new(TokenType::Access.lifetime().num_seconds(), 0); let lifetime = Duration::new(TokenType::Access.lifetime().num_seconds(), 0);

View File

@@ -5,8 +5,7 @@ use revolt_database::{
}; };
use revolt_models::v0; use revolt_models::v0;
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use rocket::{form::Form, serde::json::Json, State}; use rocket::{serde::json::Json, State};
use redis_kiss::AsyncCommands;
/// # OAuth2 Authorization Auth /// # OAuth2 Authorization Auth
/// ///

View File

@@ -1,7 +1,7 @@
use revolt_database::{util::reference::Reference, Database, User}; use revolt_database::{util::reference::Reference, Database, User};
use revolt_models::v0; use revolt_models::v0;
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use rocket::{form::Form, serde::json::Json, State}; use rocket::{serde::json::Json, State};
/// # Authorize OAuth Information /// # Authorize OAuth Information
/// ///

View File

@@ -3,18 +3,25 @@ use rocket::{serde::json::Json, State};
use revolt_database::{Database, User}; use revolt_database::{Database, User};
use revolt_result::Result; use revolt_result::Result;
#[openapi(tag = "OAuth2")] #[openapi(tag = "OAuth2")]
#[get("/authorized_bots")] #[get("/authorized_bots")]
pub async fn authorized_bots( pub async fn authorized_bots(
db: &State<Database>, db: &State<Database>,
user: User user: User
) -> Result<Json<Vec<v0::AuthorizedBot>>> { ) -> Result<Json<Vec<v0::AuthorizedBotsResponse>>> {
let authorized_bots = db.fetch_users_authorized_bots(&user.id).await?; let authorized_bots = db.fetch_users_authorized_bots(&user.id).await?;
Ok(Json(authorized_bots let mut response = Vec::new();
.into_iter()
.map(|bot| bot.into()) for authorized_bot in authorized_bots {
.collect() 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))
} }

View File

@@ -8,9 +8,8 @@ use revolt_database::{
}, AuthorizedBot, AuthorizedBotId, Database }, AuthorizedBot, AuthorizedBotId, Database
}; };
use revolt_models::v0; use revolt_models::v0;
use revolt_result::{create_error, Result}; use revolt_result::{create_error, ErrorType, Result};
use rocket::{form::Form, serde::json::Json, State}; use rocket::{form::Form, serde::json::Json, State};
use redis_kiss::AsyncCommands;
/// # OAuth Token Exchange /// # OAuth Token Exchange
/// ///
@@ -27,15 +26,47 @@ pub async fn token(
let config = config().await; let config = config().await;
let claims = oauth2::decode_token(&config.api.security.token_secret, &info.code) 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))?; .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))
};
let 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 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 { if client_secret != bot_secret {
return Err(create_error!(NotAuthenticated)) return Err(create_error!(NotAuthenticated))
} }
} else if let Some((code_verifier, method)) = info.code_verifier.as_ref().zip(claims.code_challange_method) { } 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(&info.code).await? else { let Some(server_code_challenge) = oauth2::get_code_challange(token).await? else {
return Err(create_error!(NotAuthenticated)) return Err(create_error!(NotAuthenticated))
}; };
@@ -51,40 +82,80 @@ pub async fn token(
return Err(create_error!(NotAuthenticated)) return Err(create_error!(NotAuthenticated))
} }
if claims.client_id != info.client_id || claims.exp < Utc::now().timestamp() { let token = oauth2::encode_token(
return Err(create_error!(NotAuthenticated)); &config.api.security.token_secret,
}; oauth2::TokenType::Access,
claims.sub.clone(),
claims.client_id.clone(),
claims.redirect_uri.clone(),
claims.scope.clone(),
None,
)
.map_err(|_| create_error!(InternalError))?;
match info.grant_type { let refresh_token = oauth2::encode_token(
v0::OAuth2GrantType::AuthorizationCode => { &config.api.security.token_secret,
if claims.r#type != oauth2::TokenType::Auth { oauth2::TokenType::Refresh,
return Err(create_error!(InvalidOperation)) claims.sub.clone(),
claims.client_id.clone(),
claims.redirect_uri.clone(),
claims.scope.clone(),
None,
)
.map_err(|_| create_error!(InternalError))?;
let authorized_bot_id = AuthorizedBotId { bot: claims.client_id.clone(), user: claims.sub.clone() };
if db.fetch_authorized_bot(&authorized_bot_id).await.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.scope.clone()
}).await?;
} }
Ok(Json(v0::OAuth2TokenExchangeResponse {
access_token: token,
refresh_token: Some(refresh_token),
token_type: "OAuth2".to_string(),
scope: claims.scope,
}))
},
v0::OAuth2GrantType::RefreshToken => {
if claims.r#type != oauth2::TokenType::Refresh {
return Err(create_error!(InvalidOperation))
};
let token = oauth2::encode_token( let token = oauth2::encode_token(
&config.api.security.token_secret, &config.api.security.token_secret,
oauth2::TokenType::Access, oauth2::TokenType::Access,
claims.sub.clone(), claims.sub.clone(),
claims.client_id.clone(), claims.client_id.clone(),
claims.redirect_uri, claims.redirect_uri.clone(),
claims.scope.clone(), claims.scope.clone(),
None, None,
) )
.map_err(|_| create_error!(InternalError))?; .map_err(|_| create_error!(InternalError))?;
db.insert_authorized_bot(&AuthorizedBot { let refresh_token = oauth2::encode_token(
id: AuthorizedBotId { bot: claims.client_id.clone(), user: claims.sub.clone() }, &config.api.security.token_secret,
created_at: Timestamp::now_utc(), oauth2::TokenType::Refresh,
deauthorized_at: None, claims.sub.clone(),
scope: claims.scope.clone() claims.client_id.clone(),
}).await?; claims.redirect_uri.clone(),
claims.scope.clone(),
None,
)
.map_err(|_| create_error!(InternalError))?;
Ok(Json(v0::OAuth2TokenExchangeResponse { Ok(Json(v0::OAuth2TokenExchangeResponse {
access_token: token, access_token: token,
refresh_token: Some(refresh_token),
token_type: "OAuth2".to_string(), token_type: "OAuth2".to_string(),
scope: claims.scope, scope: claims.scope,
})) }))
}, }
v0::OAuth2GrantType::Implicit => { v0::OAuth2GrantType::Implicit => {
// token is already an access token so this endpoint does not need to be called - in theory this should be unreachable // 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)) Err(create_error!(InvalidOperation))

View File

@@ -12,7 +12,6 @@ mod fetch_user;
mod fetch_user_flags; mod fetch_user_flags;
mod find_mutual; mod find_mutual;
mod get_default_avatar; mod get_default_avatar;
mod oauth2_connections;
mod open_dm; mod open_dm;
mod remove_friend; mod remove_friend;
mod send_friend_request; mod send_friend_request;