Skip to main content

better_auth_api/plugins/oauth/providers/
microsoft.rs

1use std::{sync::Arc, time::Duration};
2
3use base64::{Engine, engine::general_purpose::STANDARD};
4use better_auth_core::{AuthError, AuthResult, SchemaValue};
5use serde_json::Value;
6
7use super::{OAuthProvider, OAuthUserInfo, OAuthUserInfoRequest, OAuthUserInfoResponse};
8use crate::plugins::oauth::{
9    ClientAssertion, generic_profile::decode_claims, id_token, oidc::OidcVerifier,
10};
11
12const CONSUMER_TENANT: &str = "9188040d-6c67-4c5b-b112-36a304b66dad";
13
14/// Microsoft Graph profile photo dimensions supported by the upstream provider.
15#[derive(Clone, Copy, Debug, Default)]
16pub enum MicrosoftProfilePhotoSize {
17    #[default]
18    Size48,
19    Size64,
20    Size96,
21    Size120,
22    Size240,
23    Size360,
24    Size432,
25    Size504,
26    Size648,
27}
28
29impl MicrosoftProfilePhotoSize {
30    fn pixels(self) -> u16 {
31        match self {
32            Self::Size48 => 48,
33            Self::Size64 => 64,
34            Self::Size96 => 96,
35            Self::Size120 => 120,
36            Self::Size240 => 240,
37            Self::Size360 => 360,
38            Self::Size432 => 432,
39            Self::Size504 => 504,
40            Self::Size648 => 648,
41        }
42    }
43}
44
45/// Social Microsoft options. Generic Entra uses `GenericOAuthConfig::microsoft_entra_id`.
46#[derive(Clone, Default)]
47pub struct MicrosoftOptions {
48    /// Omission or an empty value selects `common`.
49    pub tenant_id: Option<String>,
50    /// Omission or an empty value selects `https://login.microsoftonline.com`.
51    pub authority: Option<String>,
52    pub profile_photo_size: MicrosoftProfilePhotoSize,
53    pub disable_profile_photo: bool,
54    /// Additional accepted ID-token audiences. The provider client ID remains primary.
55    pub additional_client_ids: Vec<String>,
56    /// Obtain an assertion for each code or refresh grant instead of using a secret.
57    pub client_assertion: Option<Arc<dyn ClientAssertion>>,
58    #[cfg(test)]
59    pub(in crate::plugins::oauth) jwks_url: Option<String>,
60    #[cfg(test)]
61    pub(in crate::plugins::oauth) photo_url: Option<String>,
62}
63
64impl MicrosoftOptions {
65    pub(in crate::plugins::oauth) fn tenant(&self) -> &str {
66        self.tenant_id
67            .as_deref()
68            .filter(|value| !value.is_empty())
69            .unwrap_or("common")
70    }
71
72    pub(in crate::plugins::oauth) fn authority(&self) -> &str {
73        self.authority
74            .as_deref()
75            .filter(|value| !value.is_empty())
76            .unwrap_or("https://login.microsoftonline.com")
77            .trim_end_matches('/')
78    }
79
80    pub(in crate::plugins::oauth) fn photo_endpoint(&self) -> String {
81        let size = self.profile_photo_size.pixels();
82        // BetterFetch percent-encodes the literal `$value` path segment.
83        format!("https://graph.microsoft.com/v1.0/me/photos/{size}x{size}/%24value")
84    }
85
86    pub(in crate::plugins::oauth) async fn verify(
87        &self,
88        client_id: &str,
89        token: &str,
90        nonce: Option<&str>,
91    ) -> AuthResult<Value> {
92        let decoded = decode_claims(token).ok_or_else(id_token::invalid)?;
93        let tid = decoded
94            .get("tid")
95            .and_then(Value::as_str)
96            .ok_or_else(id_token::invalid)?;
97        let tenant = self.tenant();
98        let authority = self.authority();
99        // Dynamic tenant claims select only the issuer below the configured authority.
100        // The same token must then pass the existing signature and claims verifier.
101        let issuer_tenant = match tenant {
102            "common" | "organizations" | "consumers" => tid,
103            specific => specific,
104        };
105        let issuer = format!("{authority}/{issuer_tenant}/v2.0");
106        let jwks_url = format!("{authority}/{tenant}/discovery/v2.0/keys");
107        #[cfg(test)]
108        let jwks_url = self.jwks_url.as_deref().unwrap_or(&jwks_url).to_owned();
109        let audiences = std::iter::once(client_id.to_owned())
110            .chain(self.additional_client_ids.iter().cloned())
111            .collect();
112        let verifier = OidcVerifier::with_policy(
113            jwks_url
114                .parse()
115                .map_err(|error: url::ParseError| AuthError::internal(error.to_string()))?,
116            issuer,
117            audiences,
118            None,
119            Some(Duration::from_secs(3600)),
120        )
121        .map_err(|error| AuthError::internal(error.to_string()))?;
122        let claims = verifier
123            .verify(token, nonce)
124            .await
125            .map_err(|_| id_token::invalid())?;
126        let tid = claims
127            .get("tid")
128            .and_then(Value::as_str)
129            .ok_or_else(id_token::invalid)?;
130        if claims.get("iss").and_then(Value::as_str)
131            != Some(format!("{authority}/{tid}/v2.0").as_str())
132            || tenant == "organizations" && tid == CONSUMER_TENANT
133            || tenant == "consumers" && tid != CONSUMER_TENANT
134        {
135            return Err(id_token::invalid());
136        }
137        Ok(claims)
138    }
139}
140
141pub(in crate::plugins::oauth) fn decode_profile(profile: Value) -> Result<OAuthUserInfo, String> {
142    let email_verified = profile.get("email_verified").cloned().unwrap_or_else(|| {
143        let verified = profile
144            .get("email")
145            .and_then(Value::as_str)
146            .filter(|email| !email.is_empty())
147            .is_some_and(|email| {
148                ["verified_primary_email", "verified_secondary_email"]
149                    .into_iter()
150                    .any(|field| {
151                        profile
152                            .get(field)
153                            .and_then(Value::as_array)
154                            .is_some_and(|values| {
155                                values.iter().any(|value| value.as_str() == Some(email))
156                            })
157                    })
158            });
159        Value::Bool(verified)
160    });
161    Ok(OAuthUserInfo {
162        id: profile
163            .get("oid")
164            .and_then(Value::as_str)
165            .ok_or("missing oid")?
166            .into(),
167        name: super::decode_profile_name(profile.get("name")),
168        email: super::defaults::profile_email(&profile)?,
169        image: profile
170            .get("picture")
171            .cloned()
172            .map(serde_json::from_value)
173            .transpose()
174            .map_err(|error| format!("Invalid Microsoft picture: {error}"))?,
175        email_verified: SchemaValue::from_json(Some(email_verified))
176            .map_err(|error| error.to_string())?,
177        additional_fields: Default::default(),
178    })
179}
180
181pub(in crate::plugins::oauth) async fn fetch_user_info(
182    provider: &OAuthProvider,
183    options: &MicrosoftOptions,
184    request: OAuthUserInfoRequest,
185    claims: Option<Value>,
186) -> AuthResult<Option<OAuthUserInfoResponse>> {
187    let mut data = match claims {
188        Some(claims) => claims,
189        None => {
190            let Some(token) = request
191                .id_token
192                .as_deref()
193                .filter(|token| !token.is_empty())
194            else {
195                return Ok(None);
196            };
197            decode_claims(token).ok_or_else(|| AuthError::internal("Invalid Microsoft ID token"))?
198        }
199    };
200    if data
201        .get("oid")
202        .and_then(Value::as_str)
203        .is_none_or(|oid| oid.trim().is_empty())
204    {
205        better_auth_core::observability::logger::current().error(
206            "Microsoft Entra ID token did not include a valid oid claim; unable to resolve a stable account identifier.", &[],
207        );
208        return Ok(None);
209    }
210    if !options.disable_profile_photo
211        && let Some(access_token) = request
212            .access_token
213            .as_deref()
214            .filter(|token| !token.is_empty())
215    {
216        let photo_url = options.photo_endpoint();
217        #[cfg(test)]
218        let photo_url = options
219            .photo_url
220            .as_deref()
221            .unwrap_or(&photo_url)
222            .to_owned();
223        let response = reqwest::Client::new()
224            .get(photo_url)
225            .bearer_auth(access_token)
226            .send()
227            .await
228            .map_err(|error| {
229                AuthError::internal(format!("Microsoft photo request failed: {error}"))
230            })?;
231        if response.status().is_success() {
232            match response.bytes().await {
233                Ok(bytes) => {
234                    let image = format!("data:image/jpeg;base64, {}", STANDARD.encode(bytes));
235                    let _ = data
236                        .as_object_mut()
237                        .ok_or_else(|| AuthError::internal("Microsoft profile must be an object"))?
238                        .insert("picture".into(), Value::String(image));
239                }
240                Err(error) => better_auth_core::observability::logger::current().error(
241                    "Failed to read Microsoft profile photo",
242                    &[better_auth_core::observability::LogArgument::Error(&error)],
243                ),
244            }
245        }
246    }
247    let mapped = match &provider.map_profile_to_user {
248        Some(mapper) => Some(mapper.map_profile(&data).await?),
249        None => None,
250    };
251    let mut response = OAuthUserInfoResponse {
252        user: decode_profile(data.clone()).map_err(AuthError::internal)?,
253        data,
254    };
255    if let Some(mapped) = mapped {
256        crate::plugins::oauth::social_profile::apply_mapped_profile(&mut response, mapped);
257    }
258    let _ = response.user.email()?;
259    let _ = response.user.email_verified()?;
260    Ok(Some(response))
261}