use std::{sync::Arc, time::Duration};
use base64::{Engine, engine::general_purpose::STANDARD};
use better_auth_core::{AuthError, AuthResult, FieldValue, SchemaValue};
use serde_json::Value;
use super::{OAuthProvider, OAuthUserInfo, OAuthUserInfoRequest};
use crate::plugins::oauth::{
ClientAssertion, generic_profile::decode_claims, id_token, oidc::OidcVerifier,
social_profile::ProviderProfile,
};
const CONSUMER_TENANT: &str = "9188040d-6c67-4c5b-b112-36a304b66dad";
#[derive(Clone, Copy, Debug, Default)]
pub enum MicrosoftProfilePhotoSize {
#[default]
Size48,
Size64,
Size96,
Size120,
Size240,
Size360,
Size432,
Size504,
Size648,
}
impl MicrosoftProfilePhotoSize {
fn pixels(self) -> u16 {
match self {
Self::Size48 => 48,
Self::Size64 => 64,
Self::Size96 => 96,
Self::Size120 => 120,
Self::Size240 => 240,
Self::Size360 => 360,
Self::Size432 => 432,
Self::Size504 => 504,
Self::Size648 => 648,
}
}
}
#[derive(Clone, Default)]
pub struct MicrosoftOptions {
pub tenant_id: Option<String>,
pub authority: Option<String>,
pub profile_photo_size: MicrosoftProfilePhotoSize,
pub disable_profile_photo: bool,
pub additional_client_ids: Vec<String>,
pub client_assertion: Option<Arc<dyn ClientAssertion>>,
#[cfg(test)]
pub(in crate::plugins::oauth) jwks_url: Option<String>,
#[cfg(test)]
pub(in crate::plugins::oauth) photo_url: Option<String>,
}
impl MicrosoftOptions {
pub(in crate::plugins::oauth) fn tenant(&self) -> &str {
self.tenant_id
.as_deref()
.filter(|value| !value.is_empty())
.unwrap_or("common")
}
pub(in crate::plugins::oauth) fn authority(&self) -> &str {
self.authority
.as_deref()
.filter(|value| !value.is_empty())
.unwrap_or("https://login.microsoftonline.com")
.trim_end_matches('/')
}
pub(in crate::plugins::oauth) fn photo_endpoint(&self) -> String {
let size = self.profile_photo_size.pixels();
format!("https://graph.microsoft.com/v1.0/me/photos/{size}x{size}/%24value")
}
pub(in crate::plugins::oauth) async fn verify(
&self,
client_id: &str,
token: &str,
nonce: Option<&str>,
) -> AuthResult<Value> {
let decoded = decode_claims(token).ok_or_else(id_token::invalid)?;
let tid = decoded
.get("tid")
.and_then(Value::as_str)
.ok_or_else(id_token::invalid)?;
let tenant = self.tenant();
let authority = self.authority();
let issuer_tenant = match tenant {
"common" | "organizations" | "consumers" => tid,
specific => specific,
};
let issuer = format!("{authority}/{issuer_tenant}/v2.0");
let jwks_url = format!("{authority}/{tenant}/discovery/v2.0/keys");
#[cfg(test)]
let jwks_url = self.jwks_url.as_deref().unwrap_or(&jwks_url).to_owned();
let audiences = std::iter::once(client_id.to_owned())
.chain(self.additional_client_ids.iter().cloned())
.collect();
let verifier = OidcVerifier::with_policy(
jwks_url
.parse()
.map_err(|error: url::ParseError| AuthError::internal(error.to_string()))?,
issuer,
audiences,
None,
Some(Duration::from_secs(3600)),
)
.map_err(|error| AuthError::internal(error.to_string()))?;
let claims = verifier
.verify(token, nonce)
.await
.map_err(|_| id_token::invalid())?;
let tid = claims
.get("tid")
.and_then(Value::as_str)
.ok_or_else(id_token::invalid)?;
if claims.get("iss").and_then(Value::as_str)
!= Some(format!("{authority}/{tid}/v2.0").as_str())
|| tenant == "organizations" && tid == CONSUMER_TENANT
|| tenant == "consumers" && tid != CONSUMER_TENANT
{
return Err(id_token::invalid());
}
Ok(claims)
}
}
pub(in crate::plugins::oauth) fn decode_profile(profile: Value) -> Result<OAuthUserInfo, String> {
let email_verified = profile.get("email_verified").cloned().unwrap_or_else(|| {
let verified = profile
.get("email")
.and_then(Value::as_str)
.filter(|email| !email.is_empty())
.is_some_and(|email| {
["verified_primary_email", "verified_secondary_email"]
.into_iter()
.any(|field| {
profile
.get(field)
.and_then(Value::as_array)
.is_some_and(|values| {
values.iter().any(|value| value.as_str() == Some(email))
})
})
});
Value::Bool(verified)
});
Ok(OAuthUserInfo {
id: profile
.get("oid")
.and_then(Value::as_str)
.ok_or("missing oid")?
.into(),
name: super::decode_profile_name(profile.get("name")),
email: super::defaults::profile_email(&profile)?,
image: profile
.get("picture")
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|error| format!("Invalid Microsoft picture: {error}"))?,
email_verified: SchemaValue::from_json(Some(email_verified))
.map_err(|error| error.to_string())?,
additional_fields: Default::default(),
})
}
pub(in crate::plugins::oauth) async fn fetch_user_info(
provider: &OAuthProvider,
options: &MicrosoftOptions,
request: OAuthUserInfoRequest,
claims: Option<Value>,
) -> AuthResult<ProviderProfile> {
let mut data = match claims {
Some(claims) => claims,
None => {
if !request.id_token.is_truthy()? {
return Ok(ProviderProfile::default());
}
let token = request.id_token.field_value();
decode_claims(&crate::plugins::oauth::providers::jwt_string(&token)?)
.ok_or_else(|| AuthError::internal("Invalid Microsoft ID token"))?
}
};
if data
.get("oid")
.and_then(Value::as_str)
.is_none_or(|oid| oid.trim().is_empty())
{
better_auth_core::observability::logger::current().error(
"Microsoft Entra ID token did not include a valid oid claim; unable to resolve a stable account identifier.", &[],
)?;
return Ok(ProviderProfile::default());
}
if !options.disable_profile_photo && request.access_token.is_truthy()? {
let photo_url = options.photo_endpoint();
#[cfg(test)]
let photo_url = options
.photo_url
.as_deref()
.unwrap_or(&photo_url)
.to_owned();
let response = reqwest::Client::new()
.get(photo_url)
.header(
reqwest::header::AUTHORIZATION,
super::token_http::authorization("Bearer", &request.access_token.field_value())?,
)
.send()
.await
.map_err(|error| {
AuthError::internal(format!("Microsoft photo request failed: {error}"))
})?;
if response.status().is_success() {
match response.bytes().await {
Ok(bytes) => {
let image = format!("data:image/jpeg;base64, {}", STANDARD.encode(bytes));
let _ = data
.as_object_mut()
.ok_or_else(|| AuthError::internal("Microsoft profile must be an object"))?
.insert("picture".into(), Value::String(image));
}
Err(error) => better_auth_core::observability::logger::current().error(
"Failed to read Microsoft profile photo",
&[better_auth_core::observability::LogArgument::Error(&error)],
)?,
}
}
}
let raw = FieldValue::from(data.clone());
let mapped = match &provider.map_profile_to_user {
Some(mapper) => mapper.map_profile_native(&raw).await?,
None => FieldValue::Undefined,
};
let user = decode_profile(data).map_err(AuthError::internal)?;
let mut response = ProviderProfile::from_parts(provider, user, raw)?;
response.apply_mapped(&mapped)?;
Ok(response)
}