Skip to main content

better_auth_api/plugins/oauth/
microsoft_entra.rs

1use 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    /// Configure Microsoft Entra ID for one concrete tenant GUID with verified ID tokens.
15    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        // The upstream Entra helper retains the verified token profile after a Graph HTTP error.
82        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;