chore: rework oauth2 route scoping

This commit is contained in:
Zomatree
2025-07-21 05:35:59 +01:00
parent f895ca7b23
commit 9d4bcb5e3d
13 changed files with 178 additions and 58 deletions

View File

@@ -54,6 +54,8 @@ auto_derived!(
ReadIdentify,
#[serde(rename = "read:servers")]
ReadServers,
#[serde(rename = "write:files")]
WriteFiles,
#[serde(rename = "events")]
Events,
#[serde(rename = "full")]

View File

@@ -1,8 +1,10 @@
use axum::{extract::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 FromRequestParts<Database> for User {
@@ -17,6 +19,24 @@ impl FromRequestParts<Database> for User {
{
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))
}

View File

@@ -1,5 +1,6 @@
use authifier::models::Session;
use revolt_config::config;
use revolt_models::v0;
use rocket::http::Status;
use rocket::request::{self, FromRequest, Outcome, Request};
@@ -36,14 +37,22 @@ impl<'r> FromRequest<'r> for 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()?;
let claims = oauth2::decode_token(
&config.api.security.token_secret,
&header_oauth_token,
)
.ok()?;
if oauth2::scopes_can_access_route(&claims.scopes, request) {
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)
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);

View File

@@ -94,6 +94,7 @@ impl From<crate::OAuth2Scope> for OAuth2Scope {
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,
}
@@ -105,6 +106,7 @@ impl From<OAuth2Scope> for crate::OAuth2Scope {
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,
}

View 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 })
}
}

View File

@@ -1,12 +1,18 @@
use chrono::{TimeDelta, Utc};
use jsonwebtoken::{decode, encode, errors::Error, DecodingKey, EncodingKey, Header, Validation};
use serde::{Serialize, Deserialize};
use revolt_models::v0;
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")]
use rocket::Request;
pub mod rocket;
#[cfg(feature = "axum")]
pub mod axum;
pub use jsonwebtoken::errors::{Error as JWTError, ErrorKind as JWTErrorKind};
use ulid::Ulid;
@@ -15,7 +21,7 @@ use ulid::Ulid;
pub enum TokenType {
Auth,
Access,
Refresh
Refresh,
}
impl TokenType {
@@ -54,9 +60,7 @@ pub fn encode_token(
) -> Result<String, Error> {
let now = Utc::now();
let exp = now
.checked_add_signed(token_type.lifetime())
.unwrap();
let exp = now.checked_add_signed(token_type.lifetime()).unwrap();
println!("{now:?} {exp:}");
@@ -69,7 +73,7 @@ pub fn encode_token(
client_id,
scopes,
redirect_uri,
code_challange_method
code_challange_method,
};
let encoding_key = EncodingKey::from_secret(token_secret.as_bytes());
@@ -80,48 +84,15 @@ pub fn encode_token(
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))?;
let data = decode(
code,
&decoding_key,
&Validation::new(jsonwebtoken::Algorithm::HS256),
)?;
Ok(data.claims)
}
#[cfg(feature = "rocket")]
pub fn scope_can_access_route(scope: v0::OAuth2Scope, request: &Request<'_>) -> bool {
println!("{:?}", request.route());
// TODO: figure out why request.segments(0..) is skipping the first segment
let mut segments = request.uri().path().segments();
if segments.get(0) == Some("0.8") {
segments.next(); // skip first segment
};
// Extract the function name of the route
let Some(route_name) = request
.route()
.and_then(|route| route.name.as_ref())
.map(|name| name.as_ref())
else {
return false
};
match scope {
v0::OAuth2Scope::ReadIdentify => route_name == "fetch_self",
v0::OAuth2Scope::ReadServers => route_name == "fetch_self_servers" || route_name == "fetch_server",
// This only grants access to the events websocket and not any routes
v0::OAuth2Scope::Events => false,
// TODO: maybe disallow revoking other sessions
v0::OAuth2Scope::Full => true,
}
}
#[cfg(feature = "rocket")]
pub fn scopes_can_access_route(scopes: &[v0::OAuth2Scope], request: &Request<'_>) -> bool {
scopes
.iter()
.any(|&scope| scope_can_access_route(scope, request))
}
pub async fn add_code_challange(token: &str, code_challenge: &str) -> Result<()> {
let mut conn = redis_kiss::get_connection()
.await
@@ -130,7 +101,7 @@ pub async fn add_code_challange(token: &str, code_challenge: &str) -> Result<()>
conn.pset_ex::<_, _, ()>(
format!("oauth2:{token}:code_challenge"),
code_challenge,
TokenType::Access.lifetime().num_milliseconds() as usize
TokenType::Access.lifetime().num_milliseconds() as usize,
)
.await
.map_err(|_| create_error!(InternalError))?;
@@ -146,4 +117,4 @@ pub async fn get_code_challange(token: &str) -> Result<Option<String>> {
conn.get(format!("oauth2:{token}:code_challenge"))
.await
.map_err(|_| create_error!(InternalError))
}
}

View 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::NAME.to_owned()]
},
))
}
}

View File

@@ -0,0 +1,27 @@
use std::marker::PhantomData;
pub struct OAuth2Scoped<Scope> {
pub(crate) _scope: PhantomData<Scope>
}
pub trait OAuth2Scope {
const NAME: &'static str;
const SCOPE: crate::OAuth2Scope;
}
macro_rules! define_oauth2_scope {
($struct_name:ident, $name:literal) => {
pub struct $struct_name;
impl OAuth2Scope for $struct_name {
const NAME: &'static str = $name;
const SCOPE: crate::OAuth2Scope = crate::OAuth2Scope::$struct_name;
}
};
}
define_oauth2_scope!(ReadIdentify, "read:identify");
define_oauth2_scope!(ReadServers, "read:servers");
define_oauth2_scope!(WriteFiles, "write:files");
define_oauth2_scope!(Events, "events");
define_oauth2_scope!(Full, "full");

View File

@@ -91,6 +91,8 @@ auto_derived!(
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"))]
@@ -103,6 +105,7 @@ impl Display for OAuth2Scope {
f.write_str(match self {
OAuth2Scope::ReadIdentify => "read:identify",
OAuth2Scope::ReadServers => "read:servers",
OAuth2Scope::WriteFiles => "write:files",
OAuth2Scope::Events => "events",
OAuth2Scope::Full => "full",
})

View File

@@ -203,7 +203,13 @@ fn custom_openapi_spec() -> OpenApi {
"Sync",
"Web Push"
]
}
},
{
"name": "OAuth2",
"tags": [
"OAuth2",
]
},
]),
);
@@ -375,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()
}

View File

@@ -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;
@@ -14,6 +14,7 @@ use rocket::{serde::json::Json, State};
#[get("/<target>?<options..>")]
pub async fn fetch_server(
db: &State<Database>,
_oauth2_scope: OAuth2Scoped<scopes::ReadServers>,
user: User,
target: Reference<'_>,
options: v0::OptionsFetchServer,

View File

@@ -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_self(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))
}

View File

@@ -1,4 +1,4 @@
use revolt_database::{util::permissions::DatabasePermissionQuery, Database, User};
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;
@@ -11,6 +11,7 @@ use rocket::{serde::json::Json, State};
#[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>>> {