1use 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
26const VERIFY_CLOCK_SLACK: chrono::Duration = chrono::Duration::seconds(300);
28
29const VERIFY_MAX_WAIT: Duration = Duration::from_secs(60);
31
32const VERIFY_POLL_INTERVAL: Duration = Duration::from_secs(5);
33
34pub(crate) struct EntraIntegration {
37 http: Client,
38 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 pub(crate) fn new(verify_probe: bool) -> Self {
50 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 #[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 #[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 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 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 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 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 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 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 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 return ProbeResult::Verified;
238 }
239
240 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#[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
302fn 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
319fn 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
345async 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
356fn 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 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 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 StatusCode::TOO_MANY_REQUESTS => (
403 Retry::Transient,
404 TargetEffect::NotApplied,
405 RotationFailureCode::TargetUnreachable,
406 ),
407 s if s.is_server_error() => (
410 Retry::Transient,
411 TargetEffect::Unknown,
412 RotationFailureCode::TargetUnreachable,
413 ),
414 _ => (
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 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 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 }
458 }
459 }
460
461 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 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 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 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 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 Err(IntegrationError::new(
581 Retry::Permanent,
582 TargetEffect::NotApplied,
583 RotationFailureCode::TargetRejected,
584 detail,
585 ))
586 }
587 }
588}
589
590#[derive(Debug, PartialEq, Eq)]
592enum ProbeResult {
593 Verified,
595 WrongPassword,
597 Inconclusive,
599}
600
601struct 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
618fn 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 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 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 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 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); 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 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}