You've already forked AstralRinth
forked from didirus/AstralRinth
OAuth 2.0 Authorization Server [MOD-559] (#733)
* WIP end-of-day push * Authorize endpoint, accept endpoints, DB stuff for oauth clients, their redirects, and client authorizations * OAuth Client create route * Get user clients * Client delete * Edit oauth client * Include redirects in edit client route * Database stuff for tokens * Reorg oauth stuff out of auth/flows and into its own module * Impl OAuth get access token endpoint * Accept oauth access tokens as auth and update through AuthQueue * User OAuth authorization management routes * Forgot to actually add the routes lol * Bit o cleanup * Happy path test for OAuth and minor fixes for things it found * Add dummy data oauth client (and detect/handle dummy data version changes) * More tests * Another test * More tests and reject endpoint * Test oauth client and authorization management routes * cargo sqlx prepare * dead code warning * Auto clippy fixes * Uri refactoring * minor name improvement * Don't compile-time check the test sqlx queries * Trying to fix db concurrency problem to get tests to pass * Try fix from test PR * Fixes for updated sqlx * Prevent restricted scopes from being requested or issued * Get OAuth client(s) * Remove joined oauth client info from authorization returns * Add default conversion to OAuthError::error so we can use ? * Rework routes * Consolidate scopes into SESSION_ACCESS * Cargo sqlx prepare * Parse to OAuthClientId automatically through serde and actix * Cargo clippy * Remove validation requiring 1 redirect URI on oauth client creation * Use serde(flatten) on OAuthClientCreationResult
This commit is contained in:
@@ -3,6 +3,8 @@ use thiserror::Error;
|
||||
pub use super::collections::CollectionId;
|
||||
pub use super::images::ImageId;
|
||||
pub use super::notifications::NotificationId;
|
||||
pub use super::oauth_clients::OAuthClientAuthorizationId;
|
||||
pub use super::oauth_clients::{OAuthClientId, OAuthRedirectUriId};
|
||||
pub use super::organizations::OrganizationId;
|
||||
pub use super::pats::PatId;
|
||||
pub use super::projects::{ProjectId, VersionId};
|
||||
@@ -122,6 +124,9 @@ base62_id_impl!(ThreadMessageId, ThreadMessageId);
|
||||
base62_id_impl!(SessionId, SessionId);
|
||||
base62_id_impl!(PatId, PatId);
|
||||
base62_id_impl!(ImageId, ImageId);
|
||||
base62_id_impl!(OAuthClientId, OAuthClientId);
|
||||
base62_id_impl!(OAuthRedirectUriId, OAuthRedirectUriId);
|
||||
base62_id_impl!(OAuthClientAuthorizationId, OAuthClientAuthorizationId);
|
||||
|
||||
pub mod base62_impl {
|
||||
use serde::de::{self, Deserializer, Visitor};
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod error;
|
||||
pub mod ids;
|
||||
pub mod images;
|
||||
pub mod notifications;
|
||||
pub mod oauth_clients;
|
||||
pub mod organizations;
|
||||
pub mod pack;
|
||||
pub mod pats;
|
||||
|
||||
110
src/models/oauth_clients.rs
Normal file
110
src/models/oauth_clients.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use super::{
|
||||
ids::{Base62Id, UserId},
|
||||
pats::Scopes,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::database::models::oauth_client_authorization_item::OAuthClientAuthorization as DBOAuthClientAuthorization;
|
||||
use crate::database::models::oauth_client_item::OAuthClient as DBOAuthClient;
|
||||
use crate::database::models::oauth_client_item::OAuthRedirectUri as DBOAuthRedirectUri;
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "Base62Id")]
|
||||
#[serde(into = "Base62Id")]
|
||||
pub struct OAuthClientId(pub u64);
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "Base62Id")]
|
||||
#[serde(into = "Base62Id")]
|
||||
pub struct OAuthClientAuthorizationId(pub u64);
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "Base62Id")]
|
||||
#[serde(into = "Base62Id")]
|
||||
pub struct OAuthRedirectUriId(pub u64);
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct OAuthRedirectUri {
|
||||
pub id: OAuthRedirectUriId,
|
||||
pub client_id: OAuthClientId,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct OAuthClientCreationResult {
|
||||
#[serde(flatten)]
|
||||
pub client: OAuthClient,
|
||||
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct OAuthClient {
|
||||
pub id: OAuthClientId,
|
||||
pub name: String,
|
||||
pub icon_url: Option<String>,
|
||||
|
||||
// The maximum scopes the client can request for OAuth
|
||||
pub max_scopes: Scopes,
|
||||
|
||||
// The valid URIs that can be redirected to during an authorization request
|
||||
pub redirect_uris: Vec<OAuthRedirectUri>,
|
||||
|
||||
// The user that created (and thus controls) this client
|
||||
pub created_by: UserId,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct OAuthClientAuthorization {
|
||||
pub id: OAuthClientAuthorizationId,
|
||||
pub app_id: OAuthClientId,
|
||||
pub user_id: UserId,
|
||||
pub scopes: Scopes,
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct GetOAuthClientsRequest {
|
||||
pub ids: Vec<OAuthClientId>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct DeleteOAuthClientQueryParam {
|
||||
pub client_id: OAuthClientId,
|
||||
}
|
||||
|
||||
impl From<DBOAuthClient> for OAuthClient {
|
||||
fn from(value: DBOAuthClient) -> Self {
|
||||
Self {
|
||||
id: value.id.into(),
|
||||
name: value.name,
|
||||
icon_url: value.icon_url,
|
||||
max_scopes: value.max_scopes,
|
||||
redirect_uris: value.redirect_uris.into_iter().map(|r| r.into()).collect(),
|
||||
created_by: value.created_by.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DBOAuthRedirectUri> for OAuthRedirectUri {
|
||||
fn from(value: DBOAuthRedirectUri) -> Self {
|
||||
Self {
|
||||
id: value.id.into(),
|
||||
client_id: value.client_id.into(),
|
||||
uri: value.uri,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DBOAuthClientAuthorization> for OAuthClientAuthorization {
|
||||
fn from(value: DBOAuthClientAuthorization) -> Self {
|
||||
Self {
|
||||
id: value.id.into(),
|
||||
app_id: value.client_id.into(),
|
||||
user_id: value.user_id.into(),
|
||||
scopes: value.scopes,
|
||||
created: value.created,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,9 @@ bitflags::bitflags! {
|
||||
// delete an organization
|
||||
const ORGANIZATION_DELETE = 1 << 38;
|
||||
|
||||
// only accessible by modrinth-issued sessions
|
||||
const SESSION_ACCESS = 1 << 39;
|
||||
|
||||
const NONE = 0b0;
|
||||
}
|
||||
}
|
||||
@@ -118,6 +121,7 @@ impl Scopes {
|
||||
| Scopes::PAT_DELETE
|
||||
| Scopes::SESSION_READ
|
||||
| Scopes::SESSION_DELETE
|
||||
| Scopes::SESSION_ACCESS
|
||||
| Scopes::USER_AUTH_WRITE
|
||||
| Scopes::USER_DELETE
|
||||
| Scopes::PERFORM_ANALYTICS
|
||||
@@ -126,6 +130,19 @@ impl Scopes {
|
||||
pub fn is_restricted(&self) -> bool {
|
||||
self.intersects(Self::restricted())
|
||||
}
|
||||
|
||||
pub fn parse_from_oauth_scopes(scopes: &str) -> Result<Scopes, bitflags::parser::ParseError> {
|
||||
let scopes = scopes.replace(' ', "|").replace("%20", "|");
|
||||
bitflags::parser::from_str(&scopes)
|
||||
}
|
||||
|
||||
pub fn to_postgres(&self) -> i64 {
|
||||
self.bits() as i64
|
||||
}
|
||||
|
||||
pub fn from_postgres(value: i64) -> Self {
|
||||
Self::from_bits(value as u64).unwrap_or(Scopes::NONE)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -161,3 +178,64 @@ impl PersonalAccessToken {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use itertools::Itertools;
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_oauth_scopes_well_formed() {
|
||||
let raw = "USER_READ_EMAIL SESSION_READ ORGANIZATION_CREATE";
|
||||
let expected = Scopes::USER_READ_EMAIL | Scopes::SESSION_READ | Scopes::ORGANIZATION_CREATE;
|
||||
|
||||
let parsed = Scopes::parse_from_oauth_scopes(raw).unwrap();
|
||||
|
||||
assert_same_flags(expected, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_oauth_scopes_empty() {
|
||||
let raw = "";
|
||||
let expected = Scopes::empty();
|
||||
|
||||
let parsed = Scopes::parse_from_oauth_scopes(raw).unwrap();
|
||||
|
||||
assert_same_flags(expected, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_oauth_scopes_invalid_scopes() {
|
||||
let raw = "notascope";
|
||||
|
||||
let parsed = Scopes::parse_from_oauth_scopes(raw);
|
||||
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_oauth_scopes_invalid_separator() {
|
||||
let raw = "USER_READ_EMAIL & SESSION_READ";
|
||||
|
||||
let parsed = Scopes::parse_from_oauth_scopes(raw);
|
||||
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_oauth_scopes_url_encoded() {
|
||||
let raw = urlencoding::encode("PAT_WRITE COLLECTION_DELETE").to_string();
|
||||
let expected = Scopes::PAT_WRITE | Scopes::COLLECTION_DELETE;
|
||||
|
||||
let parsed = Scopes::parse_from_oauth_scopes(&raw).unwrap();
|
||||
|
||||
assert_same_flags(expected, parsed);
|
||||
}
|
||||
|
||||
fn assert_same_flags(expected: Scopes, actual: Scopes) {
|
||||
assert_eq!(
|
||||
expected.iter_names().map(|(name, _)| name).collect_vec(),
|
||||
actual.iter_names().map(|(name, _)| name).collect_vec()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user