diff --git a/CHANGELOG.md b/CHANGELOG.md index d761b3fb..2155872d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [0.16.8] - 2026-06-XX + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added + +## Changed +- OAuth: Rework access tokens to an `AES-256-GCM-SIV` AEAD format that carries the account name for proxy routing and revokes tokens on credential change. + +## Fixed + ## [0.16.7] - 2026-05-28 If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. diff --git a/Cargo.lock b/Cargo.lock index ec8a9bf8..b9a2b923 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,7 +1084,7 @@ dependencies = [ [[package]] name = "common" -version = "0.16.7" +version = "0.16.8" dependencies = [ "aes-gcm-siv", "ahash", @@ -1280,7 +1280,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "coordinator" -version = "0.16.7" +version = "0.16.8" dependencies = [ "async-nats", "futures", @@ -1633,7 +1633,7 @@ checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "dav" -version = "0.16.7" +version = "0.16.8" dependencies = [ "calcard", "chrono", @@ -1656,7 +1656,7 @@ dependencies = [ [[package]] name = "dav-proto" -version = "0.16.7" +version = "0.16.8" dependencies = [ "calcard", "chrono", @@ -1855,7 +1855,7 @@ dependencies = [ [[package]] name = "directory" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "argon2", @@ -2096,7 +2096,7 @@ dependencies = [ [[package]] name = "email" -version = "0.16.7" +version = "0.16.8" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -2193,7 +2193,7 @@ dependencies = [ [[package]] name = "event_macro" -version = "0.16.7" +version = "0.16.8" dependencies = [ "proc-macro2", "quote", @@ -2690,7 +2690,7 @@ dependencies = [ [[package]] name = "groupware" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "calcard", @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "http" -version = "0.16.7" +version = "0.16.8" dependencies = [ "async-stream", "base64 0.22.1", @@ -3068,7 +3068,7 @@ dependencies = [ [[package]] name = "http_proto" -version = "0.16.7" +version = "0.16.8" dependencies = [ "common", "compact_str", @@ -3340,7 +3340,7 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "imap" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "common", @@ -3367,7 +3367,7 @@ dependencies = [ [[package]] name = "imap_proto" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "chrono", @@ -3579,7 +3579,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.16.7" +version = "0.16.8" dependencies = [ "aes-gcm", "aes-gcm-siv", @@ -3667,7 +3667,7 @@ dependencies = [ [[package]] name = "jmap_proto" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "calcard", @@ -4176,7 +4176,7 @@ dependencies = [ [[package]] name = "managesieve" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "common", @@ -4289,7 +4289,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492" [[package]] name = "migration" -version = "0.16.7" +version = "0.16.8" dependencies = [ "base64 0.22.1", "common", @@ -4506,7 +4506,7 @@ dependencies = [ [[package]] name = "nlp" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "bincode 1.3.3", @@ -5240,7 +5240,7 @@ dependencies = [ [[package]] name = "pop3" -version = "0.16.7" +version = "0.16.8" dependencies = [ "common", "directory", @@ -5412,7 +5412,7 @@ dependencies = [ [[package]] name = "proc_macros" -version = "0.16.7" +version = "0.16.8" dependencies = [ "proc-macro2", "quote", @@ -6035,7 +6035,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "registry" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "hashify", @@ -6965,7 +6965,7 @@ dependencies = [ [[package]] name = "services" -version = "0.16.7" +version = "0.16.8" dependencies = [ "aes-gcm", "aes-gcm-siv", @@ -7217,7 +7217,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smtp" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "base64 0.22.1", @@ -7318,7 +7318,7 @@ dependencies = [ [[package]] name = "spam-filter" -version = "0.16.7" +version = "0.16.8" dependencies = [ "common", "compact_str", @@ -7428,7 +7428,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stalwart" -version = "0.16.7" +version = "0.16.8" dependencies = [ "common", "coordinator", @@ -7436,7 +7436,7 @@ dependencies = [ "directory", "email", "groupware", - "http 0.16.7", + "http 0.16.8", "http_proto", "imap", "jemallocator", @@ -7465,7 +7465,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "arc-swap", @@ -7662,7 +7662,7 @@ dependencies = [ [[package]] name = "tests" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "async-trait", @@ -7686,7 +7686,7 @@ dependencies = [ "form_urlencoded", "futures", "groupware", - "http 0.16.7", + "http 0.16.8", "http-body-util", "http_proto", "hyper", @@ -8222,7 +8222,7 @@ dependencies = [ [[package]] name = "trc" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "base64 0.22.1", @@ -8315,7 +8315,7 @@ checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "types" -version = "0.16.7" +version = "0.16.8" dependencies = [ "blake3", "compact_str", @@ -8480,7 +8480,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utils" -version = "0.16.7" +version = "0.16.8" dependencies = [ "ahash", "arcstr", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 831e0eef..e172e514 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common" -version = "0.16.7" +version = "0.16.8" edition = "2024" build = "build.rs" diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 0895241e..bb387622 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -31,6 +31,7 @@ use tinyvec::TinyVec; use trc::{AddContext, StoreEvent}; use types::{acl::Acl, collection::Collection}; use utils::map::bitmap::{Bitmap, BitmapItem}; +use xxhash_rust::xxh3; impl Server { async fn build_access_token( @@ -124,6 +125,7 @@ impl Server { } let now = now(); + let mut credential_version = 0; let mut credential_scopes = Vec::with_capacity(account.credentials.len()); credential_scopes.push(AccessScope::new(permissions.finalize(), u32::MAX)); @@ -131,6 +133,8 @@ impl Server { for credential in account.credentials { match credential { structs::Credential::Password(credential) => { + credential_version = xxh3::xxh3_64(credential.secret.as_bytes()).max(1); + if credential.expires_at.is_some() || !credential.allowed_ips.is_empty() { let credential_scope = &mut credential_scopes[0]; @@ -201,6 +205,7 @@ impl Server { obj_size: 0, revision, revision_account, + credential_version, account_id, tenant_id, member_of, @@ -243,6 +248,7 @@ impl Server { obj_size: 0, revision, revision_account, + credential_version: 0, account_id, tenant_id, member_of: Default::default(), @@ -552,6 +558,7 @@ impl AccessToken { concurrent_uploads: old_inner.concurrent_uploads.clone(), revision_account: old_inner.revision_account, revision: old_inner.revision, + credential_version: old_inner.credential_version, obj_size: old_inner.obj_size, }; @@ -760,6 +767,7 @@ impl AccessToken { concurrent_uploads: Default::default(), revision: Default::default(), revision_account: Default::default(), + credential_version: Default::default(), obj_size: Default::default(), }), } @@ -804,6 +812,7 @@ impl AccessTokenInner { concurrent_uploads: Default::default(), revision: Default::default(), revision_account: Default::default(), + credential_version: Default::default(), obj_size: Default::default(), } } @@ -815,6 +824,10 @@ impl AccessTokenInner { pub fn revision_account(&self) -> u64 { self.revision_account } + + pub fn credential_version(&self) -> u64 { + self.credential_version + } } impl AccessScope { diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 67edc828..e0f95a3d 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -137,6 +137,7 @@ pub struct AccessTokenInner { pub(crate) concurrent_uploads: Option, pub(crate) revision_account: u64, pub(crate) revision: u64, + pub(crate) credential_version: u64, pub(crate) obj_size: u64, } diff --git a/crates/common/src/auth/oauth/crypto.rs b/crates/common/src/auth/oauth/crypto.rs index e54d5e02..9f22dabc 100644 --- a/crates/common/src/auth/oauth/crypto.rs +++ b/crates/common/src/auth/oauth/crypto.rs @@ -4,15 +4,16 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use aes_gcm_siv::{AeadInPlace, Aes256GcmSiv, Key, KeyInit, Nonce, aead::Aead}; +use aes_gcm_siv::{ + Aes256GcmSiv, Key, KeyInit, Nonce, + aead::{Aead, Payload}, +}; use store::blake3; pub struct SymmetricEncrypt { aes: Aes256GcmSiv, } -//TODO: Remove allow deprecated when aes-gcm is updated -#[allow(deprecated)] impl SymmetricEncrypt { pub const ENCRYPT_TAG_LEN: usize = 16; pub const NONCE_LEN: usize = 12; @@ -25,22 +26,25 @@ impl SymmetricEncrypt { } } - #[allow(clippy::ptr_arg)] - pub fn encrypt_in_place(&self, bytes: &mut Vec, nonce: &[u8]) -> Result<(), String> { + pub fn encrypt_with_aad( + &self, + bytes: &[u8], + nonce: &[u8], + aad: &[u8], + ) -> Result, String> { self.aes - .encrypt_in_place(Nonce::from_slice(nonce), b"", bytes) + .encrypt(Nonce::from_slice(nonce), Payload { msg: bytes, aad }) .map_err(|e| e.to_string()) } - pub fn encrypt(&self, bytes: &[u8], nonce: &[u8]) -> Result, String> { + pub fn decrypt_with_aad( + &self, + bytes: &[u8], + nonce: &[u8], + aad: &[u8], + ) -> Result, String> { self.aes - .encrypt(Nonce::from_slice(nonce), bytes) - .map_err(|e| e.to_string()) - } - - pub fn decrypt(&self, bytes: &[u8], nonce: &[u8]) -> Result, String> { - self.aes - .decrypt(Nonce::from_slice(nonce), bytes) + .decrypt(Nonce::from_slice(nonce), Payload { msg: bytes, aad }) .map_err(|e| e.to_string()) } } diff --git a/crates/common/src/auth/oauth/introspect.rs b/crates/common/src/auth/oauth/introspect.rs index dff0c82d..39a4c3ba 100644 --- a/crates/common/src/auth/oauth/introspect.rs +++ b/crates/common/src/auth/oauth/introspect.rs @@ -4,11 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::{Server, auth::AccessToken}; use serde::{Deserialize, Serialize}; use trc::{AddContext, AuthEvent, EventType}; -use crate::{Server, auth::AccessToken}; - #[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)] pub struct OAuthIntrospect { #[serde(default)] @@ -56,7 +55,6 @@ impl Server { match self.validate_access_token(None, token).await { Ok(token_info) => Ok(OAuthIntrospect { active: true, - client_id: Some(token_info.client_id), username: self .account(access_token.account_id()) .await diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs index 6c41388d..255cbdce 100644 --- a/crates/common/src/auth/oauth/token.rs +++ b/crates/common/src/auth/oauth/token.rs @@ -4,16 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use super::{CLIENT_ID_MAX_LEN, GrantType, RANDOM_CODE_LEN, crypto::SymmetricEncrypt}; +use super::{GrantType, crypto::SymmetricEncrypt}; use crate::Server; use base64::{Engine, engine::general_purpose}; -use registry::schema::structs::Account; use std::time::SystemTime; -use store::{ - blake3, - rand::{Rng, rng}, -}; -use trc::AddContext; +use store::rand::{Rng, rng}; use utils::codec::leb128::{Leb128Iterator, Leb128Vec}; pub const FAILED_TO_DECODE_TOKEN: &str = concat!( @@ -22,92 +17,62 @@ pub const FAILED_TO_DECODE_TOKEN: &str = concat!( "the Authentication object." ); +const TOKEN_HEADER: &str = "sw1."; +const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1"; +const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000 + pub struct TokenInfo { pub grant_type: GrantType, pub account_id: u32, - pub client_id: String, + pub claims: Option, pub expiry: u64, pub issued_at: u64, pub expires_in: u64, } -const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000 +struct RawToken { + grant_type: GrantType, + account_id: u32, + claims: Option, + issued_at: u64, + expiry: u64, + credential_version: u64, +} impl Server { pub async fn encode_access_token( &self, grant_type: GrantType, account_id: u32, - client_id: &str, + account_name: &str, expiry_in: u64, + claims: Option<&str>, + credential_version: Option, ) -> trc::Result { - // Build context - let mut password_hash = String::new(); - - if !matches!(grant_type, GrantType::Rsvp) { - if client_id.len() > CLIENT_ID_MAX_LEN { - return Err(trc::AuthEvent::Error - .into_err() - .details("Client id too long")); - } - - // Include password hash if expiration is over 1 hour - if expiry_in > 3600 { - password_hash = self - .password_hash(account_id) - .await - .caused_by(trc::location!())? - } - } - - let key = &self.core.oauth.oauth_key; - let context = format!( - "{} {} {} {}", - grant_type.as_str(), - client_id, + let issued_at = seconds_since_oauth_epoch(); + let raw = RawToken { + grant_type, account_id, - password_hash - ); + claims: claims.map(|claims| claims.to_string()), + issued_at, + expiry: issued_at + expiry_in, + credential_version: credential_version + .filter(|_| !matches!(grant_type, GrantType::Rsvp)) + .unwrap_or_default(), + }; - // Set expiration time - 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(); - 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() - .as_bytes() - .iter() - .take(SymmetricEncrypt::NONCE_LEN) - .copied() - .collect::>(); - - // Encrypt random bytes - let mut token = SymmetricEncrypt::new(key.as_bytes(), &context) - .encrypt(&rng().random::<[u8; RANDOM_CODE_LEN]>(), &nonce) - .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()); - - Ok(general_purpose::URL_SAFE_NO_PAD.encode(&token)) + seal_token( + self.core.oauth.oauth_key.as_bytes(), + &raw, + account_name.as_bytes(), + ) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::Reason, "Failed to encrypt token") + .reason(err) + .caused_by(trc::location!()) + }) } pub async fn validate_access_token( @@ -115,143 +80,339 @@ impl Server { expected_grant_type: Option, token_: &str, ) -> trc::Result { - // Base64 decode token - let token = general_purpose::URL_SAFE_NO_PAD - .decode(token_.as_bytes()) - .map_err(|_| { - trc::AuthEvent::Error - .into_err() - .ctx(trc::Key::Reason, FAILED_TO_DECODE_TOKEN) - .caused_by(trc::location!()) - .details(token_.to_string()) - })?; - 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::()?, - bytes.next_leb128::()?, - bytes.copied().map(char::from).collect::(), - ) - .into() - }) - .ok_or_else(|| { - trc::AuthEvent::Error - .into_err() - .ctx(trc::Key::Reason, FAILED_TO_DECODE_TOKEN) - .caused_by(trc::location!()) - .details(token_.to_string()) - })?; + let token = open_token(self.core.oauth.oauth_key.as_bytes(), token_).map_err(|_| { + trc::AuthEvent::Error + .into_err() + .ctx(trc::Key::Reason, FAILED_TO_DECODE_TOKEN) + .caused_by(trc::location!()) + .details(token_.to_string()) + })?; // Validate expiration - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()) - .saturating_sub(OAUTH_EPOCH); // Jan 1, 2000 - if expiry <= now || issued_at > now { + let now = seconds_since_oauth_epoch(); + if token.expiry <= now || token.issued_at > now { return Err(trc::AuthEvent::TokenExpired.into_err()); } // Validate grant type - if expected_grant_type.is_some_and(|g| g != grant_type) { + if expected_grant_type.is_some_and(|g| g != token.grant_type) { return Err(trc::AuthEvent::Error .into_err() .details("Invalid grant type")); } - // Obtain password hash - let password_hash = if !matches!(grant_type, GrantType::Rsvp) && expiry - issued_at > 3600 { - self.password_hash(account_id) + // Enforce credential revocation for long lived tokens + if token.credential_version != 0 { + let current = self + .access_token(token.account_id) .await .map_err(|err| trc::AuthEvent::Error.into_err().ctx(trc::Key::Details, err))? - } else { - "".into() - }; - - // Build context - let key = self.core.oauth.oauth_key.clone(); - let context = format!( - "{} {} {} {}", - grant_type.as_str(), - client_id, - account_id, - password_hash - ); - - // Calculate nonce - let mut hasher = blake3::Hasher::new(); - 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() - .as_bytes() - .iter() - .take(SymmetricEncrypt::NONCE_LEN) - .copied() - .collect::>(); - - // Decrypt - SymmetricEncrypt::new(key.as_bytes(), &context) - .decrypt( - &token[..RANDOM_CODE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN], - &nonce, - ) - .map_err(|err| { - trc::AuthEvent::Error + .credential_version(); + if current != token.credential_version { + return Err(trc::AuthEvent::TokenExpired .into_err() - .ctx(trc::Key::Details, FAILED_TO_DECODE_TOKEN) - .caused_by(trc::location!()) - .reason(err) - })?; + .details("Token revoked")); + } + } - // Success Ok(TokenInfo { - grant_type, - account_id, - client_id, - expiry: expiry + OAUTH_EPOCH, - issued_at: issued_at + OAUTH_EPOCH, - expires_in: expiry - now, + grant_type: token.grant_type, + account_id: token.account_id, + claims: token.claims, + expiry: token.expiry + OAUTH_EPOCH, + issued_at: token.issued_at + OAUTH_EPOCH, + expires_in: token.expiry - now, }) } +} - pub async fn password_hash(&self, account_id: u32) -> trc::Result { - if account_id != u32::MAX { - let Some(account) = self - .registry() - .object::(account_id.into()) - .await - .caused_by(trc::location!())? - else { - return Err(trc::AuthEvent::Error - .into_err() - .details("Account no longer exists")); - }; - let Some(account) = account.into_user() else { - return Err(trc::AuthEvent::Error - .into_err() - .details("Account is not a user")); - }; - account.into_password().ok_or_else(|| { - trc::AuthEvent::Error.into_err().details(concat!( - "Account does not have a password. ", - "If you are using an external directory, make sure to set the password attribute." - )) - }) - } else if let Some((_, secret)) = self.registry().recovery_admin() { - Ok(secret.into()) - } else { - Err(trc::AuthEvent::Error - .into_err() - .details("Invalid account ID") - .caused_by(trc::location!())) +fn seal_token(key: &[u8], token: &RawToken, footer: &[u8]) -> Result { + let mut payload = Vec::with_capacity(32); + payload.push_leb128(token.account_id); + payload.push(token.grant_type.id()); + payload.push_leb128(token.issued_at); + payload.push_leb128(token.expiry); + payload.push_leb128(token.credential_version); + if let Some(claims) = token.claims.as_deref().filter(|claims| !claims.is_empty()) { + payload.extend_from_slice(claims.as_bytes()); + } + + let nonce = rng().random::<[u8; SymmetricEncrypt::NONCE_LEN]>(); + let ciphertext = + SymmetricEncrypt::new(key, TOKEN_KEY_CONTEXT).encrypt_with_aad(&payload, &nonce, footer)?; + + let mut body = Vec::with_capacity(nonce.len() + ciphertext.len()); + body.extend_from_slice(&nonce); + body.extend_from_slice(&ciphertext); + + let mut out = String::with_capacity(TOKEN_HEADER.len() + (body.len() + footer.len()) * 2); + out.push_str(TOKEN_HEADER); + general_purpose::URL_SAFE_NO_PAD.encode_string(&body, &mut out); + if !footer.is_empty() { + out.push('.'); + general_purpose::URL_SAFE_NO_PAD.encode_string(footer, &mut out); + } + + Ok(out) +} + +fn open_token(key: &[u8], token: &str) -> Result { + let rest = token.strip_prefix(TOKEN_HEADER).ok_or(())?; + let (body, footer) = match rest.split_once('.') { + Some((body, footer)) => ( + body, + general_purpose::URL_SAFE_NO_PAD + .decode(footer.as_bytes()) + .map_err(|_| ())?, + ), + None => (rest, Vec::new()), + }; + let body = general_purpose::URL_SAFE_NO_PAD + .decode(body.as_bytes()) + .map_err(|_| ())?; + if body.len() < SymmetricEncrypt::NONCE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN { + return Err(()); + } + let (nonce, ciphertext) = body.split_at(SymmetricEncrypt::NONCE_LEN); + + let payload = SymmetricEncrypt::new(key, TOKEN_KEY_CONTEXT) + .decrypt_with_aad(ciphertext, nonce, &footer) + .map_err(|_| ())?; + + let mut bytes = payload.iter(); + let account_id: u32 = bytes.next_leb128().ok_or(())?; + let grant_type = GrantType::from_id(bytes.next().copied().ok_or(())?).ok_or(())?; + let issued_at: u64 = bytes.next_leb128().ok_or(())?; + let expiry: u64 = bytes.next_leb128().ok_or(())?; + let credential_version: u64 = bytes.next_leb128().ok_or(())?; + let bytes = bytes.as_slice(); + let claims = if bytes.is_empty() { + None + } else { + Some(String::from_utf8(bytes.to_vec()).map_err(|_| ())?) + }; + + Ok(RawToken { + grant_type, + account_id, + claims, + issued_at, + expiry, + credential_version, + }) +} + +#[inline(always)] +fn seconds_since_oauth_epoch() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) + .saturating_sub(OAUTH_EPOCH) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &[u8] = b"a-test-encryption-key-of-some-length"; + const NAME: &[u8] = b"user@example.org"; + + fn sample(grant_type: GrantType, claims: Option<&str>, cv: u64) -> RawToken { + RawToken { + grant_type, + account_id: 42, + claims: claims.map(|c| c.to_string()), + issued_at: 1_000, + expiry: 2_000, + credential_version: cv, } } + + fn assert_eq_fields(a: &RawToken, b: &RawToken) { + assert_eq!(a.account_id, b.account_id); + assert_eq!(a.grant_type, b.grant_type); + assert_eq!(a.claims, b.claims); + assert_eq!(a.issued_at, b.issued_at); + assert_eq!(a.expiry, b.expiry); + assert_eq!(a.credential_version, b.credential_version); + } + + #[test] + fn round_trip_preserves_all_fields() { + for (raw, footer) in [ + (sample(GrantType::AccessToken, None, 0), NAME), + ( + sample(GrantType::RefreshToken, None, 0xdead_beef_cafe), + NAME, + ), + ( + sample(GrantType::Rsvp, Some("attendee@x.org;7"), 0), + b"owner@example.org", + ), + (sample(GrantType::AccessToken, None, 0), b""), + ( + RawToken { + account_id: u32::MAX, + credential_version: u64::MAX, + ..sample(GrantType::AccessToken, Some("名前;1"), 1) + }, + "名字@example.org".as_bytes(), + ), + ] { + let token = seal_token(KEY, &raw, footer).unwrap(); + assert!(token.starts_with(TOKEN_HEADER)); + let opened = open_token(KEY, &token).unwrap(); + assert_eq_fields(&raw, &opened); + + // The footer (account name) round-trips in clear text for proxies + if footer.is_empty() { + assert!(!token[TOKEN_HEADER.len()..].contains('.')); + } else { + let segment = token.rsplit_once('.').unwrap().1; + assert_eq!( + general_purpose::URL_SAFE_NO_PAD.decode(segment).unwrap(), + footer + ); + } + } + } + + #[test] + fn account_name_is_readable_in_clear_text_footer() { + let token = seal_token( + KEY, + &sample(GrantType::AccessToken, None, 0), + b"route-me@example.org", + ) + .unwrap(); + let footer = token.rsplit_once('.').unwrap().1; + let decoded = general_purpose::URL_SAFE_NO_PAD.decode(footer).unwrap(); + assert_eq!(decoded, b"route-me@example.org"); + } + + #[test] + fn wrong_key_is_rejected() { + let token = seal_token(KEY, &sample(GrantType::AccessToken, None, 0), NAME).unwrap(); + assert!(open_token(b"a-different-encryption-key-entirely!", &token).is_err()); + } + + #[test] + fn tampering_with_ciphertext_is_rejected() { + let raw = sample(GrantType::AccessToken, None, 0); + let token = seal_token(KEY, &raw, NAME).unwrap(); + let (header, rest) = token.split_at(TOKEN_HEADER.len()); + let (body_b64, footer) = match rest.split_once('.') { + Some((b, f)) => (b.to_string(), Some(f.to_string())), + None => (rest.to_string(), None), + }; + let mut body = general_purpose::URL_SAFE_NO_PAD.decode(&body_b64).unwrap(); + + for idx in 0..body.len() { + let mut tampered = body.clone(); + tampered[idx] ^= 0x01; + let mut rebuilt = String::from(header); + rebuilt.push_str(&general_purpose::URL_SAFE_NO_PAD.encode(&tampered)); + if let Some(footer) = &footer { + rebuilt.push('.'); + rebuilt.push_str(footer); + } + assert!( + open_token(KEY, &rebuilt).is_err(), + "flipping byte {idx} of the body must invalidate the token" + ); + } + + // Sanity: the untampered token still opens + body[0] ^= 0x00; + assert!(open_token(KEY, &token).is_ok()); + } + + #[test] + fn tampering_with_clear_text_footer_is_rejected() { + let raw = sample(GrantType::AccessToken, None, 0); + let token = seal_token(KEY, &raw, b"victim@example.org").unwrap(); + let (body, _) = token.rsplit_once('.').unwrap(); + + // An attacker rewrites the clear-text account name to impersonate another account + let forged_footer = general_purpose::URL_SAFE_NO_PAD.encode(b"attacker@example.org"); + let forged = format!("{body}.{forged_footer}"); + assert!( + open_token(KEY, &forged).is_err(), + "the footer is bound through the associated data and must be authenticated" + ); + } + + #[test] + fn swapping_footers_between_tokens_is_rejected() { + let a = seal_token( + KEY, + &sample(GrantType::AccessToken, None, 0), + b"alice@example.org", + ) + .unwrap(); + let b = seal_token( + KEY, + &sample(GrantType::AccessToken, None, 0), + b"bob@example.org", + ) + .unwrap(); + let a_body = a.rsplit_once('.').unwrap().0; + let b_footer = b.rsplit_once('.').unwrap().1; + let frankentoken = format!("{a_body}.{b_footer}"); + assert!(open_token(KEY, &frankentoken).is_err()); + } + + #[test] + fn malformed_input_never_panics_and_is_rejected() { + let valid = seal_token(KEY, &sample(GrantType::AccessToken, None, 0), NAME).unwrap(); + let cases = [ + String::new(), + "sw1.".to_string(), + "sw1.!!!not-base64!!!".to_string(), + "sw1...".to_string(), + "wrong-prefix.".to_string(), + "sw1.AAAA".to_string(), + "sw1.AAAA.BBBB".to_string(), + valid.replace("sw1.", "sw2."), + valid[..valid.len() / 2].to_string(), + format!("sw1.{}", "A".repeat(10_000)), + "\u{0}\u{0}\u{0}".to_string(), + ]; + for case in cases { + assert!(open_token(KEY, &case).is_err(), "must reject {case:?}"); + } + } + + #[test] + fn truncating_the_body_is_rejected() { + let token = seal_token(KEY, &sample(GrantType::AccessToken, None, 0), NAME).unwrap(); + let (header, rest) = token.split_at(TOKEN_HEADER.len()); + let body_b64 = rest.split_once('.').map(|(b, _)| b).unwrap_or(rest); + let body = general_purpose::URL_SAFE_NO_PAD.decode(body_b64).unwrap(); + for len in 0..body.len() { + let mut rebuilt = String::from(header); + rebuilt.push_str(&general_purpose::URL_SAFE_NO_PAD.encode(&body[..len])); + assert!( + open_token(KEY, &rebuilt).is_err(), + "truncation to {len} must be rejected" + ); + } + } + + #[test] + fn identical_input_produces_distinct_tokens() { + let raw = sample(GrantType::AccessToken, None, 7); + let a = seal_token(KEY, &raw, NAME).unwrap(); + let b = seal_token(KEY, &raw, NAME).unwrap(); + assert_ne!(a, b, "a random nonce must make each token unique"); + assert_eq_fields(&open_token(KEY, &a).unwrap(), &open_token(KEY, &b).unwrap()); + } + + #[test] + fn claims_with_separators_round_trip_exactly() { + let raw = sample(GrantType::Rsvp, Some("a;b;c;d@e.org;999"), 0); + let token = seal_token(KEY, &raw, b"owner@example.org").unwrap(); + let opened = open_token(KEY, &token).unwrap(); + assert_eq!(opened.claims.as_deref(), Some("a;b;c;d@e.org;999")); + } } diff --git a/crates/common/src/config/smtp/queue.rs b/crates/common/src/config/smtp/queue.rs index 2ba11e5d..ca334e09 100644 --- a/crates/common/src/config/smtp/queue.rs +++ b/crates/common/src/config/smtp/queue.rs @@ -393,14 +393,13 @@ impl QueueConfig { enums::MtaProtocol::Smtp => ServerProtocol::Smtp, enums::MtaProtocol::Lmtp => ServerProtocol::Lmtp, }, - auth: route - .auth_username - .and_then(|user| secret.map(|secret| (user, secret))) - .map(|(user, secret)| Credentials::Basic { + auth: route.auth_username.zip(secret).map(|(user, secret)| { + Credentials::Basic { username: user, secret: secret.into_owned(), mfa_token: None, - }), + } + }), tls_implicit: route.implicit_tls, tls_allow_invalid_certs: route.allow_invalid_certs, }), diff --git a/crates/coordinator/Cargo.toml b/crates/coordinator/Cargo.toml index 485568be..9a525f53 100644 --- a/crates/coordinator/Cargo.toml +++ b/crates/coordinator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "coordinator" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/dav-proto/Cargo.toml b/crates/dav-proto/Cargo.toml index c2511bf5..b30ff4c7 100644 --- a/crates/dav-proto/Cargo.toml +++ b/crates/dav-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dav-proto" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/dav/Cargo.toml b/crates/dav/Cargo.toml index f232c534..e487e4e2 100644 --- a/crates/dav/Cargo.toml +++ b/crates/dav/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dav" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index a07bd2e1..328020d4 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "directory" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml index 9a634257..230e4a1f 100644 --- a/crates/email/Cargo.toml +++ b/crates/email/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "email" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/groupware/Cargo.toml b/crates/groupware/Cargo.toml index 97cb5a1e..2beb56e3 100644 --- a/crates/groupware/Cargo.toml +++ b/crates/groupware/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "groupware" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/groupware/src/calendar/itip.rs b/crates/groupware/src/calendar/itip.rs index ef9eedbd..f6bcba26 100644 --- a/crates/groupware/src/calendar/itip.rs +++ b/crates/groupware/src/calendar/itip.rs @@ -64,6 +64,7 @@ pub trait ItipIngest: Sync + Send { fn http_rsvp_url( &self, account_id: u32, + account_name: &str, document_id: u32, attendee: &str, ) -> impl Future> + Send; @@ -397,6 +398,7 @@ impl ItipIngest for Server { async fn http_rsvp_url( &self, account_id: u32, + account_name: &str, document_id: u32, attendee: &str, ) -> Option { @@ -405,8 +407,10 @@ impl ItipIngest for Server { .encode_access_token( GrantType::Rsvp, account_id, - &format!("{attendee};{document_id}"), + account_name, self.core.groupware.itip_http_rsvp_expiration, + Some(&format!("{attendee};{document_id}")), + None, ) .await { @@ -560,16 +564,16 @@ async fn decode_rsvp_response(server: &Server, query: &str) -> Option() - .ok() - .map(|doc_id| (attendee.to_string(), doc_id)) - })?; + let (attendee, document_id) = token + .claims + .as_deref() + .and_then(|claims| claims.rsplit_once(';')) + .and_then(|(attendee, doc_id)| { + doc_id + .parse::() + .ok() + .map(|doc_id| (attendee.to_string(), doc_id)) + })?; RsvpResponse { account_id: token.account_id, diff --git a/crates/http-proto/Cargo.toml b/crates/http-proto/Cargo.toml index 4a1d255b..bc39b7db 100644 --- a/crates/http-proto/Cargo.toml +++ b/crates/http-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "http_proto" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/http/Cargo.toml b/crates/http/Cargo.toml index 64f3918e..684eca9c 100644 --- a/crates/http/Cargo.toml +++ b/crates/http/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "http" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/http/src/api/mod.rs b/crates/http/src/api/mod.rs index 205234d0..c38a8424 100644 --- a/crates/http/src/api/mod.rs +++ b/crates/http/src/api/mod.rs @@ -128,8 +128,10 @@ impl ManagementApi for Server { self.encode_access_token( GrantType::LiveTracing, account_id, - "web", + self.account(account_id).await?.name(), 60, + None, + None, ) .await?, )) @@ -146,8 +148,10 @@ impl ManagementApi for Server { self.encode_access_token( GrantType::LiveMetrics, account_id, - "web", + self.account(account_id).await?.name(), 60, + None, + None, ) .await?, )) @@ -164,8 +168,10 @@ impl ManagementApi for Server { self.encode_access_token( GrantType::LiveDelivery, account_id, - "web", + self.account(account_id).await?.name(), 60, + None, + None, ) .await?, )) diff --git a/crates/http/src/auth/oauth/token.rs b/crates/http/src/auth/oauth/token.rs index 02c2301d..22b240c5 100644 --- a/crates/http/src/auth/oauth/token.rs +++ b/crates/http/src/auth/oauth/token.rs @@ -215,7 +215,7 @@ impl TokenHandler for Server { Ok(token_info) => self .issue_token( token_info.account_id, - &token_info.client_id, + "", issuer, None, token_info.expires_in @@ -285,13 +285,23 @@ impl TokenHandler for Server { with_refresh_token: bool, with_id_token: bool, ) -> trc::Result { + let credential_version = self + .access_token(account_id) + .await + .caused_by(trc::location!())? + .credential_version(); + let account = self.account(account_id).await.caused_by(trc::location!())?; + let account_name = account.name(); + Ok(OAuthResponse { access_token: self .encode_access_token( GrantType::AccessToken, account_id, - client_id, + account_name, self.core.oauth.oauth_expiry_token, + None, + credential_version.into(), ) .await?, token_type: "bearer".to_string(), @@ -300,8 +310,10 @@ impl TokenHandler for Server { self.encode_access_token( GrantType::RefreshToken, account_id, - client_id, + account_name, self.core.oauth.oauth_expiry_refresh_token, + None, + credential_version.into(), ) .await? .into() @@ -309,9 +321,6 @@ impl TokenHandler for Server { None }, id_token: if with_id_token { - // Obtain account - let account = self.account(account_id).await.caused_by(trc::location!())?; - match self.issue_id_token( account_id.to_string(), issuer, diff --git a/crates/imap-proto/Cargo.toml b/crates/imap-proto/Cargo.toml index 28f3195d..e82d7fea 100644 --- a/crates/imap-proto/Cargo.toml +++ b/crates/imap-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap_proto" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index 70c9978c..f1b3af60 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/jmap-proto/Cargo.toml b/crates/jmap-proto/Cargo.toml index 589700e8..33b6198f 100644 --- a/crates/jmap-proto/Cargo.toml +++ b/crates/jmap-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap_proto" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 158a6099..d8f0d996 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 7016344b..6810d3da 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art" keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"] categories = ["email"] license = "AGPL-3.0-only OR LicenseRef-SEL" -version = "0.16.7" +version = "0.16.8" edition = "2024" [[bin]] diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml index 9b0a4c89..7f8c0b6d 100644 --- a/crates/managesieve/Cargo.toml +++ b/crates/managesieve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "managesieve" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/migration/Cargo.toml b/crates/migration/Cargo.toml index 862ad6be..7045c7ca 100644 --- a/crates/migration/Cargo.toml +++ b/crates/migration/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "migration" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index b9843deb..3865969c 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/pop3/Cargo.toml b/crates/pop3/Cargo.toml index 262b125d..d70b8f21 100644 --- a/crates/pop3/Cargo.toml +++ b/crates/pop3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pop3" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/registry/Cargo.toml b/crates/registry/Cargo.toml index 312aab7a..ec08eb84 100644 --- a/crates/registry/Cargo.toml +++ b/crates/registry/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "registry" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index 3a27d04d..16f67db0 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "services" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/services/src/task_manager/imip.rs b/crates/services/src/task_manager/imip.rs index a7bb3301..bbc6ed7f 100644 --- a/crates/services/src/task_manager/imip.rs +++ b/crates/services/src/task_manager/imip.rs @@ -482,7 +482,9 @@ pub async fn build_itip_template( // Add RSVP buttons if matches!(summary, ItipSummary::Invite(_) | ItipSummary::Update { .. }) - && let Some(rsvp_url) = server.http_rsvp_url(account_id, document_id, to).await + && let Some(rsvp_url) = server + .http_rsvp_url(account_id, account_info.name(), document_id, to) + .await { variables.insert_single( CalendarTemplateVariable::Rsvp, diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index c0c7645e..46ee71fa 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp" keywords = ["smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only OR LicenseRef-SEL" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/spam-filter/Cargo.toml b/crates/spam-filter/Cargo.toml index 81525c9a..a931788d 100644 --- a/crates/spam-filter/Cargo.toml +++ b/crates/spam-filter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spam-filter" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 0db8a243..e15756dd 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "store" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/trc/Cargo.toml b/crates/trc/Cargo.toml index ed34fb18..0b868c3d 100644 --- a/crates/trc/Cargo.toml +++ b/crates/trc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trc" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/trc/event-macro/Cargo.toml b/crates/trc/event-macro/Cargo.toml index f0f87d37..07294dd2 100644 --- a/crates/trc/event-macro/Cargo.toml +++ b/crates/trc/event-macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "event_macro" -version = "0.16.7" +version = "0.16.8" edition = "2024" [lib] diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index 5bdf73c8..2d77d629 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "types" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 031e5f2f..4db0da65 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.16.7" +version = "0.16.8" edition = "2024" [dependencies] diff --git a/crates/utils/proc-macros/Cargo.toml b/crates/utils/proc-macros/Cargo.toml index b13042a5..0a23144f 100644 --- a/crates/utils/proc-macros/Cargo.toml +++ b/crates/utils/proc-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proc_macros" -version = "0.16.7" +version = "0.16.8" edition = "2024" [lib] diff --git a/tests/Cargo.toml b/tests/Cargo.toml index bf9aada4..045af822 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tests" -version = "0.16.7" +version = "0.16.8" edition = "2024" [features] diff --git a/tests/src/system/oidc.rs b/tests/src/system/oidc.rs index 9aa1f67f..7403de70 100644 --- a/tests/src/system/oidc.rs +++ b/tests/src/system/oidc.rs @@ -251,7 +251,7 @@ pub async fn test(test: &mut TestServer) { .await; assert_eq!(access_introspect.username.unwrap(), "user@example.org"); assert_eq!(access_introspect.token_type.unwrap(), "bearer"); - assert_eq!(access_introspect.client_id.unwrap(), client_id); + assert!(access_introspect.client_id.is_none()); assert!(access_introspect.active); let refresh_introspect = post_with_auth::( &metadata.introspection_endpoint, @@ -260,7 +260,7 @@ pub async fn test(test: &mut TestServer) { ) .await; assert_eq!(refresh_introspect.username.unwrap(), "user@example.org"); - assert_eq!(refresh_introspect.client_id.unwrap(), client_id); + assert!(refresh_introspect.client_id.is_none()); assert!(refresh_introspect.active); assert_eq!( refresh_introspect.iat.unwrap(),