feat: initial oauth2 implementation

This commit is contained in:
Zomatree
2025-06-23 21:51:03 +01:00
parent ed22b3a5ce
commit 9e05e5be7e
21 changed files with 711 additions and 10 deletions

View File

@@ -1,3 +1,4 @@
use revolt_database::BotOauth2;
use revolt_database::{util::reference::Reference, Database, PartialBot, User};
use revolt_models::v0::{self, DataEditBot};
use revolt_result::{create_error, Result};
@@ -37,7 +38,8 @@ pub async fn edit_bot(
if data.public.is_none()
&& data.analytics.is_none()
&& data.interactions_url.is_none()
&& data.remove.is_empty()
&& data.oauth2.is_none()
&& data.remove.is_none()
{
return Ok(Json(v0::BotWithUserResponse {
bot: bot.into(),
@@ -49,17 +51,36 @@ pub async fn edit_bot(
public,
analytics,
interactions_url,
oauth2,
remove,
..
} = data;
let partial = PartialBot {
let mut partial = PartialBot {
public,
analytics,
interactions_url,
..Default::default()
};
if let Some(edit_oauth2) = oauth2 {
let mut oauth2 = bot.oauth2.clone().unwrap_or_default();
if let Some(public) = edit_oauth2.public {
if oauth2.public && !public {
oauth2.secret = Some(nanoid::nanoid!(64))
} else if !oauth2.public && public {
oauth2.secret = None;
};
oauth2.public = public;
}
oauth2.redirects = edit_oauth2.redirects.unwrap_or(oauth2.redirects);
partial.oauth2 = Some(oauth2)
}
bot.update(
db,
partial,

View File

@@ -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(),

View File

@@ -0,0 +1,102 @@
use revolt_config::config;
use revolt_database::{
util::{oauth2, reference::Reference},
Database, User,
};
use revolt_models::v0;
use revolt_result::{create_error, Result};
use rocket::{form::Form, serde::json::Json, State};
use redis_kiss::AsyncCommands;
/// # OAuth2 Authorization Auth
///
/// Generates an OAuth2 code to be passed to the redirect URI.
#[openapi(tag = "OAuth2")]
#[post("/authorize", data="<info>")]
pub async fn auth(
db: &State<Database>,
user: User,
info: Form<v0::OAuth2AuthorizationForm>,
) -> Result<Json<v0::OAuth2AuthorizeAuthResponse>> {
let bot = Reference::from_unchecked(info.client_id.clone())
.as_bot(db)
.await?;
let Some(oauth2) = &bot.oauth2 else {
return Err(create_error!(InvalidOperation));
};
if !oauth2.redirects.contains(&info.redirect_uri) || v0::OAuth2Scope::scopes_from_str(&info.scope).is_none() {
return Err(create_error!(InvalidOperation));
};
let config = config().await;
let code = match info.response_type {
// implicit
v0::OAuth2ResponseType::Code => {
if info.state.is_some() || info.code_challenge.is_some() || info.code_challenge_method.is_some() {
return Err(create_error!(InvalidOperation));
};
oauth2::encode_token(
&config.api.security.token_secret,
oauth2::TokenType::Auth,
user.id.clone(),
info.client_id.clone(),
info.redirect_uri.clone(),
info.scope.clone(),
info.code_challenge_method,
)
.map_err(|_| create_error!(InternalError))?
},
// authorization code
v0::OAuth2ResponseType::Token => {
if let Some(((state, code_challange), code_challange_method)) = info.state.as_ref().zip(info.code_challenge.as_ref()).zip(info.code_challenge_method) {
let is_valid = match code_challange_method {
v0::OAuth2CodeChallangeMethod::Plain => state == code_challange,
v0::OAuth2CodeChallangeMethod::S256 => &pkce::code_challenge(state.as_bytes()) == code_challange,
};
if !is_valid || state.len() <= 43 || state.len() >= 128 {
return Err(create_error!(InvalidOperation))
}
} else if !oauth2.public {
return Err(create_error!(InvalidOperation))
};
let code = oauth2::encode_token(
&config.api.security.token_secret,
oauth2::TokenType::Access,
user.id.clone(),
info.client_id.clone(),
info.redirect_uri.clone(),
info.scope.clone(),
info.code_challenge_method,
)
.map_err(|_| create_error!(InternalError))?;
if let Some(code_challenge) = info.code_challenge.as_ref() {
let mut conn = redis_kiss::get_connection()
.await
.map_err(|_| create_error!(InternalError))?;
conn.pset_ex::<_, _, ()>(
format!("oauth2:{code}:code_challenge"),
code_challenge,
oauth2::TokenType::Access.lifetime().num_milliseconds() as usize
)
.await
.map_err(|_| create_error!(InternalError))?;
};
code
},
};
let redirect_uri = format!("{}/?code={code}", &info.redirect_uri);
Ok(Json(v0::OAuth2AuthorizeAuthResponse { redirect_uri }))
}

View File

@@ -0,0 +1,30 @@
use revolt_database::{util::reference::Reference, Database, User};
use revolt_models::v0;
use revolt_result::{create_error, Result};
use rocket::{form::Form, serde::json::Json, State};
/// # Authorize OAuth Information
///
/// Fetches the information needed for displaying on the OAuth grant page
#[openapi(tag = "OAuth2")]
#[get("/authorize", data="<info>")]
pub async fn info(
db: &State<Database>,
user: User,
info: Form<v0::OAuth2AuthorizationForm>,
) -> Result<Json<v0::OAuth2AuthorizeInfoResponse>> {
let bot = Reference::from_unchecked(info.client_id.to_string()).as_bot(db).await?;
let Some(oauth2) = &bot.oauth2 else {
return Err(create_error!(InvalidOperation));
};
if !oauth2.redirects.contains(&info.redirect_uri) || v0::OAuth2Scope::scopes_from_str(&info.scope).is_none() {
return Err(create_error!(InvalidOperation));
};
Ok(Json(v0::OAuth2AuthorizeInfoResponse {
bot: bot.into(),
user: user.into(db, None).await
}))
}

View File

@@ -0,0 +1,20 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod authorize_auth;
mod authorize_info;
mod token;
// TODO
// Scopes:
// - identity
// - servers
// - full
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
authorize_auth::auth,
authorize_info::info,
token::token,
]
}

View File

@@ -0,0 +1,88 @@
use chrono::Utc;
use revolt_config::config;
use revolt_database::{
util::{
oauth2,
reference::Reference,
},
Database,
};
use revolt_models::v0;
use revolt_result::{create_error, Result};
use rocket::{form::Form, serde::json::Json, State};
use redis_kiss::AsyncCommands;
/// # OAuth Token Exchange
///
///
#[openapi(tag = "OAuth2")]
#[post("/token", data = "<info>")]
pub async fn token(
db: &State<Database>,
info: Form<v0::OAuth2TokenExchangeForm>,
) -> Result<Json<v0::OAuth2TokenExchangeResponse>> {
let bot = Reference::from_unchecked(info.client_id.clone())
.as_bot(db)
.await?;
let config = config().await;
let claims = oauth2::decode_token(&config.api.security.token_secret, &info.code)
.map_err(|_| create_error!(NotAuthenticated))?;
if let Some((client_secret, bot_secret)) = info.client_secret.as_ref().zip(bot.oauth2.as_ref().and_then(|oauth2| oauth2.secret.as_ref())) {
if client_secret != bot_secret {
return Err(create_error!(NotAuthenticated))
}
} else if let Some((code_verifier, method)) = info.code_verifier.as_ref().zip(claims.code_challange_method) {
let mut conn = redis_kiss::get_connection()
.await
.map_err(|_| create_error!(InternalError))?;
let server_code_challenge = conn.get::<_, String>(format!("oauth2:{}:code_challenge", &info.code))
.await
.map_err(|_| create_error!(InternalError))?;
let is_valid = match method {
v0::OAuth2CodeChallangeMethod::Plain => &server_code_challenge == code_verifier,
v0::OAuth2CodeChallangeMethod::S256 => pkce::code_challenge(code_verifier.as_bytes()) == server_code_challenge,
};
if !is_valid {
return Err(create_error!(NotAuthenticated))
}
}
if claims.client_id != info.client_id || claims.exp < Utc::now().timestamp() {
return Err(create_error!(NotAuthenticated));
};
match info.grant_type {
v0::OAuth2GrantType::AuthorizationCode => {
if claims.r#type != oauth2::TokenType::Auth {
return Err(create_error!(InvalidOperation))
}
let token = oauth2::encode_token(
&config.api.security.token_secret,
oauth2::TokenType::Access,
claims.sub,
claims.client_id,
claims.redirect_uri,
claims.scope.clone(),
None,
)
.map_err(|_| create_error!(InternalError))?;
Ok(Json(v0::OAuth2TokenExchangeResponse {
access_token: token,
token_type: "OAuth2".to_string(),
scope: claims.scope,
}))
},
v0::OAuth2GrantType::Implicit => {
Err(create_error!(InvalidOperation))
}
}
}