Skip to main content

bitwarden_access_connector/integrations/
entra.rs

1//! Microsoft Entra ID (Azure AD) integration.
2//!
3//! Passwords rotate by administrative reset through Microsoft Graph, so the connector never holds
4//! the current credential. The Graph token, client secret and new password are never logged.
5
6use std::time::Duration;
7
8use async_trait::async_trait;
9use bitwarden_pam::RotationFailureCode;
10use bitwarden_sensitive_value::ExposeSensitive as _;
11use reqwest::{Client, StatusCode};
12use serde::Deserialize;
13use url::Url;
14
15use super::{Integration, IntegrationError, RotateContext, TargetEffect};
16use crate::error::{Retry, SafeDetail};
17
18const DEFAULT_LOGIN_BASE: &str = "https://login.microsoftonline.com";
19
20const DEFAULT_GRAPH_BASE: &str = "https://graph.microsoft.com";
21
22const GRAPH_SCOPE: &str = "https://graph.microsoft.com/.default";
23
24const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
25
26/// Clock-slack tolerance for the `lastPasswordChangeDateTime` freshness check.
27const VERIFY_CLOCK_SLACK: chrono::Duration = chrono::Duration::seconds(300);
28
29/// How long `verify` polls Graph for a fresh `lastPasswordChangeDateTime`.
30const VERIFY_MAX_WAIT: Duration = Duration::from_secs(60);
31
32const VERIFY_POLL_INTERVAL: Duration = Duration::from_secs(5);
33
34/// Microsoft Entra ID integration driver. Each operation fetches a fresh token; secrets never live
35/// in `self`.
36pub(crate) struct EntraIntegration {
37    http: Client,
38    /// Whether `verify` tries an ROPC sign-in first. Off by default: MFA or Conditional Access
39    /// blocks ROPC in most tenants.
40    verify_probe: bool,
41    login_base: String,
42    graph_base: String,
43    verify_max_wait: Duration,
44    verify_poll_interval: Duration,
45}
46
47impl EntraIntegration {
48    /// Build a new `EntraIntegration` using the production Microsoft endpoints.
49    pub(crate) fn new(verify_probe: bool) -> Self {
50        // Do not follow redirects: a cross-host redirect could leak the bearer token.
51        let http = bitwarden_api_base::new_http_client_builder()
52            .timeout(HTTP_TIMEOUT)
53            .redirect(reqwest::redirect::Policy::none())
54            .build()
55            .expect("Entra HTTP client build should not fail");
56        Self {
57            http,
58            verify_probe,
59            login_base: DEFAULT_LOGIN_BASE.to_owned(),
60            graph_base: DEFAULT_GRAPH_BASE.to_owned(),
61            verify_max_wait: VERIFY_MAX_WAIT,
62            verify_poll_interval: VERIFY_POLL_INTERVAL,
63        }
64    }
65
66    /// Test constructor pointing at wiremock servers, with `verify` polling disabled.
67    #[cfg(test)]
68    fn new_with_bases(verify_probe: bool, login_base: String, graph_base: String) -> Self {
69        Self::new_with_bases_and_settle(
70            verify_probe,
71            login_base,
72            graph_base,
73            Duration::ZERO,
74            Duration::ZERO,
75        )
76    }
77
78    /// Like `new_with_bases`, with explicit `verify` polling timings.
79    #[cfg(test)]
80    fn new_with_bases_and_settle(
81        verify_probe: bool,
82        login_base: String,
83        graph_base: String,
84        verify_max_wait: Duration,
85        verify_poll_interval: Duration,
86    ) -> Self {
87        // The throwaway client installs the ring provider (idempotent); the real one allows http:// for wiremock.
88        let _ = bitwarden_api_base::new_http_client_builder()
89            .build()
90            .expect("provider install client");
91        let http = reqwest::Client::builder()
92            .timeout(HTTP_TIMEOUT)
93            .build()
94            .expect("test HTTP client build should not fail");
95        Self {
96            http,
97            verify_probe,
98            login_base,
99            graph_base,
100            verify_max_wait,
101            verify_poll_interval,
102        }
103    }
104
105    /// Token endpoint URL for `tenant_id`, built from `login_base`.
106    fn token_url(&self, tenant_id: &str) -> Result<Url, IntegrationError> {
107        let invalid = || {
108            IntegrationError::new(
109                Retry::Permanent,
110                TargetEffect::NotApplied,
111                RotationFailureCode::TargetUnreachable,
112                SafeDetail::from_kind("InvalidLoginBase"),
113            )
114        };
115        let mut token_url = Url::parse(&self.login_base).map_err(|e| invalid().with_source(e))?;
116        {
117            // `path_segments_mut` fails with `()`, so there is no cause to keep.
118            let mut segments = token_url.path_segments_mut().map_err(|()| invalid())?;
119            segments.push(tenant_id);
120            segments.push("oauth2");
121            segments.push("v2.0");
122            segments.push("token");
123        }
124        Ok(token_url)
125    }
126
127    /// Fetch a Graph bearer token by client-credentials grant. The client secret goes only into the
128    /// form body.
129    async fn graph_token(&self, creds: &EntraCredentials<'_>) -> Result<String, IntegrationError> {
130        let token_url = self.token_url(creds.tenant_id)?;
131
132        let form = [
133            ("grant_type", "client_credentials"),
134            ("scope", GRAPH_SCOPE),
135            ("client_id", creds.client_id),
136            ("client_secret", creds.client_secret),
137        ];
138
139        let response = self
140            .http
141            .post(token_url.as_str())
142            .form(&form)
143            .send()
144            .await
145            .map_err(|e| network_error_before_send(e, TargetEffect::NotApplied))?;
146
147        let status = response.status();
148        let status_u16 = status.as_u16();
149
150        if status.is_success() {
151            let body: TokenResponse = response.json().await.map_err(|e| {
152                IntegrationError::new(
153                    Retry::Permanent,
154                    TargetEffect::NotApplied,
155                    RotationFailureCode::TargetRejected,
156                    SafeDetail::from_http_status_and_graph_code(status_u16, None),
157                )
158                .with_source(e)
159            })?;
160            return Ok(body.access_token);
161        }
162
163        if status_u16 == 400 || status_u16 == 401 {
164            Err(IntegrationError::new(
165                Retry::Permanent,
166                TargetEffect::NotApplied,
167                RotationFailureCode::TargetRejected,
168                SafeDetail::from_http_status_and_graph_code(status_u16, None),
169            ))
170        } else {
171            Err(IntegrationError::new(
172                Retry::Transient,
173                TargetEffect::NotApplied,
174                RotationFailureCode::TargetUnreachable,
175                SafeDetail::from_http_status_and_graph_code(status_u16, None),
176            ))
177        }
178    }
179
180    /// Build a Graph user URL with `identity` as one opaque path segment. `push` encodes slashes
181    /// and percent sequences, so attacker-influenced input cannot inject a path.
182    fn graph_user_url(
183        &self,
184        identity: &str,
185        extra_segments: &[&str],
186    ) -> Result<Url, IntegrationError> {
187        let invalid = || {
188            IntegrationError::new(
189                Retry::Permanent,
190                TargetEffect::NotApplied,
191                RotationFailureCode::TargetUnreachable,
192                SafeDetail::from_kind("InvalidGraphBase"),
193            )
194        };
195        let mut url = Url::parse(&self.graph_base).map_err(|e| invalid().with_source(e))?;
196        {
197            // `path_segments_mut` fails with `()`, so there is no cause to keep.
198            let mut segs = url.path_segments_mut().map_err(|()| invalid())?;
199            segs.push("v1.0");
200            segs.push("users");
201            segs.push(identity);
202            for &seg in extra_segments {
203                segs.push(seg);
204            }
205        }
206        Ok(url)
207    }
208
209    /// Attempt an ROPC grant with the new password, which goes only into the form body and is never
210    /// stored, logged or returned.
211    async fn ropc_probe(
212        &self,
213        creds: &EntraCredentials<'_>,
214        account_identity: &str,
215        new_password: &str,
216    ) -> ProbeResult {
217        let token_url = match self.token_url(creds.tenant_id) {
218            Ok(u) => u,
219            Err(_) => return ProbeResult::Inconclusive,
220        };
221
222        let form = [
223            ("grant_type", "password"),
224            ("scope", GRAPH_SCOPE),
225            ("client_id", creds.client_id),
226            ("username", account_identity),
227            ("password", new_password),
228        ];
229
230        let response = match self.http.post(token_url.as_str()).form(&form).send().await {
231            Ok(r) => r,
232            Err(_) => return ProbeResult::Inconclusive,
233        };
234
235        if response.status().is_success() {
236            // Token issued without MFA challenge; password accepted.
237            return ProbeResult::Verified;
238        }
239
240        // Parse the AADSTS code: the first colon/whitespace-delimited token in `error_description`.
241        let body = match response.bytes().await {
242            Ok(b) => b,
243            Err(_) => return ProbeResult::Inconclusive,
244        };
245
246        let parsed: serde_json::Value = match serde_json::from_slice(&body) {
247            Ok(v) => v,
248            Err(_) => return ProbeResult::Inconclusive,
249        };
250
251        if let Some(desc) = parsed.get("error_description").and_then(|v| v.as_str()) {
252            let aadsts_code = desc
253                .split(|c: char| c == ':' || c.is_whitespace())
254                .next()
255                .unwrap_or("");
256
257            return match aadsts_code {
258                "AADSTS50076" | "AADSTS50079" => ProbeResult::Verified,
259                "AADSTS50126" => ProbeResult::WrongPassword,
260                _ => ProbeResult::Inconclusive,
261            };
262        }
263
264        ProbeResult::Inconclusive
265    }
266}
267
268impl std::fmt::Debug for EntraIntegration {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        f.debug_struct("EntraIntegration")
271            .field("verify_probe", &self.verify_probe)
272            .field("verify_max_wait", &self.verify_max_wait)
273            .field("verify_poll_interval", &self.verify_poll_interval)
274            .finish_non_exhaustive()
275    }
276}
277
278#[derive(Deserialize)]
279struct TokenResponse {
280    access_token: String,
281}
282
283#[derive(Deserialize)]
284#[serde(rename_all = "camelCase")]
285struct UserResource {
286    last_password_change_date_time: Option<chrono::DateTime<chrono::Utc>>,
287}
288
289/// Graph error envelope. `error.message` is never read, since it can echo user-supplied content.
290#[derive(Deserialize, Default)]
291struct GraphError {
292    #[serde(default)]
293    error: GraphErrorInner,
294}
295
296#[derive(Deserialize, Default)]
297struct GraphErrorInner {
298    #[serde(default)]
299    code: Option<String>,
300}
301
302/// Maps a send error for a request with no credential effect; always Transient.
303fn network_error_before_send(e: reqwest::Error, effect: TargetEffect) -> IntegrationError {
304    IntegrationError::new(
305        Retry::Transient,
306        effect,
307        RotationFailureCode::TargetUnreachable,
308        if e.is_timeout() {
309            SafeDetail::from_kind("ConnectTimeout")
310        } else if e.is_connect() {
311            SafeDetail::from_kind("ConnectError")
312        } else {
313            SafeDetail::from_kind("NetworkError")
314        },
315    )
316    .with_source(e)
317}
318
319/// Maps a send error for a mutation. A connect error stays `Transient` with `pre_send_effect`;
320/// anything else is `Unknown`, since the mutation may have reached Graph.
321fn network_error_after_send(e: reqwest::Error, pre_send_effect: TargetEffect) -> IntegrationError {
322    if e.is_connect() {
323        IntegrationError::new(
324            Retry::Transient,
325            pre_send_effect,
326            RotationFailureCode::TargetUnreachable,
327            SafeDetail::from_kind("ConnectError"),
328        )
329        .with_source(e)
330    } else {
331        IntegrationError::new(
332            Retry::Permanent,
333            TargetEffect::Unknown,
334            RotationFailureCode::TargetUnreachable,
335            if e.is_timeout() {
336                SafeDetail::from_kind("ResponseTimeout")
337            } else {
338                SafeDetail::from_kind("NetworkError")
339            },
340        )
341        .with_source(e)
342    }
343}
344
345/// Read Graph's `error.code` from an error body. A code that fails `^[A-Za-z0-9_]{1,64}$` counts
346/// as absent, to prevent log injection from attacker-influenced responses.
347async fn read_graph_error_code(response: reqwest::Response) -> Option<String> {
348    let body = response.bytes().await.ok()?;
349    let parsed: GraphError = serde_json::from_slice(&body).ok()?;
350    parsed
351        .error
352        .code
353        .filter(|code| is_safe_graph_error_code(code))
354}
355
356/// Matches `^[A-Za-z0-9_]{1,64}$`.
357fn is_safe_graph_error_code(code: &str) -> bool {
358    !code.is_empty()
359        && code.len() <= 64
360        && code.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
361}
362
363#[async_trait]
364impl Integration for EntraIntegration {
365    /// Reset the password with `PATCH /v1.0/users/{identity}` and a `passwordProfile` body.
366    /// 429 is `NotApplied`, 5xx is `Unknown`, other rejections are permanent.
367    async fn rotate(&self, ctx: &RotateContext) -> Result<(), IntegrationError> {
368        let creds = EntraCredentials::from_ctx(ctx)?;
369        let bearer = self.graph_token(&creds).await?;
370
371        let url = self.graph_user_url(&ctx.account_identity, &[])?;
372
373        // The new password is sent in the JSON body; it is never logged.
374        let patch_body = serde_json::json!({
375            "passwordProfile": {
376                "password": ctx.new_password.as_str(),
377                "forceChangePasswordNextSignIn": false
378            }
379        });
380
381        let response = self
382            .http
383            .patch(url.as_str())
384            .bearer_auth(&bearer)
385            .json(&patch_body)
386            .send()
387            .await
388            .map_err(|e| network_error_after_send(e, TargetEffect::NotApplied))?;
389
390        let status = response.status();
391        let status_u16 = status.as_u16();
392
393        if status.is_success() {
394            return Ok(());
395        }
396
397        let graph_code = read_graph_error_code(response).await;
398        let graph_code_ref = graph_code.as_deref();
399
400        let (retry, effect, code) = match status {
401            // Throttled before processing.
402            StatusCode::TOO_MANY_REQUESTS => (
403                Retry::Transient,
404                TargetEffect::NotApplied,
405                RotationFailureCode::TargetUnreachable,
406            ),
407            // A gateway or server error can arrive after Graph applied the reset, so the effect
408            // is unknown (like a post-send timeout).
409            s if s.is_server_error() => (
410                Retry::Transient,
411                TargetEffect::Unknown,
412                RotationFailureCode::TargetUnreachable,
413            ),
414            // 400 policy rejections, 401/403/404 and the rest.
415            _ => (
416                Retry::Permanent,
417                TargetEffect::NotApplied,
418                RotationFailureCode::TargetRejected,
419            ),
420        };
421        Err(IntegrationError::new(
422            retry,
423            effect,
424            code,
425            SafeDetail::from_http_status_and_graph_code(status_u16, graph_code_ref),
426        ))
427    }
428
429    /// Verify with the optional ROPC probe first (authoritative, immune to replication lag), then
430    /// poll up to `verify_max_wait` for a fresh `lastPasswordChangeDateTime`. Errors carry
431    /// `Applied`.
432    async fn verify(&self, ctx: &RotateContext) -> Result<(), IntegrationError> {
433        let creds = EntraCredentials::from_ctx(ctx)?;
434        let bearer = self.graph_token(&creds).await.map_err(|mut e| {
435            // The rotation already applied.
436            e.effect = TargetEffect::Applied;
437            e
438        })?;
439
440        if self.verify_probe {
441            let probe_result = self
442                .ropc_probe(&creds, &ctx.account_identity, ctx.new_password.as_str())
443                .await;
444
445            match probe_result {
446                ProbeResult::Verified => return Ok(()),
447                ProbeResult::WrongPassword => {
448                    return Err(IntegrationError::new(
449                        Retry::Permanent,
450                        TargetEffect::Applied,
451                        RotationFailureCode::VerificationFailed,
452                        SafeDetail::from_kind("RopcWrongPassword"),
453                    ));
454                }
455                ProbeResult::Inconclusive => {
456                    // Fall through to the directory poll.
457                }
458            }
459        }
460
461        // Directory poll: tolerates Graph eventual-consistency lag.
462        let mut url = self.graph_user_url(&ctx.account_identity, &[])?;
463        url.set_query(Some("$select=lastPasswordChangeDateTime"));
464
465        let deadline = tokio::time::Instant::now() + self.verify_max_wait;
466
467        loop {
468            let response = self
469                .http
470                .get(url.as_str())
471                .bearer_auth(&bearer)
472                .send()
473                .await
474                .map_err(|e| {
475                    // Network error during verify: password was already changed.
476                    IntegrationError::new(
477                        Retry::Transient,
478                        TargetEffect::Applied,
479                        RotationFailureCode::TargetUnreachable,
480                        SafeDetail::from_kind("NetworkError"),
481                    )
482                    .with_source(e)
483                })?;
484
485            let status = response.status();
486            let status_u16 = status.as_u16();
487
488            if !status.is_success() {
489                let graph_code = read_graph_error_code(response).await;
490                let detail =
491                    SafeDetail::from_http_status_and_graph_code(status_u16, graph_code.as_deref());
492                // HTTP failures end the poll loop; the executor retries Transient ones.
493                let retry = if status_u16 == 429 || status_u16 >= 500 {
494                    Retry::Transient
495                } else {
496                    Retry::Permanent
497                };
498                return Err(IntegrationError::new(
499                    retry,
500                    TargetEffect::Applied,
501                    RotationFailureCode::TargetUnreachable,
502                    detail,
503                ));
504            }
505
506            let user: UserResource = response.json().await.map_err(|e| {
507                IntegrationError::new(
508                    Retry::Permanent,
509                    TargetEffect::Applied,
510                    RotationFailureCode::VerificationFailed,
511                    SafeDetail::from_http_status_and_graph_code(status_u16, None),
512                )
513                .with_source(e)
514            })?;
515
516            let is_fresh = match user.last_password_change_date_time {
517                Some(changed_at) => {
518                    let earliest_acceptable = ctx.rotation_started_at - VERIFY_CLOCK_SLACK;
519                    changed_at >= earliest_acceptable
520                }
521                None => false,
522            };
523
524            if is_fresh {
525                return Ok(());
526            }
527
528            let now = tokio::time::Instant::now();
529            if now >= deadline {
530                // Budget exhausted; a zero verify_max_wait means a single check.
531                return Err(IntegrationError::new(
532                    Retry::Permanent,
533                    TargetEffect::Applied,
534                    RotationFailureCode::VerificationFailed,
535                    SafeDetail::from_kind("StalePasswordChangeTimestamp"),
536                ));
537            }
538            let remaining = deadline - now;
539            let sleep_for = self.verify_poll_interval.min(remaining);
540            tokio::time::sleep(sleep_for).await;
541        }
542    }
543
544    /// Revoke sign-in sessions with `POST /v1.0/users/{identity}/revokeSignInSessions`. Errors
545    /// carry `NotApplied`, since termination never changes the credential.
546    async fn terminate_sessions(&self, ctx: &RotateContext) -> Result<(), IntegrationError> {
547        let creds = EntraCredentials::from_ctx(ctx)?;
548        let bearer = self.graph_token(&creds).await?;
549
550        let url = self.graph_user_url(&ctx.account_identity, &["revokeSignInSessions"])?;
551
552        let response = self
553            .http
554            .post(url.as_str())
555            .bearer_auth(&bearer)
556            .header("Content-Length", "0")
557            .send()
558            .await
559            .map_err(|e| network_error_before_send(e, TargetEffect::NotApplied))?;
560
561        let status = response.status();
562        let status_u16 = status.as_u16();
563
564        if status.is_success() {
565            return Ok(());
566        }
567
568        let graph_code = read_graph_error_code(response).await;
569        let detail = SafeDetail::from_http_status_and_graph_code(status_u16, graph_code.as_deref());
570
571        if status_u16 == 429 || status_u16 >= 500 {
572            Err(IntegrationError::new(
573                Retry::Transient,
574                TargetEffect::NotApplied,
575                RotationFailureCode::TargetUnreachable,
576                detail,
577            ))
578        } else {
579            // 4xx
580            Err(IntegrationError::new(
581                Retry::Permanent,
582                TargetEffect::NotApplied,
583                RotationFailureCode::TargetRejected,
584                detail,
585            ))
586        }
587    }
588}
589
590/// Result of the ROPC (Resource Owner Password Credentials) verify probe.
591#[derive(Debug, PartialEq, Eq)]
592enum ProbeResult {
593    /// Password accepted (or MFA required, confirming the password is correct).
594    Verified,
595    /// Password rejected (`AADSTS50126`).
596    WrongPassword,
597    /// Outcome inconclusive; fall back to the directory check.
598    Inconclusive,
599}
600
601/// The app-registration credentials every Graph call needs, borrowed from the resolved credentials.
602struct EntraCredentials<'a> {
603    tenant_id: &'a str,
604    client_id: &'a str,
605    client_secret: &'a str,
606}
607
608impl<'a> EntraCredentials<'a> {
609    fn from_ctx(ctx: &'a RotateContext) -> Result<Self, IntegrationError> {
610        Ok(Self {
611            tenant_id: get_cred(&ctx.creds, "TENANT_ID")?,
612            client_id: get_cred(&ctx.creds, "CLIENT_ID")?,
613            client_secret: get_cred(&ctx.creds, "CLIENT_SECRET")?,
614        })
615    }
616}
617
618/// Look up a required credential suffix, or fail with `credentials_unresolved` naming only the
619/// suffix.
620fn get_cred<'a>(
621    creds: &'a crate::resolver::ResolvedCredentials,
622    suffix: &'static str,
623) -> Result<&'a str, IntegrationError> {
624    creds
625        .get(suffix)
626        .map(|s| s.expose().as_ref() as &str)
627        .ok_or_else(|| {
628            IntegrationError::new(
629                Retry::Permanent,
630                TargetEffect::NotApplied,
631                RotationFailureCode::CredentialsUnresolved,
632                SafeDetail::from_kind(suffix),
633            )
634        })
635}
636
637#[cfg(test)]
638mod tests {
639    use chrono::Utc;
640    use uuid::Uuid;
641    use wiremock::{
642        Mock, MockServer, ResponseTemplate,
643        matchers::{body_string_contains, method, path, query_param},
644    };
645
646    use super::*;
647    use crate::{
648        error::Retry,
649        integrations::{RotateContext, TargetEffect},
650        resolver::ResolvedCredentials,
651    };
652
653    fn make_creds(tenant: &str, client_id: &str, secret: &str) -> ResolvedCredentials {
654        let mut creds = ResolvedCredentials::new();
655        creds.insert("TENANT_ID".to_string(), tenant.to_string());
656        creds.insert("CLIENT_ID".to_string(), client_id.to_string());
657        creds.insert("CLIENT_SECRET".to_string(), secret.to_string());
658        creds
659    }
660
661    fn make_ctx(creds: ResolvedCredentials, identity: &str, new_password: &str) -> RotateContext {
662        RotateContext {
663            target_system_id: Uuid::nil(),
664            account_identity: identity.to_string(),
665            new_password: new_password.to_string(),
666            creds,
667            rotation_started_at: Utc::now(),
668        }
669    }
670
671    fn make_ctx_started_at(
672        creds: ResolvedCredentials,
673        identity: &str,
674        new_password: &str,
675        rotation_started_at: chrono::DateTime<Utc>,
676    ) -> RotateContext {
677        RotateContext {
678            target_system_id: Uuid::nil(),
679            account_identity: identity.to_string(),
680            new_password: new_password.to_string(),
681            creds,
682            rotation_started_at,
683        }
684    }
685
686    async fn mount_token_ok(server: &MockServer, tenant: &str, bearer: &str) {
687        Mock::given(method("POST"))
688            .and(path(format!("/{tenant}/oauth2/v2.0/token")))
689            .respond_with(
690                ResponseTemplate::new(200)
691                    .set_body_json(serde_json::json!({ "access_token": bearer }))
692                    .insert_header("content-type", "application/json"),
693            )
694            .mount(server)
695            .await;
696    }
697
698    fn integration(login: &MockServer, graph: &MockServer, verify_probe: bool) -> EntraIntegration {
699        EntraIntegration::new_with_bases(verify_probe, login.uri(), graph.uri())
700    }
701
702    fn graph_user_url_for(identity: &str) -> Result<Url, IntegrationError> {
703        EntraIntegration::new_with_bases(
704            false,
705            DEFAULT_LOGIN_BASE.to_owned(),
706            DEFAULT_GRAPH_BASE.to_owned(),
707        )
708        .graph_user_url(identity, &[])
709    }
710
711    #[tokio::test]
712    async fn token_fetch_sends_correct_form_fields() {
713        let login = MockServer::start().await;
714        let graph = MockServer::start().await;
715        let tenant = "my-tenant";
716
717        Mock::given(method("POST"))
718            .and(path(format!("/{tenant}/oauth2/v2.0/token")))
719            .and(body_string_contains("grant_type=client_credentials"))
720            .and(body_string_contains(
721                "scope=https%3A%2F%2Fgraph.microsoft.com%2F.default",
722            ))
723            .and(body_string_contains("client_id=test-client"))
724            .respond_with(
725                ResponseTemplate::new(200)
726                    .set_body_json(serde_json::json!({ "access_token": "tok" }))
727                    .insert_header("content-type", "application/json"),
728            )
729            .mount(&login)
730            .await;
731
732        Mock::given(method("PATCH"))
733            .and(path("/v1.0/users/[email protected]"))
734            .respond_with(ResponseTemplate::new(204))
735            .mount(&graph)
736            .await;
737
738        let creds = make_creds(tenant, "test-client", "test-secret");
739        let ctx = make_ctx(creds, "[email protected]", "NewPass123!");
740        let integ = integration(&login, &graph, false);
741        integ.rotate(&ctx).await.unwrap();
742    }
743
744    #[tokio::test]
745    async fn rotate_patch_body_and_success() {
746        let login = MockServer::start().await;
747        let graph = MockServer::start().await;
748        let tenant = "t1";
749
750        mount_token_ok(&login, tenant, "bearer-tok").await;
751
752        Mock::given(method("PATCH"))
753            .and(path("/v1.0/users/[email protected]"))
754            .and(body_string_contains("passwordProfile"))
755            .and(body_string_contains("forceChangePasswordNextSignIn"))
756            .respond_with(ResponseTemplate::new(204))
757            .mount(&graph)
758            .await;
759
760        let creds = make_creds(tenant, "cid", "csecret");
761        let ctx = make_ctx(creds, "[email protected]", "Rotated@Pass1");
762        let integ = integration(&login, &graph, false);
763        assert!(integ.rotate(&ctx).await.is_ok());
764    }
765
766    #[tokio::test]
767    async fn rotate_200_is_success() {
768        let login = MockServer::start().await;
769        let graph = MockServer::start().await;
770        let tenant = "t1";
771
772        mount_token_ok(&login, tenant, "bearer-tok").await;
773
774        Mock::given(method("PATCH"))
775            .and(path("/v1.0/users/[email protected]"))
776            .respond_with(ResponseTemplate::new(200))
777            .mount(&graph)
778            .await;
779
780        let creds = make_creds(tenant, "cid", "csecret");
781        let ctx = make_ctx(creds, "[email protected]", "NewPass1");
782        let integ = integration(&login, &graph, false);
783        assert!(integ.rotate(&ctx).await.is_ok());
784    }
785
786    #[tokio::test]
787    async fn rotate_404_gives_fatal_not_applied_target_rejected() {
788        let login = MockServer::start().await;
789        let graph = MockServer::start().await;
790        let tenant = "t1";
791
792        mount_token_ok(&login, tenant, "tok").await;
793
794        Mock::given(method("PATCH"))
795            .and(path("/v1.0/users/missing-user"))
796            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
797                "error": {
798                    "code": "Request_ResourceNotFound",
799                    "message": "Resource does not exist"
800                }
801            })))
802            .mount(&graph)
803            .await;
804
805        let creds = make_creds(tenant, "cid", "csecret");
806        let ctx = make_ctx(creds, "missing-user", "P@ss1");
807        let integ = integration(&login, &graph, false);
808        let err = integ.rotate(&ctx).await.unwrap_err();
809        assert_eq!(err.retry, Retry::Permanent);
810        assert_eq!(err.effect, TargetEffect::NotApplied);
811        assert_eq!(err.code, RotationFailureCode::TargetRejected);
812    }
813
814    #[tokio::test]
815    async fn rotate_403_gives_fatal_not_applied_target_rejected() {
816        let login = MockServer::start().await;
817        let graph = MockServer::start().await;
818        let tenant = "t1";
819
820        mount_token_ok(&login, tenant, "tok").await;
821
822        Mock::given(method("PATCH"))
823            .and(path("/v1.0/users/some-user"))
824            .respond_with(ResponseTemplate::new(403))
825            .mount(&graph)
826            .await;
827
828        let creds = make_creds(tenant, "cid", "csecret");
829        let ctx = make_ctx(creds, "some-user", "P@ss1");
830        let integ = integration(&login, &graph, false);
831        let err = integ.rotate(&ctx).await.unwrap_err();
832        assert_eq!(err.retry, Retry::Permanent);
833        assert_eq!(err.effect, TargetEffect::NotApplied);
834        assert_eq!(err.code, RotationFailureCode::TargetRejected);
835    }
836
837    #[tokio::test]
838    async fn rotate_429_gives_transient_not_applied() {
839        let login = MockServer::start().await;
840        let graph = MockServer::start().await;
841        let tenant = "t1";
842
843        mount_token_ok(&login, tenant, "tok").await;
844
845        Mock::given(method("PATCH"))
846            .and(path("/v1.0/users/some-user"))
847            .respond_with(ResponseTemplate::new(429))
848            .mount(&graph)
849            .await;
850
851        let creds = make_creds(tenant, "cid", "csecret");
852        let ctx = make_ctx(creds, "some-user", "P@ss1");
853        let integ = integration(&login, &graph, false);
854        let err = integ.rotate(&ctx).await.unwrap_err();
855        assert_eq!(err.retry, Retry::Transient);
856        assert_eq!(err.effect, TargetEffect::NotApplied);
857        assert_eq!(err.code, RotationFailureCode::TargetUnreachable);
858    }
859
860    #[tokio::test]
861    async fn rotate_400_gives_fatal_not_applied_target_rejected() {
862        let err = rotate_with_patch_status(400).await;
863        assert_eq!(err.retry, Retry::Permanent);
864        assert_eq!(err.effect, TargetEffect::NotApplied);
865        assert_eq!(err.code, RotationFailureCode::TargetRejected);
866    }
867
868    #[tokio::test]
869    async fn rotate_5xx_gives_transient_unknown() {
870        for status in [500, 502, 503, 504] {
871            let err = rotate_with_patch_status(status).await;
872            assert_eq!(err.retry, Retry::Transient, "status {status}");
873            assert_eq!(err.effect, TargetEffect::Unknown, "status {status}");
874            assert_eq!(
875                err.code,
876                RotationFailureCode::TargetUnreachable,
877                "status {status}"
878            );
879        }
880    }
881
882    async fn rotate_with_patch_status(status: u16) -> IntegrationError {
883        let login = MockServer::start().await;
884        let graph = MockServer::start().await;
885        let tenant = "t1";
886
887        mount_token_ok(&login, tenant, "tok").await;
888
889        Mock::given(method("PATCH"))
890            .and(path("/v1.0/users/some-user"))
891            .respond_with(ResponseTemplate::new(status))
892            .mount(&graph)
893            .await;
894
895        let creds = make_creds(tenant, "cid", "csecret");
896        let ctx = make_ctx(creds, "some-user", "P@ss1");
897        let integ = integration(&login, &graph, false);
898        integ.rotate(&ctx).await.unwrap_err()
899    }
900
901    #[tokio::test]
902    async fn verify_accepts_fresh_last_password_change() {
903        let login = MockServer::start().await;
904        let graph = MockServer::start().await;
905        let tenant = "t1";
906
907        mount_token_ok(&login, tenant, "tok").await;
908
909        let now = Utc::now();
910        let changed_at = (now + chrono::Duration::seconds(10)).to_rfc3339();
911
912        Mock::given(method("GET"))
913            .and(path("/v1.0/users/[email protected]"))
914            .and(query_param("$select", "lastPasswordChangeDateTime"))
915            .respond_with(
916                ResponseTemplate::new(200)
917                    .set_body_json(serde_json::json!({
918                        "lastPasswordChangeDateTime": changed_at
919                    }))
920                    .insert_header("content-type", "application/json"),
921            )
922            .mount(&graph)
923            .await;
924
925        let creds = make_creds(tenant, "cid", "csecret");
926        let ctx = make_ctx_started_at(creds, "[email protected]", "P@ss1", now);
927        let integ = integration(&login, &graph, false);
928        assert!(integ.verify(&ctx).await.is_ok());
929    }
930
931    #[tokio::test]
932    async fn verify_rejects_stale_last_password_change() {
933        let login = MockServer::start().await;
934        let graph = MockServer::start().await;
935        let tenant = "t1";
936
937        mount_token_ok(&login, tenant, "tok").await;
938
939        let now = Utc::now();
940        let stale_at = (now - chrono::Duration::minutes(10)).to_rfc3339();
941
942        Mock::given(method("GET"))
943            .and(path("/v1.0/users/[email protected]"))
944            .respond_with(
945                ResponseTemplate::new(200)
946                    .set_body_json(serde_json::json!({
947                        "lastPasswordChangeDateTime": stale_at
948                    }))
949                    .insert_header("content-type", "application/json"),
950            )
951            .mount(&graph)
952            .await;
953
954        let creds = make_creds(tenant, "cid", "csecret");
955        let ctx = make_ctx_started_at(creds, "[email protected]", "P@ss1", now);
956        let integ = integration(&login, &graph, false);
957        let err = integ.verify(&ctx).await.unwrap_err();
958        assert_eq!(err.retry, Retry::Permanent);
959        assert_eq!(err.effect, TargetEffect::Applied);
960        assert_eq!(err.code, RotationFailureCode::VerificationFailed);
961    }
962
963    #[tokio::test]
964    async fn verify_rejects_missing_last_password_change() {
965        let login = MockServer::start().await;
966        let graph = MockServer::start().await;
967        let tenant = "t1";
968
969        mount_token_ok(&login, tenant, "tok").await;
970
971        Mock::given(method("GET"))
972            .and(path("/v1.0/users/[email protected]"))
973            .respond_with(
974                ResponseTemplate::new(200)
975                    .set_body_json(serde_json::json!({}))
976                    .insert_header("content-type", "application/json"),
977            )
978            .mount(&graph)
979            .await;
980
981        let creds = make_creds(tenant, "cid", "csecret");
982        let ctx = make_ctx(creds, "[email protected]", "P@ss1");
983        let integ = integration(&login, &graph, false);
984        let err = integ.verify(&ctx).await.unwrap_err();
985        assert_eq!(err.effect, TargetEffect::Applied);
986        assert_eq!(err.code, RotationFailureCode::VerificationFailed);
987    }
988
989    #[tokio::test]
990    async fn verify_probe_aadsts50076_counts_as_verified() {
991        let login = MockServer::start().await;
992        let graph = MockServer::start().await;
993        let tenant = "t1";
994
995        // Mounted first: wiremock matches it before the client_credentials mock.
996        Mock::given(method("POST"))
997            .and(path(format!("/{tenant}/oauth2/v2.0/token")))
998            .and(body_string_contains("grant_type=password"))
999            .respond_with(
1000                ResponseTemplate::new(400)
1001                    .set_body_json(serde_json::json!({
1002                        "error": "interaction_required",
1003                        "error_description": "AADSTS50076: Due to a configuration change made by your administrator, or because you moved to a new location, you must use multi-factor authentication to access this resource."
1004                    }))
1005                    .insert_header("content-type", "application/json"),
1006            )
1007            .mount(&login)
1008            .await;
1009
1010        mount_token_ok(&login, tenant, "graph-tok").await;
1011
1012        // A stale directory timestamp, which would fail without the probe.
1013        let stale_at = (Utc::now() - chrono::Duration::minutes(10)).to_rfc3339();
1014        Mock::given(method("GET"))
1015            .and(path("/v1.0/users/[email protected]"))
1016            .respond_with(
1017                ResponseTemplate::new(200)
1018                    .set_body_json(serde_json::json!({
1019                        "lastPasswordChangeDateTime": stale_at
1020                    }))
1021                    .insert_header("content-type", "application/json"),
1022            )
1023            .mount(&graph)
1024            .await;
1025
1026        let creds = make_creds(tenant, "cid", "csecret");
1027        let ctx = make_ctx(creds, "[email protected]", "P@ss1");
1028        let integ = integration(&login, &graph, true);
1029        assert!(integ.verify(&ctx).await.is_ok());
1030    }
1031
1032    #[tokio::test]
1033    async fn verify_probe_aadsts50126_gives_verification_failed() {
1034        let login = MockServer::start().await;
1035        let graph = MockServer::start().await;
1036        let tenant = "t1";
1037
1038        // Mounted first: wiremock matches it before the client_credentials mock.
1039        Mock::given(method("POST"))
1040            .and(path(format!("/{tenant}/oauth2/v2.0/token")))
1041            .and(body_string_contains("grant_type=password"))
1042            .respond_with(
1043                ResponseTemplate::new(401)
1044                    .set_body_json(serde_json::json!({
1045                        "error": "invalid_grant",
1046                        "error_description": "AADSTS50126: Invalid username or password or Invalid on-premise username or password."
1047                    }))
1048                    .insert_header("content-type", "application/json"),
1049            )
1050            .mount(&login)
1051            .await;
1052
1053        mount_token_ok(&login, tenant, "graph-tok").await;
1054
1055        // A fresh directory timestamp, which the probe's rejection overrides.
1056        let now = Utc::now();
1057        let fresh_at = (now + chrono::Duration::seconds(1)).to_rfc3339();
1058        Mock::given(method("GET"))
1059            .and(path("/v1.0/users/[email protected]"))
1060            .respond_with(
1061                ResponseTemplate::new(200)
1062                    .set_body_json(serde_json::json!({
1063                        "lastPasswordChangeDateTime": fresh_at
1064                    }))
1065                    .insert_header("content-type", "application/json"),
1066            )
1067            .mount(&graph)
1068            .await;
1069
1070        let creds = make_creds(tenant, "cid", "csecret");
1071        let ctx = make_ctx_started_at(creds, "[email protected]", "P@ss1", now);
1072        let integ = integration(&login, &graph, true);
1073        let err = integ.verify(&ctx).await.unwrap_err();
1074        assert_eq!(err.retry, Retry::Permanent);
1075        assert_eq!(err.effect, TargetEffect::Applied);
1076        assert_eq!(err.code, RotationFailureCode::VerificationFailed);
1077    }
1078
1079    #[tokio::test]
1080    async fn terminate_sessions_success_204() {
1081        let login = MockServer::start().await;
1082        let graph = MockServer::start().await;
1083        let tenant = "t1";
1084
1085        mount_token_ok(&login, tenant, "tok").await;
1086
1087        Mock::given(method("POST"))
1088            .and(path("/v1.0/users/[email protected]/revokeSignInSessions"))
1089            .respond_with(ResponseTemplate::new(204))
1090            .mount(&graph)
1091            .await;
1092
1093        let creds = make_creds(tenant, "cid", "csecret");
1094        let ctx = make_ctx(creds, "[email protected]", "P@ss1");
1095        let integ = integration(&login, &graph, false);
1096        assert!(integ.terminate_sessions(&ctx).await.is_ok());
1097    }
1098
1099    #[tokio::test]
1100    async fn terminate_sessions_success_200() {
1101        let login = MockServer::start().await;
1102        let graph = MockServer::start().await;
1103        let tenant = "t1";
1104
1105        mount_token_ok(&login, tenant, "tok").await;
1106
1107        Mock::given(method("POST"))
1108            .and(path("/v1.0/users/[email protected]/revokeSignInSessions"))
1109            .respond_with(
1110                ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": true})),
1111            )
1112            .mount(&graph)
1113            .await;
1114
1115        let creds = make_creds(tenant, "cid", "csecret");
1116        let ctx = make_ctx(creds, "[email protected]", "P@ss1");
1117        let integ = integration(&login, &graph, false);
1118        assert!(integ.terminate_sessions(&ctx).await.is_ok());
1119    }
1120
1121    #[tokio::test]
1122    async fn terminate_sessions_4xx_gives_fatal_not_applied() {
1123        let login = MockServer::start().await;
1124        let graph = MockServer::start().await;
1125        let tenant = "t1";
1126
1127        mount_token_ok(&login, tenant, "tok").await;
1128
1129        Mock::given(method("POST"))
1130            .and(path("/v1.0/users/[email protected]/revokeSignInSessions"))
1131            .respond_with(ResponseTemplate::new(403))
1132            .mount(&graph)
1133            .await;
1134
1135        let creds = make_creds(tenant, "cid", "csecret");
1136        let ctx = make_ctx(creds, "[email protected]", "P@ss1");
1137        let integ = integration(&login, &graph, false);
1138        let err = integ.terminate_sessions(&ctx).await.unwrap_err();
1139        assert_eq!(err.retry, Retry::Permanent);
1140        assert_eq!(err.effect, TargetEffect::NotApplied);
1141        assert_eq!(err.code, RotationFailureCode::TargetRejected);
1142    }
1143
1144    #[tokio::test]
1145    async fn terminate_sessions_429_gives_transient() {
1146        let login = MockServer::start().await;
1147        let graph = MockServer::start().await;
1148        let tenant = "t1";
1149
1150        mount_token_ok(&login, tenant, "tok").await;
1151
1152        Mock::given(method("POST"))
1153            .and(path("/v1.0/users/[email protected]/revokeSignInSessions"))
1154            .respond_with(ResponseTemplate::new(429))
1155            .mount(&graph)
1156            .await;
1157
1158        let creds = make_creds(tenant, "cid", "csecret");
1159        let ctx = make_ctx(creds, "[email protected]", "P@ss1");
1160        let integ = integration(&login, &graph, false);
1161        let err = integ.terminate_sessions(&ctx).await.unwrap_err();
1162        assert_eq!(err.retry, Retry::Transient);
1163        assert_eq!(err.effect, TargetEffect::NotApplied);
1164    }
1165
1166    #[test]
1167    fn hostile_identity_slash_encoded() {
1168        let url = graph_user_url_for("a/b").unwrap();
1169        let path = url.path();
1170        assert!(
1171            path.contains("a%2Fb") || path.contains("a%2fb"),
1172            "slash must be percent-encoded: {path}"
1173        );
1174        assert!(
1175            !path.ends_with("/b"),
1176            "raw slash must not split path: {path}"
1177        );
1178    }
1179
1180    #[test]
1181    fn hostile_identity_dotdot_does_not_traverse() {
1182        let url = graph_user_url_for("..").unwrap();
1183        assert!(
1184            !url.as_str().contains("/../"),
1185            "dotdot must not appear as a path traversal: {url}"
1186        );
1187    }
1188
1189    #[test]
1190    fn hostile_identity_query_char_encoded() {
1191        let url = graph_user_url_for("x?$filter=1").unwrap();
1192        let path = url.path();
1193        assert!(
1194            path.contains("x%3F") || path.contains("x%3f"),
1195            "query char must be encoded in path segment: {path}"
1196        );
1197        assert!(
1198            url.query().map(|q| !q.contains("$filter")).unwrap_or(true),
1199            "injected query must not appear in URL query: {url}"
1200        );
1201    }
1202
1203    #[test]
1204    fn hostile_identity_percent_encoded_dotdot_not_decoded() {
1205        let url = graph_user_url_for("%2e%2e").unwrap();
1206        assert!(
1207            !url.as_str().contains("/../"),
1208            "percent-encoded dotdot must not resolve to traversal: {url}"
1209        );
1210    }
1211
1212    #[tokio::test]
1213    async fn detail_contains_status_and_graph_code_not_message() {
1214        let login = MockServer::start().await;
1215        let graph = MockServer::start().await;
1216        let tenant = "t1";
1217
1218        mount_token_ok(&login, tenant, "tok").await;
1219
1220        let sensitive_message = "super-secret-account-name-that-must-not-leak";
1221        Mock::given(method("PATCH"))
1222            .and(path("/v1.0/users/some-user"))
1223            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
1224                "error": {
1225                    "code": "Request_ResourceNotFound",
1226                    "message": sensitive_message
1227                }
1228            })))
1229            .mount(&graph)
1230            .await;
1231
1232        let creds = make_creds(tenant, "cid", "csecret");
1233        let ctx = make_ctx(creds, "some-user", "P@ss1");
1234        let integ = integration(&login, &graph, false);
1235        let err = integ.rotate(&ctx).await.unwrap_err();
1236
1237        let detail = err.detail.as_str();
1238        assert!(
1239            detail.contains("404"),
1240            "detail must contain status code: {detail}"
1241        );
1242        assert!(
1243            detail.contains("Request_ResourceNotFound"),
1244            "detail must contain graph error code: {detail}"
1245        );
1246        assert!(
1247            !detail.contains(sensitive_message),
1248            "error.message must not appear in detail: {detail}"
1249        );
1250    }
1251
1252    #[tokio::test]
1253    async fn missing_tenant_id_gives_credentials_unresolved() {
1254        let login = MockServer::start().await;
1255        let graph = MockServer::start().await;
1256
1257        let mut creds = ResolvedCredentials::new();
1258        creds.insert("CLIENT_ID".to_string(), "cid".to_string());
1259        creds.insert("CLIENT_SECRET".to_string(), "secret".to_string());
1260
1261        let ctx = make_ctx(creds, "[email protected]", "P@ss1");
1262        let integ = integration(&login, &graph, false);
1263        let err = integ.rotate(&ctx).await.unwrap_err();
1264        assert_eq!(err.retry, Retry::Permanent);
1265        assert_eq!(err.effect, TargetEffect::NotApplied);
1266        assert_eq!(err.code, RotationFailureCode::CredentialsUnresolved);
1267        assert!(
1268            err.detail.as_str().contains("TENANT_ID"),
1269            "detail must name the missing suffix: {}",
1270            err.detail
1271        );
1272    }
1273
1274    #[tokio::test]
1275    async fn hostile_graph_error_code_is_omitted_from_detail() {
1276        let login = MockServer::start().await;
1277        let graph = MockServer::start().await;
1278        let tenant = "t1";
1279
1280        mount_token_ok(&login, tenant, "tok").await;
1281
1282        let hostile_code = "Inject\r\nEvil: value\x00\x01".repeat(5); // >64 chars with control chars
1283        Mock::given(method("PATCH"))
1284            .and(path("/v1.0/users/some-user"))
1285            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
1286                "error": {
1287                    "code": hostile_code,
1288                    "message": "some message"
1289                }
1290            })))
1291            .mount(&graph)
1292            .await;
1293
1294        let creds = make_creds(tenant, "cid", "csecret");
1295        let ctx = make_ctx(creds, "some-user", "P@ss1");
1296        let integ = integration(&login, &graph, false);
1297        let err = integ.rotate(&ctx).await.unwrap_err();
1298
1299        let detail = err.detail.as_str();
1300        assert!(
1301            detail.contains("404"),
1302            "detail must contain status code: {detail}"
1303        );
1304        assert!(
1305            !detail.contains("Inject"),
1306            "hostile graph error.code must not appear in detail: {detail}"
1307        );
1308    }
1309
1310    #[test]
1311    fn valid_graph_error_code_passes_validation() {
1312        assert!(super::is_safe_graph_error_code("Request_ResourceNotFound"));
1313        assert!(super::is_safe_graph_error_code("A"));
1314        assert!(super::is_safe_graph_error_code(&"x".repeat(64)));
1315    }
1316
1317    #[test]
1318    fn invalid_graph_error_codes_are_rejected() {
1319        assert!(!super::is_safe_graph_error_code(&"x".repeat(65)));
1320        assert!(!super::is_safe_graph_error_code(""));
1321        assert!(!super::is_safe_graph_error_code("Code\nInjection"));
1322        assert!(!super::is_safe_graph_error_code("Bad:Code"));
1323        assert!(!super::is_safe_graph_error_code("Code\x00bad"));
1324        assert!(!super::is_safe_graph_error_code("Bad Code"));
1325    }
1326
1327    #[tokio::test]
1328    async fn verify_polls_until_timestamp_fresh() {
1329        let login = MockServer::start().await;
1330        let graph = MockServer::start().await;
1331        let tenant = "t1";
1332
1333        mount_token_ok(&login, tenant, "tok").await;
1334
1335        let now = Utc::now();
1336        let stale_at = (now - chrono::Duration::minutes(10)).to_rfc3339();
1337        let fresh_at = (now + chrono::Duration::seconds(1)).to_rfc3339();
1338
1339        // Stale once, then fresh: earlier-mounted mocks take precedence in wiremock.
1340        Mock::given(method("GET"))
1341            .and(path("/v1.0/users/[email protected]"))
1342            .respond_with(
1343                ResponseTemplate::new(200)
1344                    .set_body_json(serde_json::json!({
1345                        "lastPasswordChangeDateTime": stale_at
1346                    }))
1347                    .insert_header("content-type", "application/json"),
1348            )
1349            .up_to_n_times(1)
1350            .mount(&graph)
1351            .await;
1352
1353        Mock::given(method("GET"))
1354            .and(path("/v1.0/users/[email protected]"))
1355            .respond_with(
1356                ResponseTemplate::new(200)
1357                    .set_body_json(serde_json::json!({
1358                        "lastPasswordChangeDateTime": fresh_at
1359                    }))
1360                    .insert_header("content-type", "application/json"),
1361            )
1362            .mount(&graph)
1363            .await;
1364
1365        let creds = make_creds(tenant, "cid", "csecret");
1366        let ctx = make_ctx_started_at(creds, "[email protected]", "P@ss1", now);
1367        let integ = EntraIntegration::new_with_bases_and_settle(
1368            false,
1369            login.uri(),
1370            graph.uri(),
1371            Duration::from_millis(500),
1372            Duration::from_millis(10),
1373        );
1374        assert!(integ.verify(&ctx).await.is_ok());
1375    }
1376
1377    #[tokio::test]
1378    async fn verify_times_out_when_timestamp_stays_stale() {
1379        let login = MockServer::start().await;
1380        let graph = MockServer::start().await;
1381        let tenant = "t1";
1382
1383        mount_token_ok(&login, tenant, "tok").await;
1384
1385        let now = Utc::now();
1386        let stale_at = (now - chrono::Duration::minutes(10)).to_rfc3339();
1387
1388        Mock::given(method("GET"))
1389            .and(path("/v1.0/users/[email protected]"))
1390            .respond_with(
1391                ResponseTemplate::new(200)
1392                    .set_body_json(serde_json::json!({
1393                        "lastPasswordChangeDateTime": stale_at
1394                    }))
1395                    .insert_header("content-type", "application/json"),
1396            )
1397            .mount(&graph)
1398            .await;
1399
1400        let creds = make_creds(tenant, "cid", "csecret");
1401        let ctx = make_ctx_started_at(creds, "[email protected]", "P@ss1", now);
1402        let integ = EntraIntegration::new_with_bases_and_settle(
1403            false,
1404            login.uri(),
1405            graph.uri(),
1406            Duration::from_millis(100),
1407            Duration::from_millis(10),
1408        );
1409        let err = integ.verify(&ctx).await.unwrap_err();
1410        assert_eq!(err.retry, Retry::Permanent);
1411        assert_eq!(err.effect, TargetEffect::Applied);
1412        assert_eq!(err.code, RotationFailureCode::VerificationFailed);
1413    }
1414}