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:
Jackson Kruger
2023-10-30 11:14:38 -05:00
committed by GitHub
parent 8803e11945
commit 6cfd4637db
54 changed files with 3658 additions and 135 deletions

View File

@@ -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};

View File

@@ -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
View 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,
}
}
}

View File

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