From 75bb02d13abe8e133f1268f1f6d17d81054b3d40 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Tue, 20 Feb 2024 18:44:14 +0100 Subject: [PATCH] OAuth REST API --- crates/jmap/src/api/admin.rs | 44 +++++++++++++++++++- crates/jmap/src/api/http.rs | 23 +++++++---- crates/jmap/src/auth/oauth/mod.rs | 32 ++++++++------ crates/jmap/src/auth/oauth/token.rs | 16 +++---- crates/jmap/src/auth/oauth/user_code.rs | 55 ++++++++++++++++--------- resources/config/jmap/protocol.toml | 2 +- 6 files changed, 123 insertions(+), 49 deletions(-) diff --git a/crates/jmap/src/api/admin.rs b/crates/jmap/src/api/admin.rs index 01e072d52..dc1beba8a 100644 --- a/crates/jmap/src/api/admin.rs +++ b/crates/jmap/src/api/admin.rs @@ -21,6 +21,8 @@ * for more details. */ +use std::sync::Arc; + use directory::{ backend::internal::{lookup::DirectoryStore, manage::ManageDirectory, PrincipalUpdate}, DirectoryError, ManagementError, Principal, QueryBy, Type, @@ -31,7 +33,11 @@ use jmap_proto::error::request::RequestError; use serde_json::json; use utils::config::ConfigKey; -use crate::{services::housekeeper, JMAP}; +use crate::{ + auth::{oauth::OAuthCodeRequest, AccessToken}, + services::housekeeper, + JMAP, +}; use super::{http::ToHttpResponse, HttpRequest, JsonResponse}; @@ -53,10 +59,11 @@ pub struct PrincipalResponse { } impl JMAP { - pub async fn handle_manage_request( + pub async fn handle_api_manage_request( &self, req: &HttpRequest, body: Option>, + access_token: Arc, ) -> hyper::Response> { let mut path = req.uri().path().split('/'); path.next(); @@ -423,6 +430,7 @@ impl JMAP { .into_http_response() } } + ("oauth", _, _) => self.handle_api_request(req, body, access_token).await, (path_1 @ ("queue" | "report"), Some(path_2), &Method::GET) => { self.smtp .handle_manage_request(req.uri(), req.method(), path_1, path_2) @@ -431,6 +439,38 @@ impl JMAP { _ => RequestError::not_found().into_http_response(), } } + + pub async fn handle_api_request( + &self, + req: &HttpRequest, + body: Option>, + access_token: Arc, + ) -> hyper::Response> { + let mut path = req.uri().path().split('/'); + path.next(); + path.next(); + + match (path.next().unwrap_or(""), path.next(), req.method()) { + ("oauth", Some("code"), &Method::POST) => { + if let Some(request) = + body.and_then(|body| serde_json::from_slice::(&body).ok()) + { + JsonResponse::new(json!({ + "data": self.issue_client_code(&access_token, request.client_id, request.redirect_uri), + })) + .into_http_response() + } else { + RequestError::blank( + StatusCode::BAD_REQUEST.as_u16(), + "Invalid parameters", + "Failed to deserialize modify request", + ) + .into_http_response() + } + } + _ => RequestError::unauthorized().into_http_response(), + } + } } fn map_directory_error(err: DirectoryError) -> hyper::Response> { diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index 7817fd1af..ba500ab99 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -268,16 +268,25 @@ pub async fn parse_jmap_request( } } "api" => { + // Allow CORS preflight requests + if req.method() == Method::OPTIONS { + return ().into_http_response(); + } + // Make sure the user is a superuser - let body = match jmap.authenticate_headers(&req, remote_ip).await { - Ok(Some((_, access_token))) if access_token.is_super_user() => { - fetch_body(&mut req, 8192, &access_token).await + return match jmap.authenticate_headers(&req, remote_ip).await { + Ok(Some((_, access_token))) => { + let body = fetch_body(&mut req, 8192, &access_token).await; + if access_token.is_super_user() { + jmap.handle_api_manage_request(&req, body, access_token) + .await + } else { + jmap.handle_api_request(&req, body, access_token).await + } } - Ok(_) => return RequestError::unauthorized().into_http_response(), - Err(err) => return err.into_http_response(), + Ok(None) => RequestError::unauthorized().into_http_response(), + Err(err) => err.into_http_response(), }; - - return jmap.handle_manage_request(&req, body).await; } _ => (), } diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs index d3ddb8d60..6e30e90a2 100644 --- a/crates/jmap/src/auth/oauth/mod.rs +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -73,6 +73,7 @@ pub struct OAuth { pub metadata: String, } +#[derive(Debug)] pub struct OAuthCode { pub status: AtomicU32, pub account_id: AtomicU32, @@ -136,18 +137,19 @@ pub struct TokenRequest { #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(untagged)] pub enum TokenResponse { - Granted { - access_token: String, - token_type: String, - expires_in: u64, - #[serde(skip_serializing_if = "Option::is_none")] - refresh_token: Option, - #[serde(skip_serializing_if = "Option::is_none")] - scope: Option, - }, - Error { - error: ErrorType, - }, + Granted(OAuthResponse), + Error { error: ErrorType }, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct OAuthResponse { + access_token: String, + token_type: String, + expires_in: u64, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -203,6 +205,12 @@ impl OAuthMetadata { } } +#[derive(Debug, Serialize, Deserialize)] +pub struct OAuthCodeRequest { + pub client_id: String, + pub redirect_uri: Option, +} + impl TokenResponse { pub fn error(error: ErrorType) -> Self { TokenResponse::Error { error } diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs index a328e83b1..deedc5d86 100644 --- a/crates/jmap/src/auth/oauth/token.rs +++ b/crates/jmap/src/auth/oauth/token.rs @@ -43,8 +43,8 @@ use crate::{ }; use super::{ - ErrorType, FormData, TokenResponse, CLIENT_ID_MAX_LEN, MAX_POST_LEN, RANDOM_CODE_LEN, - STATUS_AUTHORIZED, STATUS_PENDING, STATUS_TOKEN_ISSUED, + ErrorType, FormData, OAuthResponse, TokenResponse, CLIENT_ID_MAX_LEN, MAX_POST_LEN, + RANDOM_CODE_LEN, STATUS_AUTHORIZED, STATUS_PENDING, STATUS_TOKEN_ISSUED, }; impl JMAP { @@ -83,6 +83,7 @@ impl JMAP { true, ) .await + .map(TokenResponse::Granted) .unwrap_or_else(|err| { tracing::error!("Failed to generate OAuth token: {}", err); TokenResponse::error(ErrorType::InvalidRequest) @@ -122,6 +123,7 @@ impl JMAP { true, ) .await + .map(TokenResponse::Granted) .unwrap_or_else(|err| { tracing::error!("Failed to generate OAuth token: {}", err); TokenResponse::error(ErrorType::InvalidRequest) @@ -153,6 +155,7 @@ impl JMAP { time_left <= self.config.oauth_expiry_refresh_token_renew, ) .await + .map(TokenResponse::Granted) .unwrap_or_else(|err| { tracing::debug!("Failed to refresh OAuth token: {}", err); TokenResponse::error(ErrorType::InvalidGrant) @@ -174,12 +177,12 @@ impl JMAP { .into_http_response() } - async fn issue_token( + pub async fn issue_token( &self, account_id: u32, client_id: &str, with_refresh_token: bool, - ) -> Result { + ) -> Result { let password_hash = self .directory .query(QueryBy::Id(account_id), false) @@ -191,7 +194,7 @@ impl JMAP { .next() .ok_or("Failed to obtain password hash")?; - Ok(TokenResponse::Granted { + Ok(OAuthResponse { access_token: self.encode_access_token( "access_token", account_id, @@ -297,8 +300,7 @@ impl JMAP { return Err("Token expired."); } - // Optain password hash - + // Obtain password hash let password_hash = self .directory .query(QueryBy::Id(account_id), false) diff --git a/crates/jmap/src/auth/oauth/user_code.rs b/crates/jmap/src/auth/oauth/user_code.rs index d18912e20..0af1ace0c 100644 --- a/crates/jmap/src/auth/oauth/user_code.rs +++ b/crates/jmap/src/auth/oauth/user_code.rs @@ -39,6 +39,7 @@ use utils::map::ttl_dashmap::TtlMap; use crate::{ api::{http::ToHttpResponse, HtmlResponse, HttpRequest, HttpResponse}, + auth::AccessToken, JMAP, }; @@ -108,6 +109,33 @@ impl JMAP { HtmlResponse::new(response).into_http_response() } + pub fn issue_client_code( + &self, + access_token: &AccessToken, + client_id: String, + redirect_uri: Option, + ) -> String { + // Generate client code + let client_code = thread_rng() + .sample_iter(Alphanumeric) + .take(DEVICE_CODE_LEN) + .map(char::from) + .collect::(); + + // Add client code + self.oauth_codes.insert_with_ttl( + client_code.clone(), + Arc::new(OAuthCode { + status: STATUS_AUTHORIZED.into(), + account_id: access_token.primary_id().into(), + client_id, + redirect_uri, + }), + Instant::now() + Duration::from_secs(self.config.oauth_expiry_auth_code), + ); + client_code + } + // Handles POST request from the code authorization form pub async fn handle_user_code_auth_post( &self, @@ -141,30 +169,17 @@ impl JMAP { if let AuthResult::Success(access_token) = self.authenticate_plain(email, password, remote_addr).await { - // Generate client code - let client_code = thread_rng() - .sample_iter(Alphanumeric) - .take(DEVICE_CODE_LEN) - .map(char::from) - .collect::(); - - // Add client code - self.oauth_codes.insert_with_ttl( - client_code.clone(), - Arc::new(OAuthCode { - status: STATUS_AUTHORIZED.into(), - account_id: access_token.primary_id().into(), - client_id: code_req + auth_code = self + .issue_client_code( + &access_token, + code_req .get("client_id") .map(|s| s.as_str()) .unwrap_or_default() .to_string(), - redirect_uri: code_req.get("redirect_uri").cloned(), - }), - Instant::now() + Duration::from_secs(self.config.oauth_expiry_auth_code), - ); - - auth_code = client_code.into(); + code_req.get("redirect_uri").cloned(), + ) + .into(); } } diff --git a/resources/config/jmap/protocol.toml b/resources/config/jmap/protocol.toml index 0d04585f3..df2abc364 100644 --- a/resources/config/jmap/protocol.toml +++ b/resources/config/jmap/protocol.toml @@ -45,4 +45,4 @@ allow-lookups = true [jmap.http] #headers = ["Access-Control-Allow-Origin: *", # "Access-Control-Allow-Methods: POST, GET, HEAD, OPTIONS", -# "Access-Control-Allow-Headers: *"] +# "Access-Control-Allow-Headers: Authorization, Content-Type, Accept, X-Requested-With"]