feat: initial oauth2 implementation
This commit is contained in:
@@ -82,5 +82,8 @@ revolt-presence = { path = "../core/presence" }
|
||||
revolt-result = { path = "../core/result", features = ["rocket", "okapi"] }
|
||||
revolt-permissions = { path = "../core/permissions", features = ["schemas"] }
|
||||
|
||||
# oauth2
|
||||
pkce = "0.2.0"
|
||||
|
||||
[build-dependencies]
|
||||
vergen = "7.5.0"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
102
crates/delta/src/routes/oauth2/authorize_auth.rs
Normal file
102
crates/delta/src/routes/oauth2/authorize_auth.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use revolt_config::config;
|
||||
use revolt_database::{
|
||||
util::{oauth2, reference::Reference},
|
||||
Database, User,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{form::Form, serde::json::Json, State};
|
||||
use redis_kiss::AsyncCommands;
|
||||
|
||||
/// # OAuth2 Authorization Auth
|
||||
///
|
||||
/// Generates an OAuth2 code to be passed to the redirect URI.
|
||||
#[openapi(tag = "OAuth2")]
|
||||
#[post("/authorize", data="<info>")]
|
||||
pub async fn auth(
|
||||
db: &State<Database>,
|
||||
user: User,
|
||||
info: Form<v0::OAuth2AuthorizationForm>,
|
||||
) -> Result<Json<v0::OAuth2AuthorizeAuthResponse>> {
|
||||
let bot = Reference::from_unchecked(info.client_id.clone())
|
||||
.as_bot(db)
|
||||
.await?;
|
||||
|
||||
let Some(oauth2) = &bot.oauth2 else {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
if !oauth2.redirects.contains(&info.redirect_uri) || v0::OAuth2Scope::scopes_from_str(&info.scope).is_none() {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
let config = config().await;
|
||||
|
||||
let code = match info.response_type {
|
||||
// implicit
|
||||
v0::OAuth2ResponseType::Code => {
|
||||
if info.state.is_some() || info.code_challenge.is_some() || info.code_challenge_method.is_some() {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Auth,
|
||||
user.id.clone(),
|
||||
info.client_id.clone(),
|
||||
info.redirect_uri.clone(),
|
||||
info.scope.clone(),
|
||||
info.code_challenge_method,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?
|
||||
},
|
||||
// authorization code
|
||||
v0::OAuth2ResponseType::Token => {
|
||||
if let Some(((state, code_challange), code_challange_method)) = info.state.as_ref().zip(info.code_challenge.as_ref()).zip(info.code_challenge_method) {
|
||||
let is_valid = match code_challange_method {
|
||||
v0::OAuth2CodeChallangeMethod::Plain => state == code_challange,
|
||||
v0::OAuth2CodeChallangeMethod::S256 => &pkce::code_challenge(state.as_bytes()) == code_challange,
|
||||
};
|
||||
|
||||
if !is_valid || state.len() <= 43 || state.len() >= 128 {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
}
|
||||
|
||||
} else if !oauth2.public {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
};
|
||||
|
||||
let code = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Access,
|
||||
user.id.clone(),
|
||||
info.client_id.clone(),
|
||||
info.redirect_uri.clone(),
|
||||
info.scope.clone(),
|
||||
info.code_challenge_method,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
if let Some(code_challenge) = info.code_challenge.as_ref() {
|
||||
let mut conn = redis_kiss::get_connection()
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
conn.pset_ex::<_, _, ()>(
|
||||
format!("oauth2:{code}:code_challenge"),
|
||||
code_challenge,
|
||||
oauth2::TokenType::Access.lifetime().num_milliseconds() as usize
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
};
|
||||
|
||||
code
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
let redirect_uri = format!("{}/?code={code}", &info.redirect_uri);
|
||||
|
||||
Ok(Json(v0::OAuth2AuthorizeAuthResponse { redirect_uri }))
|
||||
}
|
||||
30
crates/delta/src/routes/oauth2/authorize_info.rs
Normal file
30
crates/delta/src/routes/oauth2/authorize_info.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use revolt_database::{util::reference::Reference, Database, User};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{form::Form, serde::json::Json, State};
|
||||
|
||||
/// # Authorize OAuth Information
|
||||
///
|
||||
/// Fetches the information needed for displaying on the OAuth grant page
|
||||
#[openapi(tag = "OAuth2")]
|
||||
#[get("/authorize", data="<info>")]
|
||||
pub async fn info(
|
||||
db: &State<Database>,
|
||||
user: User,
|
||||
info: Form<v0::OAuth2AuthorizationForm>,
|
||||
) -> Result<Json<v0::OAuth2AuthorizeInfoResponse>> {
|
||||
let bot = Reference::from_unchecked(info.client_id.to_string()).as_bot(db).await?;
|
||||
|
||||
let Some(oauth2) = &bot.oauth2 else {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
if !oauth2.redirects.contains(&info.redirect_uri) || v0::OAuth2Scope::scopes_from_str(&info.scope).is_none() {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
Ok(Json(v0::OAuth2AuthorizeInfoResponse {
|
||||
bot: bot.into(),
|
||||
user: user.into(db, None).await
|
||||
}))
|
||||
}
|
||||
20
crates/delta/src/routes/oauth2/mod.rs
Normal file
20
crates/delta/src/routes/oauth2/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
|
||||
use rocket::Route;
|
||||
|
||||
mod authorize_auth;
|
||||
mod authorize_info;
|
||||
mod token;
|
||||
|
||||
// TODO
|
||||
// Scopes:
|
||||
// - identity
|
||||
// - servers
|
||||
// - full
|
||||
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
authorize_auth::auth,
|
||||
authorize_info::info,
|
||||
token::token,
|
||||
]
|
||||
}
|
||||
88
crates/delta/src/routes/oauth2/token.rs
Normal file
88
crates/delta/src/routes/oauth2/token.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use chrono::Utc;
|
||||
use revolt_config::config;
|
||||
use revolt_database::{
|
||||
util::{
|
||||
oauth2,
|
||||
reference::Reference,
|
||||
},
|
||||
Database,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{form::Form, serde::json::Json, State};
|
||||
use redis_kiss::AsyncCommands;
|
||||
|
||||
/// # OAuth Token Exchange
|
||||
///
|
||||
///
|
||||
#[openapi(tag = "OAuth2")]
|
||||
#[post("/token", data = "<info>")]
|
||||
pub async fn token(
|
||||
db: &State<Database>,
|
||||
info: Form<v0::OAuth2TokenExchangeForm>,
|
||||
) -> Result<Json<v0::OAuth2TokenExchangeResponse>> {
|
||||
let bot = Reference::from_unchecked(info.client_id.clone())
|
||||
.as_bot(db)
|
||||
.await?;
|
||||
|
||||
let config = config().await;
|
||||
|
||||
let claims = oauth2::decode_token(&config.api.security.token_secret, &info.code)
|
||||
.map_err(|_| create_error!(NotAuthenticated))?;
|
||||
|
||||
if let Some((client_secret, bot_secret)) = info.client_secret.as_ref().zip(bot.oauth2.as_ref().and_then(|oauth2| oauth2.secret.as_ref())) {
|
||||
if client_secret != bot_secret {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
}
|
||||
} else if let Some((code_verifier, method)) = info.code_verifier.as_ref().zip(claims.code_challange_method) {
|
||||
let mut conn = redis_kiss::get_connection()
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
let server_code_challenge = conn.get::<_, String>(format!("oauth2:{}:code_challenge", &info.code))
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
let is_valid = match method {
|
||||
v0::OAuth2CodeChallangeMethod::Plain => &server_code_challenge == code_verifier,
|
||||
v0::OAuth2CodeChallangeMethod::S256 => pkce::code_challenge(code_verifier.as_bytes()) == server_code_challenge,
|
||||
};
|
||||
|
||||
if !is_valid {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
}
|
||||
}
|
||||
|
||||
if claims.client_id != info.client_id || claims.exp < Utc::now().timestamp() {
|
||||
return Err(create_error!(NotAuthenticated));
|
||||
};
|
||||
|
||||
match info.grant_type {
|
||||
v0::OAuth2GrantType::AuthorizationCode => {
|
||||
if claims.r#type != oauth2::TokenType::Auth {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
}
|
||||
|
||||
let token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Access,
|
||||
claims.sub,
|
||||
claims.client_id,
|
||||
claims.redirect_uri,
|
||||
claims.scope.clone(),
|
||||
None,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
Ok(Json(v0::OAuth2TokenExchangeResponse {
|
||||
access_token: token,
|
||||
token_type: "OAuth2".to_string(),
|
||||
scope: claims.scope,
|
||||
}))
|
||||
},
|
||||
v0::OAuth2GrantType::Implicit => {
|
||||
Err(create_error!(InvalidOperation))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user