better_auth_api/plugins/oauth/providers/
microsoft.rs1use 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#[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#[derive(Clone, Default)]
47pub struct MicrosoftOptions {
48 pub tenant_id: Option<String>,
50 pub authority: Option<String>,
52 pub profile_photo_size: MicrosoftProfilePhotoSize,
53 pub disable_profile_photo: bool,
54 pub additional_client_ids: Vec<String>,
56 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 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 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}