use std::{collections::HashSet, fmt::Display}; use actix_web::{ delete, get, patch, post, web::{self, scope}, HttpRequest, HttpResponse, }; use chrono::Utc; use itertools::Itertools; use rand::{distributions::Alphanumeric, Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use validator::Validate; use super::ApiError; use crate::{ auth::checks::ValidateAllAuthorized, models::{ids::base62_impl::parse_base62, oauth_clients::DeleteOAuthClientQueryParam}, }; use crate::{ auth::{checks::ValidateAuthorized, get_user_from_headers}, database::{ models::{ generate_oauth_client_id, generate_oauth_redirect_id, oauth_client_authorization_item::OAuthClientAuthorization, oauth_client_item::{OAuthClient, OAuthRedirectUri}, DatabaseError, OAuthClientId, User, }, redis::RedisPool, }, models::{ self, oauth_clients::{GetOAuthClientsRequest, OAuthClientCreationResult}, pats::Scopes, }, queue::session::AuthQueue, routes::v3::project_creation::CreateError, util::validate::validation_errors_to_string, }; use crate::database::models::oauth_client_item::OAuthClient as DBOAuthClient; use crate::models::ids::OAuthClientId as ApiOAuthClientId; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get_user_clients); cfg.service( scope("oauth") .service(oauth_client_create) .service(oauth_client_edit) .service(oauth_client_delete) .service(get_client) .service(get_clients) .service(get_user_oauth_authorizations) .service(revoke_oauth_authorization), ); } #[get("user/{user_id}/oauth_apps")] pub async fn get_user_clients( req: HttpRequest, info: web::Path, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let current_user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::SESSION_ACCESS]), ) .await? .1; let target_user = User::get(&info.into_inner(), &**pool, &redis).await?; if let Some(target_user) = target_user { let clients = OAuthClient::get_all_user_clients(target_user.id, &**pool).await?; clients .iter() .validate_all_authorized(Some(¤t_user))?; let response = clients .into_iter() .map(models::oauth_clients::OAuthClient::from) .collect_vec(); Ok(HttpResponse::Ok().json(response)) } else { Ok(HttpResponse::NotFound().body("")) } } #[get("app/{id}")] pub async fn get_client( req: HttpRequest, id: web::Path, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let clients = get_clients_inner(&[id.into_inner()], req, pool, redis, session_queue).await?; if let Some(client) = clients.into_iter().next() { Ok(HttpResponse::Ok().json(client)) } else { Ok(HttpResponse::NotFound().body("")) } } #[get("apps")] pub async fn get_clients( req: HttpRequest, info: web::Query, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let ids: Vec<_> = info .ids .iter() .map(|id| parse_base62(id).map(ApiOAuthClientId)) .collect::>()?; let clients = get_clients_inner(&ids, req, pool, redis, session_queue).await?; Ok(HttpResponse::Ok().json(clients)) } #[derive(Deserialize, Validate)] pub struct NewOAuthApp { #[validate( custom(function = "crate::util::validate::validate_name"), length(min = 3, max = 255) )] pub name: String, #[validate( custom(function = "crate::util::validate::validate_url"), length(max = 255) )] pub icon_url: Option, #[validate(custom(function = "crate::util::validate::validate_no_restricted_scopes"))] pub max_scopes: Scopes, pub redirect_uris: Vec, } #[post("app")] pub async fn oauth_client_create<'a>( req: HttpRequest, new_oauth_app: web::Json, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let current_user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::SESSION_ACCESS]), ) .await? .1; new_oauth_app .validate() .map_err(|e| CreateError::ValidationError(validation_errors_to_string(e, None)))?; let mut transaction = pool.begin().await?; let client_id = generate_oauth_client_id(&mut transaction).await?; let client_secret = generate_oauth_client_secret(); let client_secret_hash = DBOAuthClient::hash_secret(&client_secret); let redirect_uris = create_redirect_uris(&new_oauth_app.redirect_uris, client_id, &mut transaction).await?; let client = OAuthClient { id: client_id, icon_url: new_oauth_app.icon_url.clone(), max_scopes: new_oauth_app.max_scopes, name: new_oauth_app.name.clone(), redirect_uris, created: Utc::now(), created_by: current_user.id.into(), secret_hash: client_secret_hash, }; client.clone().insert(&mut transaction).await?; transaction.commit().await?; let client = models::oauth_clients::OAuthClient::from(client); Ok(HttpResponse::Ok().json(OAuthClientCreationResult { client, client_secret, })) } #[delete("app/{id}")] pub async fn oauth_client_delete<'a>( req: HttpRequest, client_id: web::Path, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let current_user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::SESSION_ACCESS]), ) .await? .1; let client = OAuthClient::get(client_id.into_inner().into(), &**pool).await?; if let Some(client) = client { client.validate_authorized(Some(¤t_user))?; OAuthClient::remove(client.id, &**pool).await?; Ok(HttpResponse::NoContent().body("")) } else { Ok(HttpResponse::NotFound().body("")) } } #[derive(Serialize, Deserialize, Validate)] pub struct OAuthClientEdit { #[validate( custom(function = "crate::util::validate::validate_name"), length(min = 3, max = 255) )] pub name: Option, #[validate( custom(function = "crate::util::validate::validate_url"), length(max = 255) )] pub icon_url: Option>, pub max_scopes: Option, #[validate(length(min = 1))] pub redirect_uris: Option>, } #[patch("app/{id}")] pub async fn oauth_client_edit( req: HttpRequest, client_id: web::Path, client_updates: web::Json, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let current_user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::SESSION_ACCESS]), ) .await? .1; client_updates .validate() .map_err(|e| ApiError::Validation(validation_errors_to_string(e, None)))?; if client_updates.icon_url.is_none() && client_updates.name.is_none() && client_updates.max_scopes.is_none() { return Err(ApiError::InvalidInput("No changes provided".to_string())); } if let Some(existing_client) = OAuthClient::get(client_id.into_inner().into(), &**pool).await? { existing_client.validate_authorized(Some(¤t_user))?; let mut updated_client = existing_client.clone(); let OAuthClientEdit { name, icon_url, max_scopes, redirect_uris, } = client_updates.into_inner(); if let Some(name) = name { updated_client.name = name; } if let Some(icon_url) = icon_url { updated_client.icon_url = icon_url; } if let Some(max_scopes) = max_scopes { updated_client.max_scopes = max_scopes; } let mut transaction = pool.begin().await?; updated_client .update_editable_fields(&mut *transaction) .await?; if let Some(redirects) = redirect_uris { edit_redirects(redirects, &existing_client, &mut transaction).await?; } transaction.commit().await?; Ok(HttpResponse::Ok().body("")) } else { Ok(HttpResponse::NotFound().body("")) } } #[get("authorizations")] pub async fn get_user_oauth_authorizations( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let current_user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::SESSION_ACCESS]), ) .await? .1; let authorizations = OAuthClientAuthorization::get_all_for_user(current_user.id.into(), &**pool).await?; let mapped: Vec = authorizations.into_iter().map(|a| a.into()).collect_vec(); Ok(HttpResponse::Ok().json(mapped)) } #[delete("authorizations")] pub async fn revoke_oauth_authorization( req: HttpRequest, info: web::Query, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let current_user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::SESSION_ACCESS]), ) .await? .1; OAuthClientAuthorization::remove(info.client_id.into(), current_user.id.into(), &**pool) .await?; Ok(HttpResponse::Ok().body("")) } fn generate_oauth_client_secret() -> String { ChaCha20Rng::from_entropy() .sample_iter(&Alphanumeric) .take(32) .map(char::from) .collect::() } async fn create_redirect_uris( uri_strings: impl IntoIterator, client_id: OAuthClientId, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result, DatabaseError> { let mut redirect_uris = vec![]; for uri in uri_strings.into_iter() { let id = generate_oauth_redirect_id(transaction).await?; redirect_uris.push(OAuthRedirectUri { id, client_id, uri: uri.to_string(), }); } Ok(redirect_uris) } async fn edit_redirects( redirects: Vec, existing_client: &OAuthClient, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DatabaseError> { let updated_redirects: HashSet = redirects.into_iter().collect(); let original_redirects: HashSet = existing_client .redirect_uris .iter() .map(|r| r.uri.to_string()) .collect(); let redirects_to_add = create_redirect_uris( updated_redirects.difference(&original_redirects), existing_client.id, &mut *transaction, ) .await?; OAuthClient::insert_redirect_uris(&redirects_to_add, &mut **transaction).await?; let mut redirects_to_remove = existing_client.redirect_uris.clone(); redirects_to_remove.retain(|r| !updated_redirects.contains(&r.uri)); OAuthClient::remove_redirect_uris(redirects_to_remove.iter().map(|r| r.id), &mut **transaction) .await?; Ok(()) } pub async fn get_clients_inner( ids: &[ApiOAuthClientId], req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result, ApiError> { let current_user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::SESSION_ACCESS]), ) .await? .1; let ids: Vec = ids.iter().map(|i| (*i).into()).collect(); let clients = OAuthClient::get_many(&ids, &**pool).await?; clients .iter() .validate_all_authorized(Some(¤t_user))?; Ok(clients.into_iter().map(|c| c.into()).collect_vec()) }