better_auth_api/plugins/oauth/
microsoft_entra.rs1use std::{error::Error as _, fmt::Write as _, sync::Arc};
2
3use async_trait::async_trait;
4use better_auth_core::{AuthError, AuthResult};
5use serde_json::{Map, Value};
6
7use super::{
8 GenericOAuthConfig, GenericOAuthProfileContext, GenericOAuthUserInfoHandler,
9 OAuthAccountSubject, OAuthUserInfoRequest,
10};
11use crate::plugins::json_body;
12
13impl GenericOAuthConfig {
14 pub fn microsoft_entra_id(
16 client_id: &str,
17 client_secret: &str,
18 tenant_id: &str,
19 ) -> AuthResult<Self> {
20 let tenant = tenant_id.to_ascii_lowercase();
21 if !uuid::Uuid::parse_str(&tenant)
22 .is_ok_and(|value| value.hyphenated().to_string() == tenant)
23 {
24 return Err(AuthError::config(
25 "The generic Microsoft Entra ID provider requires a concrete tenant GUID",
26 ));
27 }
28 let issuer = format!("https://login.microsoftonline.com/{tenant}");
29 Ok(Self {
30 client_id: client_id.into(),
31 client_secret: Some(client_secret.into()),
32 discovery_url: Some(format!("{issuer}/v2.0/.well-known/openid-configuration")),
33 require_id_token_verification: true,
34 authorization_url: Some(format!("{issuer}/oauth2/v2.0/authorize")),
35 token_url: Some(format!("{issuer}/oauth2/v2.0/token")),
36 user_info_url: Some("https://graph.microsoft.com/oidc/userinfo".into()),
37 scopes: vec!["openid".into(), "profile".into(), "email".into()],
38 get_user_info: Some(Arc::new(EntraProfile)),
39 account_subject: Some(Arc::new(EntraSubject)),
40 ..Default::default()
41 })
42 }
43}
44
45struct EntraProfile;
46
47#[async_trait]
48impl GenericOAuthUserInfoHandler for EntraProfile {
49 async fn get_user_info(&self, _: &OAuthUserInfoRequest) -> AuthResult<Option<Value>> {
50 Ok(None)
51 }
52
53 async fn get_user_info_with_context(
54 &self,
55 tokens: &OAuthUserInfoRequest,
56 context: GenericOAuthProfileContext<'_>,
57 ) -> AuthResult<Option<Value>> {
58 let Some(claims) = context.verified_claims() else {
59 return Ok(None);
60 };
61 let claims = claims.as_value();
62 let Some(token_profile) = profile(claims, None)? else {
63 return Ok(None);
64 };
65 let Some(access_token) = tokens
66 .access_token
67 .as_deref()
68 .filter(|value| !value.is_empty())
69 else {
70 return Ok(Some(token_profile));
71 };
72 let endpoint = context
73 .user_info_url()
74 .ok_or_else(|| AuthError::internal("Missing Microsoft Graph userinfo endpoint"))?;
75 let response = reqwest::Client::new()
76 .get(endpoint)
77 .bearer_auth(access_token)
78 .send()
79 .await
80 .map_err(|error| request_error("request failed", error))?;
81 if !response.status().is_success() {
83 return Ok(Some(token_profile));
84 }
85 let graph: Value = response
86 .json()
87 .await
88 .map_err(|error| request_error("response failed", error))?;
89 if !claims.get("sub").is_some_and(Value::is_string) || graph.get("sub") != claims.get("sub")
90 {
91 return Ok(Some(token_profile));
92 }
93 profile(claims, Some(&graph))
94 }
95}
96
97fn profile(claims: &Value, graph: Option<&Value>) -> AuthResult<Option<Value>> {
98 let Some(oid) = claims
99 .get("oid")
100 .and_then(Value::as_str)
101 .filter(|value| !value.trim().is_empty())
102 else {
103 return Ok(None);
104 };
105 let mut output = graph
106 .and_then(Value::as_object)
107 .cloned()
108 .unwrap_or_default();
109 output.extend(
110 claims
111 .as_object()
112 .ok_or_else(|| AuthError::internal("Microsoft Entra ID claims must be an object"))?
113 .clone(),
114 );
115 replace(
116 &mut output,
117 "name",
118 name(claims).or_else(|| graph.and_then(name)),
119 );
120 replace(
121 &mut output,
122 "image",
123 match graph {
124 Some(graph) => non_null(claims, "picture").or_else(|| graph.get("picture")),
125 None => claims.get("picture"),
126 }
127 .cloned(),
128 );
129 let email =
130 non_null(claims, "email").or_else(|| graph.and_then(|graph| non_null(graph, "email")));
131 let verified = if email.is_some_and(|value| graph.is_some() || json_body::is_truthy(value)) {
132 non_null(claims, "email_verified")
133 .or_else(|| graph.and_then(|graph| non_null(graph, "email_verified")))
134 .cloned()
135 .unwrap_or(Value::Bool(false))
136 } else {
137 Value::Bool(false)
138 };
139 let email = match email {
140 Some(email) => email.clone(),
141 None => Value::String(super::social_profile::placeholder_email(
142 oid,
143 "microsoft-entra-id",
144 )?),
145 };
146 let _ = output.insert("email".into(), email);
147 let _ = output.insert("emailVerified".into(), verified);
148 Ok(Some(Value::Object(output)))
149}
150
151fn non_null<'a>(profile: &'a Value, field: &str) -> Option<&'a Value> {
152 profile.get(field).filter(|value| !value.is_null())
153}
154
155fn name(profile: &Value) -> Option<Value> {
156 non_null(profile, "name").cloned().or_else(|| {
157 let given = non_null(profile, "given_name")
158 .or_else(|| non_null(profile, "givenname"))
159 .and_then(Value::as_str)
160 .unwrap_or_default();
161 let family = non_null(profile, "family_name")
162 .or_else(|| non_null(profile, "familyname"))
163 .and_then(Value::as_str)
164 .unwrap_or_default();
165 let name = format!("{given} {family}")
166 .trim_matches(crate::plugins::helpers::oauth_scope_whitespace)
167 .to_owned();
168 (!name.is_empty()).then_some(Value::String(name))
169 })
170}
171
172fn replace(output: &mut Map<String, Value>, field: &str, value: Option<Value>) {
173 if let Some(value) = value {
174 let _ = output.insert(field.into(), value);
175 } else {
176 let _ = output.remove(field);
177 }
178}
179
180struct EntraSubject;
181
182fn request_error(context: &str, error: reqwest::Error) -> AuthError {
183 let error = error.without_url();
184 let mut message = format!("Microsoft Graph {context}: {error}");
185 let mut cause = error.source();
186 while let Some(error) = cause {
187 let _ = write!(message, ": {error}");
188 cause = error.source();
189 }
190 AuthError::internal(message)
191}
192
193#[async_trait]
194impl OAuthAccountSubject for EntraSubject {
195 async fn resolve_subject(
196 &self,
197 _: &OAuthUserInfoRequest,
198 profile: &Value,
199 ) -> AuthResult<String> {
200 Ok(profile
201 .get("oid")
202 .and_then(Value::as_str)
203 .unwrap_or_default()
204 .into())
205 }
206}
207
208#[cfg(test)]
209#[path = "microsoft_entra_tests.rs"]
210mod tests;