Skip to main content

alibi_api/plugins/oauth/providers/
microsoft.rs

1//! Microsoft Entra ID's configured authority, tenant and original object ID.
2use super::{
3    OAuthAuthorizationPolicy, OAuthClientAssertion, OAuthProvider, OAuthScopeEncoding,
4    OAuthTokenEndpointAuth, OAuthUserInfo, OAuthUserInfoHandler, OAuthUserInfoRequest,
5    OAuthUserInfoResponse,
6};
7use crate::plugins::oauth::{
8    HttpOAuthJwksSource, OAuthIdTokenClaimsVerifier, OAuthIdTokenConfig, OAuthJwksSelection,
9    OAuthJwksSource, OAuthNonceComparison,
10};
11use alibi_core::utils::json::JsValue;
12use async_trait::async_trait;
13use base64::Engine;
14use serde_json::Value;
15use std::sync::Arc;
16
17const CONSUMER_TENANT: &str = "9188040d-6c67-4c5b-b112-36a304b66dad";
18const DEFAULT_SCOPES: [&str; 5] = ["openid", "profile", "email", "User.Read", "offline_access"];
19
20/// Supported Graph profile-photo sizes in pixels.
21#[derive(Debug, Clone, Copy, Default)]
22pub enum MicrosoftProfilePhotoSize {
23    #[default]
24    Size48,
25    Size64,
26    Size96,
27    Size120,
28    Size240,
29    Size360,
30    Size432,
31    Size504,
32    Size648,
33}
34impl MicrosoftProfilePhotoSize {
35    const fn pixels(self) -> u16 {
36        match self {
37            Self::Size48 => 48,
38            Self::Size64 => 64,
39            Self::Size96 => 96,
40            Self::Size120 => 120,
41            Self::Size240 => 240,
42            Self::Size360 => 360,
43            Self::Size432 => 432,
44            Self::Size504 => 504,
45            Self::Size648 => 648,
46        }
47    }
48}
49
50/// Immutable application configuration for the published Microsoft factory.
51#[derive(Clone)]
52pub struct MicrosoftOptions {
53    pub client_ids: Vec<String>,
54    pub client_secret: Option<String>,
55    pub client_key: Option<String>,
56    pub client_assertion: Option<OAuthClientAssertion>,
57    pub tenant_id: Option<String>,
58    pub authority: Option<String>,
59    pub scope: Vec<String>,
60    pub disable_default_scope: bool,
61    /// Reject new identities, including an explicit signup request.
62    pub disable_sign_up: bool,
63    pub prompt: Option<String>,
64    pub authorization_endpoint: Option<String>,
65    pub redirect_uri: Option<String>,
66    pub disable_profile_photo: bool,
67    pub profile_photo_size: MicrosoftProfilePhotoSize,
68    /// Trusted application transport override. Claims never select this source.
69    pub jwks_source: Option<Arc<dyn OAuthJwksSource>>,
70    /// Trusted application Graph transport override, retaining byte/photo mapping.
71    pub profile_photo_endpoint: Option<String>,
72    /// Receives the original claims with any successfully fetched photo applied.
73    /// Its mapped ID cannot replace the raw oid account identity.
74    pub map_profile_to_user: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
75}
76impl MicrosoftOptions {
77    #[must_use]
78    pub fn new(client_id: impl Into<String>, client_secret: Option<String>) -> Self {
79        Self {
80            client_ids: vec![client_id.into()],
81            client_secret,
82            client_key: None,
83            client_assertion: None,
84            tenant_id: None,
85            authority: None,
86            scope: Vec::new(),
87            disable_default_scope: false,
88            disable_sign_up: false,
89            prompt: None,
90            authorization_endpoint: None,
91            redirect_uri: None,
92            disable_profile_photo: false,
93            profile_photo_size: MicrosoftProfilePhotoSize::default(),
94            jwks_source: None,
95            profile_photo_endpoint: None,
96            map_profile_to_user: None,
97        }
98    }
99}
100impl std::fmt::Debug for MicrosoftOptions {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("MicrosoftOptions")
103            .field("client_ids", &self.client_ids)
104            .field("tenant_id", &self.tenant_id)
105            .field("authority", &self.authority)
106            .field("scope", &self.scope)
107            .field("disable_default_scope", &self.disable_default_scope)
108            .field("disable_sign_up", &self.disable_sign_up)
109            .field("disable_profile_photo", &self.disable_profile_photo)
110            .field("profile_photo_size", &self.profile_photo_size)
111            .finish_non_exhaustive()
112    }
113}
114impl OAuthProvider {
115    /// Builds Microsoft's actual authorization, grant and tenant-verification policy.
116    ///
117    /// # Errors
118    /// Returns an error when a secret is combined with a client assertion.
119    pub fn microsoft(options: MicrosoftOptions) -> Result<Self, String> {
120        if options.client_assertion.is_some()
121            && options
122                .client_secret
123                .as_ref()
124                .is_some_and(|secret| !secret.is_empty())
125        {
126            return Err(
127                "Microsoft Entra ID clientAssertion cannot be combined with clientSecret".into(),
128            );
129        }
130        let tenant = options
131            .tenant_id
132            .filter(|value| !value.is_empty())
133            .unwrap_or_else(|| "common".into());
134        let authority = options
135            .authority
136            .filter(|value| !value.is_empty())
137            .unwrap_or_else(|| "https://login.microsoftonline.com".into())
138            .trim_end_matches('/')
139            .to_owned();
140        let multi_tenant = ["common", "organizations", "consumers"].contains(&tenant.as_str());
141        let verification = OAuthIdTokenConfig {
142            issuers: if multi_tenant {
143                Vec::new()
144            } else {
145                vec![format!("{authority}/{tenant}/v2.0")]
146            },
147            audience: None,
148            client_ids: Some(options.client_ids.clone()),
149            jwks_source: options.jwks_source.unwrap_or_else(|| {
150                Arc::new(HttpOAuthJwksSource::new(format!(
151                    "{authority}/{tenant}/discovery/v2.0/keys"
152                )))
153            }),
154            max_age_secs: Some(3600),
155            allow_opaque_token: false,
156            algorithm: None,
157            selection: OAuthJwksSelection::ExactKid,
158            nonce_comparison: OAuthNonceComparison::Exact,
159            verify_claims: Some(Arc::new(MicrosoftClaims {
160                authority: authority.clone(),
161                tenant: tenant.clone(),
162            })),
163        };
164        let scopes: Vec<_> = DEFAULT_SCOPES.map(String::from).to_vec();
165        let mut refresh_scopes = if options.disable_default_scope {
166            Vec::new()
167        } else {
168            scopes.clone()
169        };
170        refresh_scopes.extend(options.scope.clone());
171        let token_endpoint_auth = if options.client_assertion.is_some() {
172            OAuthTokenEndpointAuth::PrivateKeyJwt
173        } else if options
174            .client_secret
175            .as_ref()
176            .is_some_and(|secret| !secret.is_empty())
177        {
178            OAuthTokenEndpointAuth::ClientSecretPost
179        } else {
180            OAuthTokenEndpointAuth::None
181        };
182        let size = options.profile_photo_size.pixels();
183        let photo_endpoint = options.profile_photo_endpoint.unwrap_or_else(|| {
184            format!("https://graph.microsoft.com/v1.0/me/photos/{size}x{size}/$value")
185        });
186        let mut clients = options.client_ids.into_iter();
187        Ok(Self {
188            client_id: clients.next().unwrap_or_default(),
189            additional_client_ids: clients.collect(),
190            client_secret: options.client_secret.unwrap_or_default(),
191            hosted_domain: None,
192            require_email_verification: false,
193            auth_url: options
194                .authorization_endpoint
195                .filter(|value| !value.is_empty())
196                .unwrap_or_else(|| format!("{authority}/{tenant}/oauth2/v2.0/authorize")),
197            token_url: format!("{authority}/{tenant}/oauth2/v2.0/token"),
198            user_info_url: None,
199            scopes,
200            authorization: Some(OAuthAuthorizationPolicy {
201                configured_scopes: options.scope,
202                scope_encoding: OAuthScopeEncoding::UriComponent,
203                disable_default_scopes: options.disable_default_scope,
204                disable_sign_up_option: options.disable_sign_up.then_some(true),
205                require_client_id: true,
206                token_endpoint_auth: Some(token_endpoint_auth),
207                authorization_code_client_key: options.client_key,
208                client_assertion: options.client_assertion,
209                refresh_scope: Some(refresh_scopes.join(" ")),
210                prompt: options.prompt,
211                redirect_uri: options.redirect_uri,
212                ..OAuthAuthorizationPolicy::default()
213            }),
214            authorization_params: Vec::new(),
215            account_subject: Some(subject),
216            map_user_info: None,
217            get_user_info: Some(Arc::new(MicrosoftUserInfo {
218                photo_endpoint,
219                disable_photo: options.disable_profile_photo,
220                mapper: options.map_profile_to_user,
221            })),
222            refresh_access_token: None,
223            verify_id_token: None,
224            id_token: Some(verification),
225            disable_id_token_sign_in: false,
226            disable_implicit_sign_up: false,
227            disable_sign_up: false,
228            override_user_info_on_sign_in: false,
229        })
230    }
231}
232struct MicrosoftClaims {
233    authority: String,
234    tenant: String,
235}
236impl OAuthIdTokenClaimsVerifier for MicrosoftClaims {
237    fn verify_claims(&self, claims: &JsValue) -> bool {
238        let Some(tid) = claims.get("tid").and_then(JsValue::as_str) else {
239            return false;
240        };
241        claims.get("iss").and_then(JsValue::as_str)
242            == Some(format!("{}/{tid}/v2.0", self.authority).as_str())
243            && !(self.tenant == "organizations" && tid == CONSUMER_TENANT)
244            && !(self.tenant == "consumers" && tid != CONSUMER_TENANT)
245    }
246}
247struct MicrosoftUserInfo {
248    photo_endpoint: String,
249    disable_photo: bool,
250    mapper: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
251}
252#[async_trait]
253impl OAuthUserInfoHandler for MicrosoftUserInfo {
254    async fn get_user_info(
255        &self,
256        request: OAuthUserInfoRequest,
257    ) -> Result<OAuthUserInfoResponse, String> {
258        let token = request
259            .id_token
260            .filter(|value| !value.is_empty())
261            .ok_or("Missing Microsoft ID token")?;
262        let parts: Vec<_> = token.split('.').collect();
263        let [_, payload, _] = parts.as_slice() else {
264            return Err("Invalid Microsoft ID token".into());
265        };
266        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
267            .decode(payload)
268            .map_err(|error| error.to_string())?;
269        let mut profile: Value =
270            alibi_core::utils::json::from_slice(&bytes).map_err(|error| error.to_string())?;
271        if !profile.is_object() {
272            return Err("Invalid Microsoft ID-token claims set".into());
273        }
274        let id = subject(&profile)?;
275        if !self.disable_photo
276            && let Some(access) = request.access_token.filter(|value| !value.is_empty())
277        {
278            // A failed optional photo operation does not deny the authenticated profile.
279            if let Ok(response) = reqwest::Client::new()
280                .get(&self.photo_endpoint)
281                .bearer_auth(access)
282                .send()
283                .await
284                && response.status().is_success()
285                && let Ok(bytes) = response.bytes().await
286                && let Some(object) = profile.as_object_mut()
287            {
288                drop(object.insert(
289                    "picture".into(),
290                    Value::String(format!(
291                        "data:image/jpeg;base64, {}",
292                        base64::engine::general_purpose::STANDARD.encode(bytes)
293                    )),
294                ));
295            }
296        }
297        let mapped = self
298            .mapper
299            .map(|mapper| mapper(profile.clone()))
300            .transpose()?;
301        let email = profile
302            .get("email")
303            .and_then(Value::as_str)
304            .unwrap_or_default()
305            .to_owned();
306        let verified = match profile.get("email_verified") {
307            Some(value) => truthy(value),
308            None => {
309                !email.is_empty()
310                    && (email_list_includes(profile.get("verified_primary_email"), &email)?
311                        || email_list_includes(profile.get("verified_secondary_email"), &email)?)
312            }
313        };
314        // Public provider output keeps the original JSON shape independently
315        // from the typed values used by account admission and persistence.
316        let user_output = Some(mapped.as_ref().map_or_else(
317            || {
318                let mut output = serde_json::Map::new();
319                for (source, target) in [("name", "name"), ("email", "email"), ("picture", "image")]
320                {
321                    if let Some(value) = profile.get(source) {
322                        drop(output.insert(target.into(), value.clone()));
323                    }
324                }
325                drop(
326                    output.insert(
327                        "emailVerified".into(),
328                        profile
329                            .get("email_verified")
330                            .cloned()
331                            .unwrap_or(Value::Bool(verified)),
332                    ),
333                );
334                output
335            },
336            |user| user.public_profile(true),
337        ));
338        let user = match mapped {
339            Some(user) => user,
340            None => OAuthUserInfo {
341                additional_fields: Default::default(),
342                id,
343                email,
344                name: scalar(profile.get("name"))?,
345                image: scalar(profile.get("picture"))?,
346                email_verified: verified,
347            },
348        };
349        Ok(OAuthUserInfoResponse {
350            user_output,
351            user,
352            data: profile,
353        })
354    }
355}
356fn subject(profile: &Value) -> Result<String, String> {
357    let id = profile
358        .get("oid")
359        .and_then(Value::as_str)
360        .ok_or("Missing Microsoft oid")?;
361    if id
362        .trim_matches(|character: char| {
363            (character.is_whitespace() && character != '\u{85}') || character == '\u{feff}'
364        })
365        .is_empty()
366    {
367        return Err("Invalid Microsoft oid".into());
368    }
369    Ok(id.into())
370}
371fn email_list_includes(value: Option<&Value>, email: &str) -> Result<bool, String> {
372    match value {
373        None | Some(Value::Null) => Ok(false),
374        Some(Value::Array(values)) => Ok(values.iter().any(|value| value.as_str() == Some(email))),
375        Some(Value::String(value)) => Ok(value.contains(email)),
376        _ => Err("Invalid Microsoft verified email list".into()),
377    }
378}
379fn truthy(value: &Value) -> bool {
380    match value {
381        Value::Null => false,
382        Value::Bool(value) => *value,
383        Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
384        Value::String(value) => !value.is_empty(),
385        Value::Array(_) | Value::Object(_) => true,
386    }
387}
388fn scalar(value: Option<&Value>) -> Result<Option<String>, String> {
389    match value {
390        None | Some(Value::Null) => Ok(None),
391        Some(Value::String(value)) => Ok(Some(value.clone())),
392        Some(Value::Number(value)) => alibi_core::utils::json::number_to_string(value)
393            .map(Some)
394            .map_err(|error| error.to_string()),
395        Some(Value::Bool(value)) => Ok(Some(value.to_string())),
396        Some(Value::Array(_) | Value::Object(_)) => Err("Invalid Microsoft profile field".into()),
397    }
398}