From 96795b0cf220ea3cb1a40681691f6ba53522b9a7 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:20:37 +0200 Subject: [PATCH] OIDC: Extract username from JWT token --- CHANGELOG.md | 1 + crates/common/src/auth/authentication.rs | 30 +++++++++++++++++++++++- crates/common/src/auth/oauth/token.rs | 12 +++++++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5353a1..5720e9b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. This projec This version includes **multiple breaking changes**. 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 +- OIDC: Extract username from JWT token. - `system('node_hostname')` and `system('node_role')` expression variables to retrieve the local node hostname and cluster role respectively. ## Changed diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs index d004da9c..d5014ead 100644 --- a/crates/common/src/auth/authentication.rs +++ b/crates/common/src/auth/authentication.rs @@ -12,6 +12,7 @@ use crate::{ oauth::GrantType, }, }; +use base64::{Engine, engine::general_purpose}; use directory::{ Credentials, Directory, core::secret::{SecretVerificationResult, verify_mfa_secret_hash, verify_secret_hash}, @@ -297,14 +298,20 @@ impl Server { return Ok(AccessToken::new_admin()); } - // Obtain external directory, if any + // Obtain external directory, if any. When no username is supplied + // (e.g. HTTP bearer auth), peek at the JWT claims to find the + // user's domain so per-domain OIDC directories are reachable. let directory = if let Some(username) = username.as_deref().map(UsernameParts::new) { if let Some(domain_name) = username.auth_as().domain() { self.get_directory_for_domain(domain_name).await? + } else if let Some(domain_name) = extract_jwt_domain(token) { + self.get_directory_for_domain(&domain_name).await? } else { self.get_default_directory() } + } else if let Some(domain_name) = extract_jwt_domain(token) { + self.get_directory_for_domain(&domain_name).await? } else { self.get_default_directory() }; @@ -496,6 +503,27 @@ impl Server { } } +fn extract_jwt_domain(token: &str) -> Option { + let mut parts = token.split('.'); + let _header = parts.next()?; + let payload = parts.next()?; + let _signature = parts.next()?; + if parts.next().is_some() { + return None; + } + let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?; + let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + for claim in ["email", "preferred_username", "upn"] { + if let Some(val) = claims.get(claim).and_then(|v| v.as_str()) + && let Some((_, domain)) = val.rsplit_once('@') + && !domain.is_empty() + { + return Some(domain.to_ascii_lowercase()); + } + } + None +} + impl UsernameParts { pub fn new(address: &str) -> Self { let mut account = Username { diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs index adca3622..5d55e5a0 100644 --- a/crates/common/src/auth/oauth/token.rs +++ b/crates/common/src/auth/oauth/token.rs @@ -16,6 +16,12 @@ use store::{ use trc::AddContext; use utils::codec::leb128::{Leb128Iterator, Leb128Vec}; +pub const FAILED_TO_DECODE_TOKEN: &str = concat!( + "Failed to decode token. If you are using an ", + "external OIDC provider, make sure it is configured as the default directory under ", + "the Authentication object." +); + pub struct TokenInfo { pub grant_type: GrantType, pub account_id: u32, @@ -115,7 +121,7 @@ impl Server { .map_err(|_| { trc::AuthEvent::Error .into_err() - .ctx(trc::Key::Reason, "Failed to decode token") + .ctx(trc::Key::Reason, FAILED_TO_DECODE_TOKEN) .caused_by(trc::location!()) .details(token_.to_string()) })?; @@ -135,7 +141,7 @@ impl Server { .ok_or_else(|| { trc::AuthEvent::Error .into_err() - .ctx(trc::Key::Reason, "Failed to decode token") + .ctx(trc::Key::Reason, FAILED_TO_DECODE_TOKEN) .caused_by(trc::location!()) .details(token_.to_string()) })?; @@ -200,7 +206,7 @@ impl Server { .map_err(|err| { trc::AuthEvent::Error .into_err() - .ctx(trc::Key::Details, "Failed to decode token") + .ctx(trc::Key::Details, FAILED_TO_DECODE_TOKEN) .caused_by(trc::location!()) .reason(err) })?;