diff options
author | mdecimus <mauro@stalw.art> | 2024-09-28 17:45:16 +0200 |
---|---|---|
committer | mdecimus <mauro@stalw.art> | 2024-09-28 17:45:16 +0200 |
commit | 1fed40a926b7070ad59fc69cb57ee1b67d53502f (patch) | |
tree | 9da1bc7e9bfec69cdad6a5b45996341060bbf9ec /crates | |
parent | 7528715c2dca7477d36f8af36396892766a87eb4 (diff) |
RFC7662 - OAuth 2.0 Token Introspection
Diffstat (limited to 'crates')
-rw-r--r-- | crates/common/src/auth/mod.rs | 9 | ||||
-rw-r--r-- | crates/common/src/auth/oauth/introspect.rs | 97 | ||||
-rw-r--r-- | crates/common/src/auth/oauth/mod.rs | 89 | ||||
-rw-r--r-- | crates/common/src/auth/oauth/token.rs | 163 | ||||
-rw-r--r-- | crates/common/src/auth/sasl.rs | 131 | ||||
-rw-r--r-- | crates/imap/src/op/authenticate.rs | 75 | ||||
-rw-r--r-- | crates/jmap/src/api/http.rs | 22 | ||||
-rw-r--r-- | crates/jmap/src/api/management/enterprise/telemetry.rs | 6 | ||||
-rw-r--r-- | crates/jmap/src/auth/oauth/mod.rs | 12 | ||||
-rw-r--r-- | crates/jmap/src/auth/oauth/token.rs | 71 | ||||
-rw-r--r-- | crates/managesieve/src/op/authenticate.rs | 25 | ||||
-rw-r--r-- | crates/pop3/src/op/authenticate.rs | 17 | ||||
-rw-r--r-- | crates/smtp/src/inbound/auth.rs | 85 |
13 files changed, 508 insertions, 294 deletions
diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 5a67a0e0..7b74cff8 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -11,6 +11,7 @@ use directory::{ }; use jmap_proto::types::collection::Collection; use mail_send::Credentials; +use oauth::GrantType; use utils::map::{bitmap::Bitmap, ttl_dashmap::TtlMap, vec_map::VecMap}; use crate::Server; @@ -18,6 +19,7 @@ use crate::Server; pub mod access_token; pub mod oauth; pub mod roles; +pub mod sasl; #[derive(Debug, Clone, Default)] pub struct AccessToken { @@ -58,8 +60,11 @@ impl Server { // Validate credentials match &req.credentials { Credentials::OAuthBearer { token } => { - match self.validate_access_token("access_token", token).await { - Ok((account_id, _, _)) => self.get_cached_access_token(account_id).await, + match self + .validate_access_token(GrantType::AccessToken.into(), token) + .await + { + Ok(token_into) => self.get_cached_access_token(token_into.account_id).await, Err(err) => Err(err), } } diff --git a/crates/common/src/auth/oauth/introspect.rs b/crates/common/src/auth/oauth/introspect.rs new file mode 100644 index 00000000..f4e931b5 --- /dev/null +++ b/crates/common/src/auth/oauth/introspect.rs @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art> + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use serde::{Deserialize, Serialize}; +use trc::{AddContext, AuthEvent, EventType}; + +use crate::{auth::AccessToken, Server}; + +#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)] +pub struct OAuthIntrospect { + #[serde(default)] + pub active: bool, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option<String>, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option<String>, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option<String>, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub token_type: Option<String>, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option<i64>, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub iat: Option<i64>, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub nbf: Option<i64>, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option<String>, + /*#[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option<String>, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option<String>, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option<String>,*/ +} + +impl Server { + pub async fn introspect_access_token( + &self, + token: &str, + access_token: &AccessToken, + ) -> trc::Result<OAuthIntrospect> { + match self.validate_access_token(None, token).await { + Ok(token_info) => Ok(OAuthIntrospect { + active: true, + client_id: Some(token_info.client_id), + username: if access_token.primary_id() == token_info.account_id { + access_token.name.clone() + } else { + self.get_cached_access_token(token_info.account_id) + .await + .caused_by(trc::location!())? + .name + .clone() + } + .into(), + token_type: "bearer".to_string().into(), + exp: Some(token_info.expiry as i64), + iat: Some(token_info.issued_at as i64), + ..Default::default() + }), + Err(err) + if matches!( + err.inner, + EventType::Auth(AuthEvent::Error) | EventType::Auth(AuthEvent::TokenExpired) + ) => + { + Ok(OAuthIntrospect::default()) + } + Err(err) => Err(err), + } + } +} diff --git a/crates/common/src/auth/oauth/mod.rs b/crates/common/src/auth/oauth/mod.rs index b58474e1..20078868 100644 --- a/crates/common/src/auth/oauth/mod.rs +++ b/crates/common/src/auth/oauth/mod.rs @@ -5,6 +5,7 @@ */ pub mod crypto; +pub mod introspect; pub mod token; pub const DEVICE_CODE_LEN: usize = 40; @@ -14,68 +15,40 @@ pub const CLIENT_ID_MAX_LEN: usize = 20; pub const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0, O, I, 1 -pub fn extract_oauth_bearer(bytes: &[u8]) -> Option<&str> { - let mut start_pos = 0; - let eof = bytes.len().saturating_sub(1); - - for (pos, ch) in bytes.iter().enumerate() { - let is_separator = *ch == 1; - if is_separator || pos == eof { - if bytes - .get(start_pos..start_pos + 12) - .map_or(false, |s| s.eq_ignore_ascii_case(b"auth=Bearer ")) - { - return bytes - .get(start_pos + 12..if is_separator { pos } else { bytes.len() }) - .and_then(|s| std::str::from_utf8(s).ok()); - } +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum GrantType { + AccessToken, + RefreshToken, + LiveTracing, + LiveMetrics, +} - start_pos = pos + 1; +impl GrantType { + pub fn as_str(&self) -> &'static str { + match self { + GrantType::AccessToken => "access_token", + GrantType::RefreshToken => "refresh_token", + GrantType::LiveTracing => "live_tracing", + GrantType::LiveMetrics => "live_metrics", } } - None -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_oauth_bearer() { - let input = b"auth=Bearer validtoken"; - let result = extract_oauth_bearer(input); - assert_eq!(result, Some("validtoken")); - - let input = b"auth=Invalid validtoken"; - let result = extract_oauth_bearer(input); - assert_eq!(result, None); - - let input = b"auth=Bearer"; - let result = extract_oauth_bearer(input); - assert_eq!(result, None); - - let input = b""; - let result = extract_oauth_bearer(input); - assert_eq!(result, None); - - let input = b"auth=Bearer token1\x01auth=Bearer token2"; - let result = extract_oauth_bearer(input); - assert_eq!(result, Some("token1")); - - let input = b"auth=Bearer VALIDTOKEN"; - let result = extract_oauth_bearer(input); - assert_eq!(result, Some("VALIDTOKEN")); - - let input = b"auth=Bearer token with spaces"; - let result = extract_oauth_bearer(input); - assert_eq!(result, Some("token with spaces")); - - let input = b"auth=Bearer token_with_special_chars!@#"; - let result = extract_oauth_bearer(input); - assert_eq!(result, Some("token_with_special_chars!@#")); + pub fn id(&self) -> u8 { + match self { + GrantType::AccessToken => 0, + GrantType::RefreshToken => 1, + GrantType::LiveTracing => 2, + GrantType::LiveMetrics => 3, + } + } - let input = "n,a=user@example.com,\x01host=server.example.com\x01port=143\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==\x01\x01"; - let result = extract_oauth_bearer(input.as_bytes()); - assert_eq!(result, Some("vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==")); + pub fn from_id(id: u8) -> Option<Self> { + match id { + 0 => Some(GrantType::AccessToken), + 1 => Some(GrantType::RefreshToken), + 2 => Some(GrantType::LiveTracing), + 3 => Some(GrantType::LiveMetrics), + _ => None, + } } } diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs index 08904983..ec6cb946 100644 --- a/crates/common/src/auth/oauth/token.rs +++ b/crates/common/src/auth/oauth/token.rs @@ -13,63 +13,71 @@ use store::{ blake3, rand::{thread_rng, Rng}, }; +use trc::AddContext; use utils::codec::leb128::{Leb128Iterator, Leb128Vec}; use crate::Server; -use super::{crypto::SymmetricEncrypt, CLIENT_ID_MAX_LEN, RANDOM_CODE_LEN}; +use super::{crypto::SymmetricEncrypt, GrantType, CLIENT_ID_MAX_LEN, RANDOM_CODE_LEN}; + +pub struct TokenInfo { + pub grant_type: GrantType, + pub account_id: u32, + pub client_id: String, + pub expiry: u64, + pub issued_at: u64, + pub expires_in: u64, +} + +const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000 impl Server { - pub async fn issue_custom_token( + pub async fn encode_access_token( &self, + grant_type: GrantType, account_id: u32, - grant_type: &str, client_id: &str, expiry_in: u64, ) -> trc::Result<String> { - self.encode_access_token( - grant_type, - account_id, - &self - .password_hash(account_id) - .await - .map_err(|err| trc::StoreEvent::UnexpectedError.into_err().details(err))?, - client_id, - expiry_in, - ) - .map_err(|err| trc::StoreEvent::UnexpectedError.into_err().details(err)) - } - - pub fn encode_access_token( - &self, - grant_type: &str, - account_id: u32, - password_hash: &str, - client_id: &str, - expiry_in: u64, - ) -> Result<String, &'static str> { // Build context if client_id.len() > CLIENT_ID_MAX_LEN { - return Err("ClientId is too long"); + return Err(trc::AuthEvent::Error + .into_err() + .details("Client id too long")); } - let key = self.core.jmap.oauth_key.clone(); + + // Include password hash if expiration is over 1 hour + let password_hash = if expiry_in > 3600 { + self.password_hash(account_id) + .await + .caused_by(trc::location!())? + } else { + String::new() + }; + + let key = &self.core.jmap.oauth_key; let context = format!( "{} {} {} {}", - grant_type, client_id, account_id, password_hash + grant_type.as_str(), + client_id, + account_id, + password_hash ); - let context_nonce = format!("{} nonce {}", grant_type, password_hash); // Set expiration time - let expiry = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - .saturating_sub(946684800) // Jan 1, 2000 - + expiry_in; + let issued_at = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) + .saturating_sub(OAUTH_EPOCH); // Jan 1, 2000 + let expiry = issued_at + expiry_in; // Calculate nonce let mut hasher = blake3::Hasher::new(); - hasher.update(context_nonce.as_bytes()); + if !password_hash.is_empty() { + hasher.update(password_hash.as_bytes()); + } + hasher.update(grant_type.as_str().as_bytes()); + hasher.update(issued_at.to_be_bytes().as_slice()); hasher.update(expiry.to_be_bytes().as_slice()); let nonce = hasher .finalize() @@ -82,8 +90,15 @@ impl Server { // Encrypt random bytes let mut token = SymmetricEncrypt::new(key.as_bytes(), &context) .encrypt(&thread_rng().gen::<[u8; RANDOM_CODE_LEN]>(), &nonce) - .map_err(|_| "Failed to encrypt token.")?; + .map_err(|_| { + trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::Reason, "Failed to encrypt token") + .caused_by(trc::location!()) + })?; token.push_leb128(account_id); + token.push(grant_type.id()); + token.push_leb128(issued_at); token.push_leb128(expiry); token.extend_from_slice(client_id.as_bytes()); @@ -92,9 +107,9 @@ impl Server { pub async fn validate_access_token( &self, - grant_type: &str, + expected_grant_type: Option<GrantType>, token_: &str, - ) -> trc::Result<(u32, String, u64)> { + ) -> trc::Result<TokenInfo> { // Base64 decode token let token = base64_decode(token_.as_bytes()).ok_or_else(|| { trc::AuthEvent::Error @@ -103,12 +118,14 @@ impl Server { .caused_by(trc::location!()) .details(token_.to_string()) })?; - let (account_id, expiry, client_id) = token + let (account_id, grant_type, issued_at, expiry, client_id) = token .get((RANDOM_CODE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN)..) .and_then(|bytes| { let mut bytes = bytes.iter(); ( bytes.next_leb128()?, + GrantType::from_id(bytes.next().copied()?)?, + bytes.next_leb128::<u64>()?, bytes.next_leb128::<u64>()?, bytes.copied().map(char::from).collect::<String>(), ) @@ -125,30 +142,45 @@ impl Server { // Validate expiration let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - .saturating_sub(946684800); // Jan 1, 2000 - if expiry <= now { + .map_or(0, |d| d.as_secs()) + .saturating_sub(OAUTH_EPOCH); // Jan 1, 2000 + if expiry <= now || issued_at > now { return Err(trc::AuthEvent::TokenExpired.into_err()); } + // Validate grant type + if expected_grant_type.map_or(false, |g| g != grant_type) { + return Err(trc::AuthEvent::Error + .into_err() + .details("Invalid grant type")); + } + // Obtain password hash - let password_hash = self - .password_hash(account_id) - .await - .map_err(|err| trc::AuthEvent::Error.into_err().ctx(trc::Key::Details, err))?; + let password_hash = if expiry - issued_at > 3600 { + self.password_hash(account_id) + .await + .map_err(|err| trc::AuthEvent::Error.into_err().ctx(trc::Key::Details, err))? + } else { + String::new() + }; // Build context let key = self.core.jmap.oauth_key.clone(); let context = format!( "{} {} {} {}", - grant_type, client_id, account_id, password_hash + grant_type.as_str(), + client_id, + account_id, + password_hash ); - let context_nonce = format!("{} nonce {}", grant_type, password_hash); // Calculate nonce let mut hasher = blake3::Hasher::new(); - hasher.update(context_nonce.as_bytes()); + if !password_hash.is_empty() { + hasher.update(password_hash.as_bytes()); + } + hasher.update(grant_type.as_str().as_bytes()); + hasher.update(issued_at.to_be_bytes().as_slice()); hasher.update(expiry.to_be_bytes().as_slice()); let nonce = hasher .finalize() @@ -173,27 +205,46 @@ impl Server { })?; // Success - Ok((account_id, client_id, expiry - now)) + Ok(TokenInfo { + grant_type, + account_id, + client_id, + expiry: expiry + OAUTH_EPOCH, + issued_at: issued_at + OAUTH_EPOCH, + expires_in: expiry - now, + }) } - pub async fn password_hash(&self, account_id: u32) -> Result<String, &'static str> { + pub async fn password_hash(&self, account_id: u32) -> trc::Result<String> { if account_id != u32::MAX { self.core .storage .directory .query(QueryBy::Id(account_id), false) .await - .map_err(|_| "Temporary lookup error")? - .ok_or("Account no longer exists")? + .caused_by(trc::location!())? + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Account no longer exists") + })? .take_str_array(PrincipalField::Secrets) .unwrap_or_default() .into_iter() .next() - .ok_or("Failed to obtain password hash") + .ok_or( + trc::AuthEvent::Error + .into_err() + .details("Account does not contain secrets") + .caused_by(trc::location!()), + ) } else if let Some((_, secret)) = &self.core.jmap.fallback_admin { Ok(secret.clone()) } else { - Err("Invalid account id.") + Err(trc::AuthEvent::Error + .into_err() + .details("Invalid account ID") + .caused_by(trc::location!())) } } } diff --git a/crates/common/src/auth/sasl.rs b/crates/common/src/auth/sasl.rs new file mode 100644 index 00000000..92ba84b6 --- /dev/null +++ b/crates/common/src/auth/sasl.rs @@ -0,0 +1,131 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art> + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use mail_send::Credentials; + +pub fn sasl_decode_challenge_plain(challenge: &[u8]) -> Option<Credentials<String>> { + let mut username = Vec::new(); + let mut secret = Vec::new(); + let mut arg_num = 0; + for &ch in challenge { + if ch != 0 { + if arg_num == 1 { + username.push(ch); + } else if arg_num == 2 { + secret.push(ch); + } + } else { + arg_num += 1; + } + } + + match (String::from_utf8(username), String::from_utf8(secret)) { + (Ok(username), Ok(secret)) if !username.is_empty() && !secret.is_empty() => { + Some((username, secret).into()) + } + _ => None, + } +} + +pub fn sasl_decode_challenge_xoauth(challenge: &[u8]) -> Option<Credentials<String>> { + let mut b_username = Vec::new(); + let mut b_secret = Vec::new(); + let mut arg_num = 0; + let mut in_arg = false; + + for &ch in challenge { + if in_arg { + if ch != 1 { + if arg_num == 1 { + b_username.push(ch); + } else if arg_num == 2 { + b_secret.push(ch); + } + } else { + in_arg = false; + } + } else if ch == b'=' { + arg_num += 1; + in_arg = true; + } + } + match (String::from_utf8(b_username), String::from_utf8(b_secret)) { + (Ok(s_username), Ok(s_secret)) if !s_username.is_empty() => { + Some((s_username, s_secret).into()) + } + _ => None, + } +} + +pub fn sasl_decode_challenge_oauth(challenge: &[u8]) -> Option<Credentials<String>> { + extract_oauth_bearer(challenge).map(|s| Credentials::OAuthBearer { token: s.into() }) +} + +fn extract_oauth_bearer(bytes: &[u8]) -> Option<&str> { + let mut start_pos = 0; + let eof = bytes.len().saturating_sub(1); + + for (pos, ch) in bytes.iter().enumerate() { + let is_separator = *ch == 1; + if is_separator || pos == eof { + if bytes + .get(start_pos..start_pos + 12) + .map_or(false, |s| s.eq_ignore_ascii_case(b"auth=Bearer ")) + { + return bytes + .get(start_pos + 12..if is_separator { pos } else { bytes.len() }) + .and_then(|s| std::str::from_utf8(s).ok()); + } + + start_pos = pos + 1; + } + } + + None +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_oauth_bearer() { + let input = b"auth=Bearer validtoken"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some("validtoken")); + + let input = b"auth=Invalid validtoken"; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b"auth=Bearer"; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b""; + let result = extract_oauth_bearer(input); + assert_eq!(result, None); + + let input = b"auth=Bearer token1\x01auth=Bearer token2"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some("token1")); + + let input = b"auth=Bearer VALIDTOKEN"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some("VALIDTOKEN")); + + let input = b"auth=Bearer token with spaces"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some("token with spaces")); + + let input = b"auth=Bearer token_with_special_chars!@#"; + let result = extract_oauth_bearer(input); + assert_eq!(result, Some("token_with_special_chars!@#")); + + let input = "n,a=user@example.com,\x01host=server.example.com\x01port=143\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==\x01\x01"; + let result = extract_oauth_bearer(input.as_bytes()); + assert_eq!(result, Some("vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==")); + } +} diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index 3858b25c..c4140ec8 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -4,7 +4,13 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{auth::AuthRequest, listener::SessionStream}; +use common::{ + auth::{ + sasl::{sasl_decode_challenge_oauth, sasl_decode_challenge_plain}, + AuthRequest, + }, + listener::SessionStream, +}; use directory::Permission; use imap_proto::{ protocol::{authenticate::Mechanism, capability::Capability}, @@ -35,14 +41,14 @@ impl<T: SessionStream> Session<T> { })?; let credentials = if args.mechanism == Mechanism::Plain { - decode_challenge_plain(&challenge) + sasl_decode_challenge_plain(&challenge) } else { - decode_challenge_oauth(&challenge) + sasl_decode_challenge_oauth(&challenge) } - .map_err(|err| { + .ok_or_else(|| { trc::AuthEvent::Error .into_err() - .details(err) + .details("Invalid SASL challenge.") .id(args.tag.clone()) })?; @@ -152,62 +158,3 @@ impl<T: SessionStream> Session<T> { .await } } - -pub fn decode_challenge_plain(challenge: &[u8]) -> Result<Credentials<String>, &'static str> { - let mut username = Vec::new(); - let mut secret = Vec::new(); - let mut arg_num = 0; - for &ch in challenge { - if ch != 0 { - if arg_num == 1 { - username.push(ch); - } else if arg_num == 2 { - secret.push(ch); - } - } else { - arg_num += 1; - } - } - - match (String::from_utf8(username), String::from_utf8(secret)) { - (Ok(username), Ok(secret)) if !username.is_empty() && !secret.is_empty() => { - Ok((username, secret).into()) - } - _ => Err("Invalid AUTH=PLAIN challenge."), - } -} - -pub fn decode_challenge_oauth(challenge: &[u8]) -> Result<Credentials<String>, &'static str> { - let mut saw_marker = true; - for (pos, &ch) in challenge.iter().enumerate() { - if saw_marker { - if challenge - .get(pos..) - .map_or(false, |b| b.starts_with(b"auth=Bearer ")) - { - let pos = pos + 12; - return Ok(Credentials::OAuthBearer { - token: String::from_utf8( - challenge - .get( - pos..pos - + challenge - .get(pos..) - .and_then(|c| c.iter().position(|&ch| ch == 0x01)) - .unwrap_or(challenge.len()), - ) - .ok_or("Failed to find end of bearer token")? - .to_vec(), - ) - .map_err(|_| "Bearer token is not a valid UTF-8 string.")?, - }); - } else { - saw_marker = false; - } - } else if ch == 0x01 { - saw_marker = true; - } - } - - Err("Failed to find 'auth=Bearer' in challenge.") -} diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index d3dd0938..9de2e2ab 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -7,7 +7,7 @@ use std::{borrow::Cow, net::IpAddr, sync::Arc}; use common::{ - auth::AccessToken, + auth::{oauth::GrantType, AccessToken}, core::BuildServer, expr::{functions::ResolveVariable, *}, ipc::StateEvent, @@ -285,6 +285,15 @@ impl ParseHttp for Server { .handle_token_request(&mut req, session.session_id) .await; } + ("introspect", &Method::POST) => { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + return self + .handle_token_introspect(&mut req, &access_token, session.session_id) + .await; + } (_, &Method::OPTIONS) => { return Ok(StatusCode::NO_CONTENT.into_http_response()); } @@ -321,21 +330,22 @@ impl ParseHttp for Server { .strip_prefix("/api/telemetry/") .and_then(|p| { p.strip_prefix("traces/live/") - .map(|t| ("traces", "live_tracing", t)) + .map(|t| ("traces", GrantType::LiveTracing, t)) .or_else(|| { p.strip_prefix("metrics/live/") - .map(|t| ("metrics", "live_metrics", t)) + .map(|t| ("metrics", GrantType::LiveMetrics, t)) }) }) { - let (account_id, _, _) = - self.validate_access_token(grant_type, token).await?; + let token_info = self + .validate_access_token(grant_type.into(), token) + .await?; return self .handle_telemetry_api_request( &req, vec!["", live_path, "live"], - &AccessToken::from_id(account_id) + &AccessToken::from_id(token_info.account_id) .with_permission(Permission::MetricsLive) .with_permission(Permission::TracingLive), ) diff --git a/crates/jmap/src/api/management/enterprise/telemetry.rs b/crates/jmap/src/api/management/enterprise/telemetry.rs index 376837fc..0ae46fe9 100644 --- a/crates/jmap/src/api/management/enterprise/telemetry.rs +++ b/crates/jmap/src/api/management/enterprise/telemetry.rs @@ -14,7 +14,7 @@ use std::{ }; use common::{ - auth::AccessToken, + auth::{oauth::GrantType, AccessToken}, telemetry::{ metrics::store::{Metric, MetricsStore}, tracers::store::{TracingQuery, TracingStore}, @@ -355,7 +355,7 @@ impl TelemetryApi for Server { // Issue a live telemetry token valid for 60 seconds Ok(JsonResponse::new(json!({ - "data": self.issue_custom_token(account_id, "live_tracing", "web", 60).await?, + "data": self.encode_access_token(GrantType::LiveTracing, account_id, "web", 60).await?, })) .into_http_response()) } @@ -366,7 +366,7 @@ impl TelemetryApi for Server { // Issue a live telemetry token valid for 60 seconds Ok(JsonResponse::new(json!({ - "data": self.issue_custom_token(account_id, "live_metrics", "web", 60).await?, + "data": self.encode_access_token(GrantType::LiveMetrics, account_id, "web", 60).await?, })) .into_http_response()) } diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs index f56585eb..eaa8c0c1 100644 --- a/crates/jmap/src/auth/oauth/mod.rs +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -140,11 +140,12 @@ pub enum ErrorType { pub struct OAuthMetadata { pub issuer: String, pub token_endpoint: String, - pub grant_types_supported: Vec<String>, + pub authorization_endpoint: String, pub device_authorization_endpoint: String, + pub introspection_endpoint: String, + pub grant_types_supported: Vec<String>, pub response_types_supported: Vec<String>, pub scopes_supported: Vec<String>, - pub authorization_endpoint: String, } impl OAuthMetadata { @@ -152,16 +153,17 @@ impl OAuthMetadata { let base_url = base_url.as_ref(); OAuthMetadata { issuer: base_url.into(), - authorization_endpoint: format!("{}/authorize/code", base_url), - token_endpoint: format!("{}/auth/token", base_url), + authorization_endpoint: format!("{base_url}/authorize/code",), + token_endpoint: format!("{base_url}/auth/token"), grant_types_supported: vec![ "authorization_code".to_string(), "implicit".to_string(), "urn:ietf:params:oauth:grant-type:device_code".to_string(), ], - device_authorization_endpoint: format!("{}/auth/device", base_url), + device_authorization_endpoint: format!("{base_url}/auth/device"), response_types_supported: vec!["code".to_string(), "code token".to_string()], scopes_supported: vec!["offline_access".to_string()], + introspection_endpoint: format!("{base_url}/auth/introspect"), } } } diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs index 277a94ae..49046746 100644 --- a/crates/jmap/src/auth/oauth/token.rs +++ b/crates/jmap/src/auth/oauth/token.rs @@ -4,7 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::Server; +use common::{ + auth::{oauth::GrantType, AccessToken}, + Server, +}; use hyper::StatusCode; use std::future::Future; use store::write::Bincode; @@ -22,12 +25,19 @@ pub trait TokenHandler: Sync + Send { session_id: u64, ) -> impl Future<Output = trc::Result<HttpResponse>> + Send; + fn handle_token_introspect( + &self, + req: &mut HttpRequest, + access_token: &AccessToken, + session_id: u64, + ) -> impl Future<Output = trc::Result<HttpResponse>> + Send; + fn issue_token( &self, account_id: u32, client_id: &str, with_refresh_token: bool, - ) -> impl Future<Output = Result<OAuthResponse, &'static str>> + Send; + ) -> impl Future<Output = trc::Result<OAuthResponse>> + Send; } impl TokenHandler for Server { @@ -139,14 +149,15 @@ impl TokenHandler for Server { } else if grant_type.eq_ignore_ascii_case("refresh_token") { if let Some(refresh_token) = params.get("refresh_token") { response = match self - .validate_access_token("refresh_token", refresh_token) + .validate_access_token(GrantType::RefreshToken.into(), refresh_token) .await { - Ok((account_id, client_id, time_left)) => self + Ok(token_info) => self .issue_token( - account_id, - &client_id, - time_left <= self.core.jmap.oauth_expiry_refresh_token_renew, + token_info.account_id, + &token_info.client_id, + token_info.expires_in + <= self.core.jmap.oauth_expiry_refresh_token_renew, ) .await .map(TokenResponse::Granted) @@ -180,32 +191,52 @@ impl TokenHandler for Server { .into_http_response()) } + async fn handle_token_introspect( + &self, + req: &mut HttpRequest, + access_token: &AccessToken, + session_id: u64, + ) -> trc::Result<HttpResponse> { + // Parse token + let token = FormData::from_request(req, 1024, session_id) + .await? + .remove("token") + .ok_or_else(|| { + trc::ResourceEvent::BadParameters + .into_err() + .details("Client ID is missing.") + })?; + + self.introspect_access_token(&token, access_token) + .await + .map(|response| JsonResponse::new(response).into_http_response()) + } + async fn issue_token( &self, account_id: u32, client_id: &str, with_refresh_token: bool, - ) -> Result<OAuthResponse, &'static str> { - let password_hash = self.password_hash(account_id).await?; - + ) -> trc::Result<OAuthResponse> { Ok(OAuthResponse { - access_token: self.encode_access_token( - "access_token", - account_id, - &password_hash, - client_id, - self.core.jmap.oauth_expiry_token, - )?, + access_token: self + .encode_access_token( + GrantType::AccessToken, + account_id, + client_id, + self.core.jmap.oauth_expiry_token, + ) + .await?, token_type: "bearer".to_string(), expires_in: self.core.jmap.oauth_expiry_token, refresh_token: if with_refresh_token { self.encode_access_token( - "refresh_token", + GrantType::RefreshToken, account_id, - &password_hash, client_id, self.core.jmap.oauth_expiry_refresh_token, - )? + ) + .await? .into() } else { None diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index eaec3c48..893a33a1 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -5,12 +5,14 @@ */ use common::{ - auth::AuthRequest, + auth::{ + sasl::{sasl_decode_challenge_oauth, sasl_decode_challenge_plain}, + AuthRequest, + }, listener::{limiter::ConcurrencyLimiter, SessionStream}, ConcurrencyLimiters, }; use directory::Permission; -use imap::op::authenticate::{decode_challenge_oauth, decode_challenge_plain}; use imap_proto::{ protocol::authenticate::Mechanism, receiver::{self, Request}, @@ -39,18 +41,19 @@ impl<T: SessionStream> Session<T> { let credentials = match mechanism { Mechanism::Plain | Mechanism::OAuthBearer => { if !params.is_empty() { - let challenge = - base64_decode(params.pop().unwrap().as_bytes()).ok_or_else(|| { + base64_decode(params.pop().unwrap().as_bytes()) + .and_then(|challenge| { + if mechanism == Mechanism::Plain { + sasl_decode_challenge_plain(&challenge) + } else { + sasl_decode_challenge_oauth(&challenge) + } + }) + .ok_or_else(|| { trc::AuthEvent::Error .into_err() .details("Failed to decode challenge.") - })?; - (if mechanism == Mechanism::Plain { - decode_challenge_plain(&challenge) - } else { - decode_challenge_oauth(&challenge) - } - .map_err(|err| trc::AuthEvent::Error.into_err().details(err)))? + })? } else { self.receiver.request = receiver::Request { tag: String::new(), diff --git a/crates/pop3/src/op/authenticate.rs b/crates/pop3/src/op/authenticate.rs index 4c70afa5..795043ce 100644 --- a/crates/pop3/src/op/authenticate.rs +++ b/crates/pop3/src/op/authenticate.rs @@ -5,12 +5,14 @@ */ use common::{ - auth::AuthRequest, + auth::{ + sasl::{sasl_decode_challenge_oauth, sasl_decode_challenge_plain}, + AuthRequest, + }, listener::{limiter::ConcurrencyLimiter, SessionStream}, ConcurrencyLimiters, }; use directory::Permission; -use imap::op::authenticate::{decode_challenge_oauth, decode_challenge_plain}; use jmap::auth::rate_limit::RateLimiter; use mail_parser::decoders::base64::base64_decode; use mail_send::Credentials; @@ -31,15 +33,18 @@ impl<T: SessionStream> Session<T> { Mechanism::Plain | Mechanism::OAuthBearer => { if !params.is_empty() { let credentials = base64_decode(params.pop().unwrap().as_bytes()) - .ok_or("Failed to decode challenge.") .and_then(|challenge| { if mechanism == Mechanism::Plain { - decode_challenge_plain(&challenge) + sasl_decode_challenge_plain(&challenge) } else { - decode_challenge_oauth(&challenge) + sasl_decode_challenge_oauth(&challenge) } }) - .map_err(|err| trc::AuthEvent::Error.into_err().details(err))?; + .ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Invalid SASL challenge") + })?; self.handle_auth(credentials).await } else { diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index 88b32d64..3a728f1a 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -5,7 +5,12 @@ */ use common::{ - auth::{oauth::extract_oauth_bearer, AuthRequest}, + auth::{ + sasl::{ + sasl_decode_challenge_oauth, sasl_decode_challenge_plain, sasl_decode_challenge_xoauth, + }, + AuthRequest, + }, listener::SessionStream, }; use directory::Permission; @@ -74,30 +79,9 @@ impl<T: SessionStream> Session<T> { } } else if let Some(response) = base64_decode(response) { match (token.mechanism, &mut token.credentials) { - (AUTH_PLAIN, Credentials::Plain { username, secret }) => { - let mut b_username = Vec::new(); - let mut b_secret = Vec::new(); - let mut arg_num = 0; - for ch in response { - if ch != 0 { - if arg_num == 1 { - b_username.push(ch); - } else if arg_num == 2 { - b_secret.push(ch); - } - } else { - arg_num += 1; - } - } - match (String::from_utf8(b_username), String::from_utf8(b_secret)) { - (Ok(s_username), Ok(s_secret)) if !s_username.is_empty() => { - *username = s_username; - *secret = s_secret; - return self - .authenticate(std::mem::take(&mut token.credentials)) - .await; - } - _ => (), + (AUTH_PLAIN, _) => { + if let Some(credentials) = sasl_decode_challenge_plain(&response) { + return self.authenticate(credentials).await; } } (AUTH_LOGIN, Credentials::Plain { username, secret }) => { @@ -111,45 +95,14 @@ impl<T: SessionStream> Session<T> { .await }; } - (AUTH_OAUTHBEARER, Credentials::OAuthBearer { token: token_ }) => { - if let Some(bearer) = extract_oauth_bearer(&response) { - *token_ = bearer.to_string(); - return self - .authenticate(std::mem::take(&mut token.credentials)) - .await; + (AUTH_OAUTHBEARER, _) => { + if let Some(credentials) = sasl_decode_challenge_oauth(&response) { + return self.authenticate(credentials).await; } } - (AUTH_XOAUTH2, Credentials::XOauth2 { username, secret }) => { - let mut b_username = Vec::new(); - let mut b_secret = Vec::new(); - let mut arg_num = 0; - let mut in_arg = false; - - for ch in response { - if in_arg { - if ch != 1 { - if arg_num == 1 { - b_username.push(ch); - } else if arg_num == 2 { - b_secret.push(ch); - } - } else { - in_arg = false; - } - } else if ch == b'=' { - arg_num += 1; - in_arg = true; - } - } - match (String::from_utf8(b_username), String::from_utf8(b_secret)) { - (Ok(s_username), Ok(s_secret)) if !s_username.is_empty() => { - *username = s_username; - *secret = s_secret; - return self - .authenticate(std::mem::take(&mut token.credentials)) - .await; - } - _ => (), + (AUTH_XOAUTH2, _) => { + if let Some(credentials) = sasl_decode_challenge_xoauth(&response) { + return self.authenticate(credentials).await; } } @@ -210,7 +163,13 @@ impl<T: SessionStream> Session<T> { .await; } trc::EventType::Security(trc::SecurityEvent::Unauthorized) => { - self.write(b"550 5.7.1 Your account is not authorized to use this service.\r\n") + self.write( + concat!( + "550 5.7.1 Your account is not authorized ", + "to use this service.\r\n" + ) + .as_bytes(), + ) .await?; return Ok(false); } |