1use crate::SyncError;
30use crate::loopback::{self, Verdict};
31use base64::Engine;
32use base64::engine::general_purpose::URL_SAFE_NO_PAD;
33use sioul_core::config::{MICROSOFT_IMAP, MICROSOFT_SMTP_PERSONAL, MICROSOFT_SMTP_WORK};
34use std::collections::HashMap;
35use std::net::TcpListener;
36use std::sync::Mutex;
37use std::sync::atomic::AtomicBool;
38use std::time::{Duration, Instant};
39
40pub const LOGIN: &str = "https://login.microsoftonline.com";
42pub const CONSUMERS_TID: &str = "9188040d-6c67-4c5b-b112-36a304b66dad";
45pub const IMAP_SCOPE: &str = "https://outlook.office.com/IMAP.AccessAsUser.All";
47pub const SMTP_SCOPE: &str = "https://outlook.office.com/SMTP.Send";
49pub const MAIL_SCOPES: &str = "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access openid email profile";
52pub const REFRESH_SCOPES: &str = "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access";
54pub const IMAP_HOST: &str = MICROSOFT_IMAP;
56pub const SMTP_PERSONAL: &str = MICROSOFT_SMTP_PERSONAL;
58pub const SMTP_WORK: &str = MICROSOFT_SMTP_WORK;
60pub const SMTP_PORT: u16 = 587;
61pub const CONSENT_PERSONAL: &str = "https://account.live.com/consent/Manage";
63pub const CONSENT_WORK: &str = "https://myapps.microsoft.com";
65pub const IMAP_HELP: &str = "https://support.microsoft.com/en-us/office/pop-imap-and-smtp-settings-for-outlook-com-d088b986-291d-42b8-9564-9c414e2aa040";
67pub const SMTP_AUTH_HELP: &str = "https://aka.ms/smtp_auth_disabled";
69
70pub const AGAIN: &str = "microsoft-again";
74pub const AGAIN_PASSWORD: &str = "microsoft-again-password";
76pub const AGAIN_UNUSED: &str = "microsoft-again-unused";
78pub const AGAIN_FREQUENCY: &str = "microsoft-again-frequency";
80pub const AGAIN_LATE: &str = "microsoft-again-late";
82pub const ADMIN: &str = "microsoft-admin";
84pub const BLOCKED: &str = "microsoft-blocked";
86pub const UNKNOWN_APP: &str = "microsoft-unknown-app";
88pub const REDIRECT: &str = "microsoft-redirect";
90pub const CONFIDENTIAL: &str = "microsoft-confidential";
92pub const SECRET_SENT: &str = "microsoft-secret-sent";
94pub const ACCOUNT_TYPES: &str = "microsoft-account-types";
96pub const SCOPES_MISSING: &str = "microsoft-scopes";
98pub const PASSWORD: &str = "microsoft-password";
100
101pub const CANCELLED: &str = "microsoft-cancelled";
104pub const TIMEOUT: &str = "microsoft-timeout";
106pub const DECLINED: &str = "microsoft-declined";
108pub const DECLINED_WORK: &str = "microsoft-declined-work";
110pub const NOT_CONNECTED_PERSONAL: &str = "sync-said-microsoft-not-connected-personal";
112pub const NOT_CONNECTED_WORK: &str = "sync-said-microsoft-not-connected-work";
114pub const SMTP_OFF: &str = "sync-said-microsoft-smtp-off";
116pub const OTHER_HOST: &str = "sync-said-microsoft-other-host";
118pub const OTHER_ACCOUNT: &str = "microsoft-other-account";
120pub const MAILBOX_REFUSED: &str = "microsoft-mailbox-refused";
122pub const LOGIN_TAKEN: &str = "microsoft-login-taken";
125pub const OTHER_DECLINED: &str = "microsoft-other-declined";
127
128pub(crate) fn message_of(detail: &str) -> Option<&'static str> {
130 Some(match detail {
131 AGAIN => "sync-error-microsoft-again",
132 AGAIN_PASSWORD => "sync-error-microsoft-again-password",
133 AGAIN_UNUSED => "sync-error-microsoft-again-unused",
134 AGAIN_FREQUENCY => "sync-error-microsoft-again-frequency",
135 AGAIN_LATE => "sync-error-microsoft-again-late",
136 ADMIN => "sync-error-microsoft-admin",
137 BLOCKED => "sync-error-microsoft-blocked",
138 UNKNOWN_APP => "sync-error-microsoft-unknown-app",
139 REDIRECT => "sync-error-microsoft-redirect",
140 CONFIDENTIAL => "sync-error-microsoft-confidential",
141 SECRET_SENT => "sync-error-microsoft-secret-sent",
142 ACCOUNT_TYPES => "sync-error-microsoft-account-types",
143 SCOPES_MISSING => "sync-error-microsoft-scopes",
144 PASSWORD => "sync-error-microsoft-password",
145 _ => return None,
146 })
147}
148
149pub fn built_in() -> Option<&'static str> {
154 option_env!("SIOUL_MICROSOFT_CLIENT_ID").map(str::trim).filter(|id| !id.is_empty())
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum Kind {
163 Personal,
164 Work,
165 Unknown,
166}
167
168impl Kind {
169 pub fn as_str(self) -> &'static str {
170 match self {
171 Kind::Personal => "personal",
172 Kind::Work => "work",
173 Kind::Unknown => "unknown",
174 }
175 }
176
177 pub fn parse(text: &str) -> Kind {
178 match text {
179 "personal" => Kind::Personal,
180 "work" => Kind::Work,
181 _ => Kind::Unknown,
182 }
183 }
184
185 pub fn smtp_host(self) -> &'static str {
187 match self {
188 Kind::Personal => SMTP_PERSONAL,
189 Kind::Work | Kind::Unknown => SMTP_WORK,
190 }
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum Authority {
197 Consumers,
199 Organizations,
201 Common,
203 Tenant(String),
206}
207
208impl Authority {
209 pub fn of(kind: Kind) -> Authority {
211 match kind {
212 Kind::Personal => Authority::Consumers,
213 Kind::Work => Authority::Organizations,
214 Kind::Unknown => Authority::Common,
215 }
216 }
217
218 pub fn tenant(given: &str) -> Result<Option<Authority>, SyncError> {
221 let given = given.trim().to_ascii_lowercase();
222 if given.is_empty() {
223 return Ok(None);
224 }
225 if is_tenant(&given) { Ok(Some(Authority::Tenant(given))) } else { Err(SyncError::Login(UNKNOWN_APP.into())) }
226 }
227
228 pub fn segment(&self) -> &str {
230 match self {
231 Authority::Consumers => "consumers",
232 Authority::Organizations => "organizations",
233 Authority::Common => "common",
234 Authority::Tenant(tenant) => tenant,
235 }
236 }
237}
238
239fn is_tenant(text: &str) -> bool {
243 !text.is_empty() && text.len() <= 253 && !text.starts_with(['.', '-']) && text.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
244}
245
246pub fn is_imap_host(host: &str) -> bool {
248 host.eq_ignore_ascii_case(IMAP_HOST)
249}
250
251pub fn is_smtp_host(host: &str) -> bool {
253 host.eq_ignore_ascii_case(SMTP_PERSONAL) || host.eq_ignore_ascii_case(SMTP_WORK)
254}
255
256#[derive(Clone, Default, PartialEq, Eq)]
258pub struct Grant {
259 pub client_id: String,
261 pub tenant: String,
264 pub personal: bool,
266 pub refresh_token: String,
267 pub scopes: Vec<String>,
268 pub signed_in: i64,
271 pub signed_in_as: String,
274}
275
276impl std::fmt::Debug for Grant {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 f.debug_struct("Grant")
280 .field("client_id", &self.client_id)
281 .field("tenant", &self.tenant)
282 .field("personal", &self.personal)
283 .field("scopes", &self.scopes)
284 .field("signed_in", &self.signed_in)
285 .field("signed_in_as", &self.signed_in_as)
286 .finish_non_exhaustive()
287 }
288}
289
290#[derive(Clone, serde::Serialize, serde::Deserialize)]
300struct Kept {
301 client_id: String,
302 tenant: String,
303 personal: bool,
304 refresh_token: String,
305 #[serde(default)]
306 parts: usize,
307 check: String,
308 scopes: Vec<String>,
309 signed_in: i64,
310 #[serde(default)]
311 signed_in_as: String,
312}
313
314const MOST_PARTS: usize = 3;
316
317fn keyring_name(login: &str) -> String {
319 format!("microsoft-mail:{}", login.trim().to_lowercase())
320}
321
322trait Store {
324 fn get(&self, name: &str) -> Result<Option<Vec<u8>>, SyncError>;
325 fn set(&self, name: &str, value: &[u8]) -> Result<(), SyncError>;
326 fn forget(&self, name: &str) -> Result<(), SyncError>;
327}
328
329struct Keyring;
331
332impl Store for Keyring {
333 fn get(&self, name: &str) -> Result<Option<Vec<u8>>, SyncError> {
334 crate::secret::named_bytes(name)
335 }
336 fn set(&self, name: &str, value: &[u8]) -> Result<(), SyncError> {
337 crate::secret::save_named_bytes(name, value)
338 }
339 fn forget(&self, name: &str) -> Result<(), SyncError> {
340 crate::secret::forget_named(name)
341 }
342}
343
344fn check_of(token: &str) -> String {
345 use sha2::Digest;
346 sha2::Sha256::digest(token.as_bytes()).iter().take(8).map(|b| format!("{b:02x}")).collect()
347}
348
349fn part_name(name: &str, check: &str, index: usize) -> String {
351 format!("{name}#{check}#{index}")
352}
353
354fn legacy_part_name(name: &str, index: usize) -> String {
358 format!("{name}#{index}")
359}
360
361fn kept_in(store: &dyn Store, name: &str) -> Option<Kept> {
363 store.get(name).ok().flatten().and_then(|bytes| serde_json::from_slice::<Kept>(&bytes).ok())
364}
365
366#[cfg(test)]
369fn part_names(store: &dyn Store, name: &str) -> Vec<String> {
370 kept_in(store, name).map(|kept| (2..kept.parts.min(MOST_PARTS) + 2).map(|index| part_name(name, &kept.check, index)).collect()).unwrap_or_default()
371}
372
373fn forget_parts(store: &dyn Store, name: &str, check: &str, parts: usize) -> bool {
375 (2..parts.min(MOST_PARTS) + 2).map(|index| store.forget(&part_name(name, check, index)).is_ok()).fold(true, |all, gone| all && gone)
376}
377
378fn generations_name(name: &str) -> String {
386 format!("{name}#generations")
387}
388
389const MOST_GENERATIONS: usize = 16;
392
393fn generations(store: &dyn Store, name: &str) -> Result<Vec<String>, SyncError> {
394 Ok(store.get(&generations_name(name))?.and_then(|bytes| serde_json::from_slice::<Vec<String>>(&bytes).ok()).unwrap_or_default())
395}
396
397fn note_generation(store: &dyn Store, name: &str, check: &str) {
400 let Ok(mut noted) = generations(store, name).or_else(|_| generations(store, name)) else { return };
401 if noted.iter().any(|c| c == check) {
402 return;
403 }
404 noted.push(check.to_string());
405 let past = noted.len().saturating_sub(MOST_GENERATIONS);
406 noted.drain(..past);
407 if let Ok(bytes) = serde_json::to_vec(¬ed) {
408 let _ = store.set(&generations_name(name), &bytes);
409 }
410}
411
412fn strike_generations(store: &dyn Store, name: &str, gone: &[&str]) {
414 let Ok(mut noted) = generations(store, name) else { return };
415 let before = noted.len();
416 noted.retain(|c| !gone.contains(&c.as_str()));
417 if noted.len() == before {
418 return;
419 }
420 if noted.is_empty() {
421 let _ = store.forget(&generations_name(name));
422 } else if let Ok(bytes) = serde_json::to_vec(¬ed) {
423 let _ = store.set(&generations_name(name), &bytes);
424 }
425}
426
427fn forget_legacy_parts(store: &dyn Store, name: &str) {
428 for index in 2..MOST_PARTS + 2 {
429 let _ = store.forget(&legacy_part_name(name, index));
430 }
431}
432
433fn split(grant: &Grant) -> Result<(Kept, Vec<String>), SyncError> {
436 let limit = crate::secret::ENTRY_BYTES;
437 let token = grant.refresh_token.as_str();
438 let mut kept = Kept {
439 client_id: grant.client_id.clone(),
440 tenant: grant.tenant.clone(),
441 personal: grant.personal,
442 refresh_token: String::new(),
443 parts: MOST_PARTS,
444 check: check_of(token),
445 scopes: grant.scopes.clone(),
446 signed_in: grant.signed_in,
447 signed_in_as: grant.signed_in_as.clone(),
448 };
449 let json = |kept: &Kept| serde_json::to_vec(kept).map_err(|e| SyncError::Keyring(e.to_string()));
450 let mut first = token.len().min(limit.saturating_sub(json(&kept)?.len()));
452 loop {
454 while !token.is_char_boundary(first) {
455 first -= 1;
456 }
457 kept.refresh_token = token[..first].to_string();
458 if json(&kept)?.len() <= limit || first == 0 {
459 break;
460 }
461 first = first.saturating_sub(64);
462 }
463 let rest = &token[first..];
464 let mut parts = Vec::new();
465 let mut at = 0;
466 while at < rest.len() {
467 let mut end = (at + limit).min(rest.len());
468 while !rest.is_char_boundary(end) {
469 end -= 1;
470 }
471 parts.push(rest[at..end].to_string());
472 at = end;
473 }
474 kept.parts = parts.len();
475 if kept.parts > MOST_PARTS {
476 return Err(SyncError::Keyring(format!("a refresh token of {} bytes, longer than Sioul keeps", token.len())));
477 }
478 Ok((kept, parts))
479}
480
481fn save_in(store: &dyn Store, name: &str, grant: &Grant) -> Result<(), SyncError> {
492 let (kept, parts) = split(grant)?;
493 let written: Vec<String> = (0..parts.len()).map(|index| part_name(name, &kept.check, index + 2)).collect();
494 let take_back = || {
496 if written.iter().map(|n| store.forget(n).is_ok()).fold(true, |all, gone| all && gone) {
497 strike_generations(store, name, &[&kept.check]);
498 }
499 };
500 if !parts.is_empty() {
501 note_generation(store, name, &kept.check);
502 }
503 for (part, part_named) in parts.iter().zip(&written) {
504 if let Err(e) = store.set(part_named, part.as_bytes()) {
505 take_back();
506 return Err(e);
507 }
508 }
509 let replaced = store.get(name).ok().flatten();
511 let old = replaced.as_deref().and_then(|b| serde_json::from_slice::<Kept>(b).ok()).filter(|old| old.check != kept.check && old.parts > 0);
512 if let Some(old) = &old {
513 note_generation(store, name, &old.check);
514 }
515 let bytes = serde_json::to_vec(&kept).map_err(|e| SyncError::Keyring(e.to_string()))?;
516 if let Err(e) = store.set(name, &bytes) {
517 take_back();
518 return Err(e);
519 }
520 let back = match load_from(store, name).or_else(|_| {
522 std::thread::sleep(std::time::Duration::from_millis(50));
523 load_from(store, name)
524 }) {
525 Ok(back) => back.map(|grant| check_of(&grant.refresh_token)),
526 Err(_) => return Ok(()),
527 };
528 match back {
529 Some(check) if check == kept.check => {
531 if let Some(old) = old
532 && forget_parts(store, name, &old.check, old.parts)
533 {
534 strike_generations(store, name, &[&old.check]);
535 }
536 forget_legacy_parts(store, name);
537 Ok(())
538 }
539 Some(_) => {
541 take_back();
542 Ok(())
543 }
544 None => {
546 if let Some(old) = replaced {
547 let _ = store.set(name, &old);
548 }
549 take_back();
550 Err(SyncError::Keyring(format!("{name}: the grant written did not read back whole; the one before is kept")))
551 }
552 }
553}
554
555fn load_from(store: &dyn Store, name: &str) -> Result<Option<Grant>, SyncError> {
559 let Some(bytes) = store.get(name)? else { return Ok(None) };
560 let Ok(kept) = serde_json::from_slice::<Kept>(&bytes) else { return Ok(None) };
561 if kept.parts > MOST_PARTS || kept.check.is_empty() {
562 return Ok(None);
563 }
564 let mut token = kept.refresh_token.clone();
565 for index in 2..kept.parts + 2 {
566 let part = match store.get(&part_name(name, &kept.check, index))? {
567 Some(part) => part,
568 None => match store.get(&legacy_part_name(name, index))? {
570 Some(part) => part,
571 None => return Ok(None),
572 },
573 };
574 token.push_str(&String::from_utf8_lossy(&part));
575 }
576 if kept.check != check_of(&token) {
577 return Ok(None);
578 }
579 Ok(Some(Grant {
580 client_id: kept.client_id,
581 tenant: kept.tenant,
582 personal: kept.personal,
583 refresh_token: token,
584 scopes: kept.scopes,
585 signed_in: kept.signed_in,
586 signed_in_as: kept.signed_in_as,
587 }))
588}
589
590pub fn grant(login: &str) -> Option<Grant> {
593 load_from(&Keyring, &keyring_name(login)).ok().flatten()
594}
595
596pub fn own_app(login: &str) -> Option<String> {
600 grant(login).map(|g| g.client_id).filter(|id| !id.is_empty() && Some(id.as_str()) != built_in())
601}
602
603pub struct SignIn {
605 listener: TcpListener,
606 verifier: String,
607 state: String,
608 redirect: String,
609 authority: Authority,
610 pub url: String,
612}
613
614pub fn begin(client_id: &str, authority: &Authority, address: &str) -> Result<SignIn, SyncError> {
619 let (listener, redirect) = loopback::open(false)?;
620 let verifier = loopback::random(48);
621 let challenge = loopback::challenge(&verifier);
622 let state = loopback::random(16);
623 let url = format!(
624 "{LOGIN}/{}/oauth2/v2.0/authorize?{}",
625 authority.segment(),
626 loopback::query(&[
627 ("client_id", client_id.trim()),
628 ("response_type", "code"),
629 ("redirect_uri", &redirect),
630 ("response_mode", "query"),
631 ("scope", MAIL_SCOPES),
632 ("state", &state),
633 ("code_challenge", &challenge),
634 ("code_challenge_method", "S256"),
635 ("login_hint", address.trim()),
636 ("prompt", "select_account"),
637 ])
638 );
639 Ok(SignIn { listener, verifier, state, redirect, authority: authority.clone(), url })
640}
641
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644enum Step {
645 Page,
647 Redeem,
649 Refresh,
651}
652
653fn aadsts(description: &str) -> Option<u64> {
655 let at = description.find("AADSTS")? + 6;
656 let digits: String = description[at..].chars().take_while(char::is_ascii_digit).collect();
657 digits.parse().ok()
658}
659
660fn refusal(status: u16, error: &str, codes: &[u64], description: &str, personal: Option<bool>, step: Step) -> SyncError {
664 let code = codes.first().copied().or_else(|| aadsts(description));
665 let login = |id: &str| SyncError::Login(id.to_string());
666 match (error, code) {
667 (_, Some(50173)) => login(AGAIN_PASSWORD),
668 (_, Some(70008 | 700082 | 54005)) if step != Step::Refresh => login(AGAIN_LATE),
669 (_, Some(70008 | 700082)) => login(AGAIN_UNUSED),
670 (_, Some(70043)) => login(AGAIN_FREQUENCY),
671 (_, Some(50076 | 50079 | 50074)) => login(AGAIN),
673 (_, Some(90094 | 90008)) => login(ADMIN),
674 (_, Some(65004)) | ("access_denied", _) => SyncError::Said(if personal == Some(true) { DECLINED } else { DECLINED_WORK }, String::new()),
675 ("consent_required", _) | (_, Some(65001)) => login(if personal == Some(true) { AGAIN } else { ADMIN }),
676 (_, Some(53003 | 530035)) => login(BLOCKED),
677 (_, Some(700016)) | ("unauthorized_client", _) => login(UNKNOWN_APP),
678 (_, Some(50011)) => login(REDIRECT),
679 (_, Some(7000218)) => login(CONFIDENTIAL),
680 (_, Some(700025)) => login(SECRET_SENT),
681 (_, Some(50020 | 50194)) => login(ACCOUNT_TYPES),
682 ("invalid_grant", _) if step == Step::Redeem => login(AGAIN_LATE),
683 ("invalid_grant" | "interaction_required" | "login_required", _) => login(AGAIN),
684 _ => {
686 let status = (status != 0).then(|| status.to_string()).unwrap_or_default();
687 let said = [status, error.to_string(), code.map(|c| format!("AADSTS{c}")).unwrap_or_default()].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" ");
688 SyncError::Server(format!("Microsoft: {said}"))
689 }
690 }
691}
692
693fn post(url: &str, form: &[(&str, &str)], personal: Option<bool>, step: Step) -> Result<serde_json::Value, SyncError> {
697 #[cfg(not(test))]
698 if !url.starts_with(&format!("{LOGIN}/")) {
699 return Err(SyncError::Elsewhere(url.split('?').next().unwrap_or_default().to_string()));
700 }
701 let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).max_redirects(0).https_only(!cfg!(test)).build().into();
702 let mut response = agent.post(url).header("Content-Type", "application/x-www-form-urlencoded").send(loopback::query(form)).map_err(|e| SyncError::Network(e.to_string()))?;
703 let status = response.status().as_u16();
704 let body: serde_json::Value = serde_json::from_str(&response.body_mut().read_to_string().unwrap_or_default()).unwrap_or_default();
705 if status == 200 {
706 return Ok(body);
707 }
708 let codes: Vec<u64> = body["error_codes"].as_array().map(|codes| codes.iter().filter_map(serde_json::Value::as_u64).collect()).unwrap_or_default();
709 Err(refusal(status, body["error"].as_str().unwrap_or(""), &codes, body["error_description"].as_str().unwrap_or(""), personal, step))
710}
711
712fn claims(id_token: &str) -> Option<(String, String)> {
716 let payload = id_token.split('.').nth(1)?;
717 let json: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?).ok()?;
718 let tid = json["tid"].as_str().unwrap_or("").to_ascii_lowercase();
719 let address = [&json["email"], &json["preferred_username"]].into_iter().filter_map(|v| v.as_str()).map(|v| v.trim().to_lowercase()).find(|v| v.contains('@')).unwrap_or_default();
721 Some((tid, address))
722}
723
724fn gave_mail(scopes: &[String]) -> bool {
727 let has = |wanted: &str| scopes.iter().any(|s| s.eq_ignore_ascii_case(wanted) || s.rsplit('/').next().is_some_and(|last| wanted.ends_with(&format!("/{last}")) && last.contains('.')));
728 scopes.is_empty() || (has(IMAP_SCOPE) && has(SMTP_SCOPE))
729}
730
731fn now() -> i64 {
732 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
733}
734
735fn wait_for_code(sign_in: &SignIn, deadline: Duration, stop: &AtomicBool, personal: Option<bool>) -> Result<String, SyncError> {
740 let ends = loopback::Ends { cancelled: SyncError::Said(CANCELLED, String::new()), timed_out: SyncError::Said(TIMEOUT, String::new()) };
741 loopback::wait(&sign_in.listener, &sign_in.state, deadline, stop, ends, |answer| {
742 let granted = if cfg!(target_os = "android") { "microsoft-page-granted-phone" } else { "microsoft-page-granted" };
744 match (answer.state_ok, answer.get("code"), answer.get("error")) {
745 (true, Some(code), _) => Verdict::Done(granted, Ok(code.to_string())),
746 (true, _, Some(error)) => Verdict::Done("microsoft-page-refused", Err(refusal(0, error, &[], answer.get("error_description").unwrap_or(""), personal, Step::Page))),
747 _ => Verdict::Ignored("microsoft-page-foreign"),
748 }
749 })
750}
751
752pub fn finish(sign_in: SignIn, client_id: &str, address: &str, stop: &AtomicBool) -> Result<Grant, SyncError> {
760 finish_at(sign_in, client_id, address, stop, None, &Keyring)
761}
762
763fn finish_at(sign_in: SignIn, client_id: &str, address: &str, stop: &AtomicBool, token_url: Option<&str>, store: &dyn Store) -> Result<Grant, SyncError> {
766 let hinted = match sign_in.authority {
767 Authority::Consumers => Some(true),
768 Authority::Organizations | Authority::Tenant(_) => Some(false),
769 Authority::Common => None,
770 };
771 let code = wait_for_code(&sign_in, Duration::from_secs(300), stop, hinted)?;
772 let endpoint = token_url.map_or_else(|| format!("{LOGIN}/{}/oauth2/v2.0/token", sign_in.authority.segment()), str::to_string);
773 let answer = post(
774 &endpoint,
775 &[("client_id", client_id.trim()), ("scope", MAIL_SCOPES), ("code", &code), ("redirect_uri", &sign_in.redirect), ("grant_type", "authorization_code"), ("code_verifier", &sign_in.verifier)],
776 hinted,
777 Step::Redeem,
778 )?;
779 let refresh_token = answer["refresh_token"].as_str().filter(|t| !t.is_empty()).ok_or_else(|| SyncError::Server("Microsoft gave no refresh token".into()))?.to_string();
780 let scopes: Vec<String> = answer["scope"].as_str().unwrap_or("").split_whitespace().map(str::to_string).collect();
781 if !gave_mail(&scopes) {
782 return Err(SyncError::Login(SCOPES_MISSING.into()));
783 }
784 let (tid, signed_in_as) = answer["id_token"].as_str().and_then(claims).unwrap_or_default();
785 let personal = if tid.is_empty() { hinted.unwrap_or(false) } else { tid == CONSUMERS_TID };
787 let tenant = if personal {
788 "consumers".to_string()
789 } else if is_tenant(&tid) {
790 tid
791 } else {
792 sign_in.authority.segment().to_string()
793 };
794 let grant = Grant { client_id: client_id.trim().into(), tenant, personal, refresh_token, scopes, signed_in: now(), signed_in_as };
795 let name = keyring_name(address);
796 save_in(store, &name, &grant)?;
797 if let Some(access) = answer["access_token"].as_str() {
798 remember(&name, access, answer["expires_in"].as_u64().unwrap_or(3600));
799 }
800 Ok(grant)
801}
802
803pub fn move_grant(from: &str, to: &str) -> Result<(), SyncError> {
806 move_grant_in(&Keyring, from, to)
807}
808
809fn move_grant_in(store: &dyn Store, from: &str, to: &str) -> Result<(), SyncError> {
810 let (from_name, to_name) = (keyring_name(from), keyring_name(to));
811 if from_name == to_name {
812 return Ok(());
813 }
814 let grant = load_from(store, &from_name)?.ok_or_else(|| SyncError::Login(AGAIN.into()))?;
815 save_in(store, &to_name, &grant)?;
816 let access = ACCESS.lock().ok().and_then(|a| a.as_ref().and_then(|m| m.get(&from_name).cloned()));
817 if let Some((token, until)) = access {
818 remember_until(&to_name, &token, until);
819 }
820 forget_entries_in(store, &from_name)
821}
822
823static ACCESS: Mutex<Option<HashMap<String, (String, Instant)>>> = Mutex::new(None);
825
826fn remember(key: &str, token: &str, lasts: u64) {
827 remember_until(key, token, Instant::now() + Duration::from_secs(lasts.saturating_sub(60)));
828}
829
830fn remember_until(key: &str, token: &str, until: Instant) {
831 if let Ok(mut access) = ACCESS.lock() {
832 access.get_or_insert_with(HashMap::new).insert(key.to_string(), (token.to_string(), until));
833 }
834}
835
836fn remembered(key: &str) -> Option<String> {
838 ACCESS.lock().ok().and_then(|a| a.as_ref().and_then(|m| m.get(key).cloned())).filter(|(_, until)| *until > Instant::now()).map(|(token, _)| token)
839}
840
841pub(crate) fn test_token() -> Option<String> {
845 #[cfg(feature = "insecure-test-tls")]
846 if let Some(token) = std::env::var_os("SIOUL_TEST_MICROSOFT_TOKEN") {
847 return Some(token.to_string_lossy().to_string());
848 }
849 None
850}
851
852pub fn access_token(login: &str, fresh: bool) -> Result<String, SyncError> {
856 if let Some(token) = test_token() {
857 return Ok(token);
858 }
859 let key = keyring_name(login);
860 if !fresh && let Some(token) = remembered(&key) {
861 return Ok(token);
862 }
863 let grant = load_from(&Keyring, &key)?.ok_or_else(|| SyncError::Login(AGAIN.into()))?;
865 let tenant = if is_tenant(&grant.tenant) { grant.tenant.as_str() } else { "common" };
867 let endpoint = format!("{LOGIN}/{tenant}/oauth2/v2.0/token");
868 let (token, lasts) = refreshed(&endpoint, &grant, &Keyring, &key)?;
869 remember(&key, &token, lasts);
870 Ok(token)
871}
872
873fn refreshed(endpoint: &str, grant: &Grant, store: &dyn Store, name: &str) -> Result<(String, u64), SyncError> {
880 let answer = post(
881 endpoint,
882 &[("client_id", &grant.client_id), ("grant_type", "refresh_token"), ("refresh_token", &grant.refresh_token), ("scope", REFRESH_SCOPES)],
883 Some(grant.personal),
884 Step::Refresh,
885 )?;
886 let token = answer["access_token"].as_str().ok_or_else(|| SyncError::Server("Microsoft gave no access token".into()))?.to_string();
887 if let Some(next) = answer["refresh_token"].as_str().filter(|next| !next.is_empty() && *next != grant.refresh_token) {
888 let renewed = Grant { refresh_token: next.to_string(), ..grant.clone() };
889 if let Err(e) = save_in(store, name, &renewed) {
890 eprintln!("sioul: Microsoft's new refresh token for {} could not be kept ({e}); the one kept still works until its own end", name.trim_start_matches("microsoft-mail:"));
891 }
892 }
893 Ok((token, answer["expires_in"].as_u64().unwrap_or(3600)))
894}
895
896fn forget_entries(name: &str) -> Result<(), SyncError> {
897 forget_entries_in(&Keyring, name)
898}
899
900fn forget_entries_in(store: &dyn Store, name: &str) -> Result<(), SyncError> {
904 if let Ok(mut access) = ACCESS.lock()
905 && let Some(map) = access.as_mut()
906 {
907 map.remove(name);
908 }
909 if let Some(kept) = kept_in(store, name) {
910 forget_parts(store, name, &kept.check, kept.parts);
911 }
912 if let Ok(noted) = generations(store, name).or_else(|_| generations(store, name)) {
915 for check in noted {
916 forget_parts(store, name, &check, MOST_PARTS);
917 }
918 let _ = store.forget(&generations_name(name));
919 }
920 forget_legacy_parts(store, name);
921 store.forget(name)
922}
923
924pub fn forget(login: &str, personal: bool) -> Result<&'static str, SyncError> {
929 forget_entries(&keyring_name(login))?;
930 Ok(if personal { CONSENT_PERSONAL } else { CONSENT_WORK })
931}
932
933pub fn is_app_id(text: &str) -> bool {
937 let text = text.trim();
938 text.len() == 36 && text.char_indices().all(|(i, c)| if [8, 13, 18, 23].contains(&i) { c == '-' } else { c.is_ascii_hexdigit() })
939}
940
941pub fn app_for(login: &str, given: &str) -> Result<Option<String>, SyncError> {
945 let given = given.trim();
946 if !given.is_empty() {
947 return if is_app_id(given) { Ok(Some(given.to_ascii_lowercase())) } else { Err(SyncError::Login(UNKNOWN_APP.into())) };
948 }
949 Ok(own_app(login).or_else(|| built_in().map(str::to_string)))
950}
951
952pub fn authority_for(login: &str, kind: Kind, tenant: &str) -> Result<Authority, SyncError> {
956 if let Some(given) = Authority::tenant(tenant)? {
957 return Ok(given);
958 }
959 Ok(match grant(login) {
960 Some(kept) if kept.personal => Authority::Consumers,
961 Some(kept) if is_tenant(&kept.tenant) && kept.tenant != "common" => Authority::Tenant(kept.tenant),
962 _ => Authority::of(kind),
963 })
964}
965
966pub struct Signed {
968 pub account: sioul_core::config::Account,
970 pub grant: Grant,
971 pub other_login: Option<String>,
977 before: Option<String>,
980}
981
982impl Signed {
983 pub fn answer(self, yes: bool) -> Result<(sioul_core::config::Account, Grant), SyncError> {
988 self.answer_in(&Keyring, yes)
989 }
990
991 fn answer_in(self, store: &dyn Store, yes: bool) -> Result<(sioul_core::config::Account, Grant), SyncError> {
992 let now = self.account.login().unwrap_or_default().trim().to_lowercase();
993 if !yes {
994 drop(forget_entries_in(store, &keyring_name(&now)));
995 return Err(SyncError::Said(OTHER_DECLINED, now));
996 }
997 if let Some(before) = self.before.filter(|b| *b != now) {
998 drop(forget_entries_in(store, &keyring_name(&before)));
999 }
1000 Ok((self.account, self.grant))
1001 }
1002}
1003
1004fn refuse_taken(login: &str, taken: &[String]) -> Result<(), SyncError> {
1008 if taken.iter().any(|t| t.trim().eq_ignore_ascii_case(login.trim())) { Err(SyncError::Said(LOGIN_TAKEN, login.trim().to_lowercase())) } else { Ok(()) }
1009}
1010
1011pub fn sign_in(mut account: sioul_core::config::Account, client_id: &str, authority: &Authority, taken: &[String], show: impl FnOnce(&str), stop: &AtomicBool) -> Result<Signed, SyncError> {
1023 use sioul_core::config::Security;
1024 let address = account.address.clone().unwrap_or_default().trim().to_lowercase();
1025 let before = account.login().map(str::to_lowercase).filter(|login| account.is_microsoft() && *login != address);
1026 refuse_taken(&address, taken)?;
1028 let started = begin(client_id, authority, &address)?;
1029 show(&started.url);
1030 let grant = finish(started, client_id, &address, stop)?;
1031 account.auth = Some("microsoft".into());
1032 account.host = Some(IMAP_HOST.into());
1033 account.port = Some(993);
1034 account.security = Security::Tls;
1035 account.username = None;
1036 account.smtp_host = Some(if grant.personal { SMTP_PERSONAL } else { SMTP_WORK }.into());
1037 account.smtp_port = Some(SMTP_PORT);
1038 account.smtp_security = Security::Starttls;
1039 account.microsoft_kind = Some(if grant.personal { "personal" } else { "work" }.into());
1041 let (account, other_login) = open_mailbox(&Keyring, account, &grant, &address, before.as_deref(), taken, |a| crate::test(a, "").map(drop))?;
1042 Ok(Signed { account, grant, other_login, before })
1043}
1044
1045fn open_mailbox(store: &dyn Store, mut account: sioul_core::config::Account, grant: &Grant, address: &str, before: Option<&str>, taken: &[String], test: impl Fn(&sioul_core::config::Account) -> Result<(), SyncError>) -> Result<(sioul_core::config::Account, Option<String>), SyncError> {
1053 let forget_under = |login: &str| drop(forget_entries_in(store, &keyring_name(login)));
1054 let refused = |e: &SyncError| matches!(e, SyncError::Login(d) if d == AGAIN) || matches!(e, SyncError::Said(id, _) if *id == NOT_CONNECTED_PERSONAL || *id == NOT_CONNECTED_WORK);
1056 let other = grant.signed_in_as.clone();
1057 let other_login = match test(&account) {
1058 Ok(()) => None,
1059 Err(first) if refused(&first) && !other.is_empty() && other != address => {
1061 if let Err(e) = refuse_taken(&other, taken) {
1062 forget_under(address);
1063 return Err(e);
1064 }
1065 move_grant_in(store, address, &other).inspect_err(|_| forget_under(address))?;
1067 account.username = Some(other.clone());
1068 if let Err(second) = test(&account) {
1069 forget_under(&other);
1070 return Err(if refused(&second) { SyncError::Said(OTHER_ACCOUNT, other.clone()) } else { second });
1071 }
1072 (before != Some(other.as_str())).then(|| other.clone())
1074 }
1075 Err(first) => {
1077 forget_under(address);
1078 return Err(if refused(&first) { SyncError::Said(MAILBOX_REFUSED, String::new()) } else { first });
1079 }
1080 };
1081 Ok((account, other_login))
1082}
1083
1084pub fn admin_consent_url(client_id: &str, tenant: &str) -> String {
1088 let tenant = Some(tenant.trim().to_ascii_lowercase()).filter(|t| is_tenant(t)).unwrap_or_else(|| "organizations".into());
1089 format!("{LOGIN}/{tenant}/v2.0/adminconsent?{}", loopback::query(&[("client_id", client_id.trim()), ("scope", MAIL_SCOPES), ("redirect_uri", "http://127.0.0.1"), ("state", "sioul")]))
1090}
1091
1092pub(crate) fn not_connected(personal: bool, refusal: &str) -> Option<SyncError> {
1096 refusal
1097 .to_ascii_lowercase()
1098 .contains("authenticated but not connected")
1099 .then(|| SyncError::Said(if personal { NOT_CONNECTED_PERSONAL } else { NOT_CONNECTED_WORK }, refusal.trim().to_string()))
1100}
1101
1102pub(crate) fn smtp_refused(refusal: &str) -> Option<SyncError> {
1105 let off = refusal.contains("5.7.139") || refusal.to_ascii_lowercase().contains("smtpclientauthentication is disabled");
1106 off.then(|| SyncError::Said(SMTP_OFF, refusal.trim().to_string()))
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111 use super::*;
1112 use std::io::{BufRead, BufReader, Read, Write};
1113
1114 #[derive(Default)]
1116 struct Memory(Mutex<HashMap<String, Vec<u8>>>);
1117
1118 impl Store for Memory {
1119 fn get(&self, name: &str) -> Result<Option<Vec<u8>>, SyncError> {
1120 Ok(self.0.lock().unwrap().get(name).cloned())
1121 }
1122 fn set(&self, name: &str, value: &[u8]) -> Result<(), SyncError> {
1123 assert!(value.len() <= crate::secret::ENTRY_BYTES, "{name}: {} bytes, more than Windows keeps", value.len());
1124 self.0.lock().unwrap().insert(name.to_string(), value.to_vec());
1125 Ok(())
1126 }
1127 fn forget(&self, name: &str) -> Result<(), SyncError> {
1128 self.0.lock().unwrap().remove(name);
1129 Ok(())
1130 }
1131 }
1132
1133 fn id_token(claims: &str) -> String {
1134 format!("eyJhbGciOiJub25lIn0.{}.", URL_SAFE_NO_PAD.encode(claims))
1135 }
1136
1137 #[test]
1139 fn a_sign_in_page_with_pkce_and_the_account_chooser() {
1140 for (authority, segment) in [(Authority::Consumers, "consumers"), (Authority::Organizations, "organizations"), (Authority::Common, "common"), (Authority::Tenant("contoso.example".into()), "contoso.example")] {
1141 let sign_in = begin("00000000-1111-2222-3333-444444444444", &authority, "you@example.org").unwrap();
1142 let port = sign_in.listener.local_addr().unwrap().port();
1143 assert!(
1144 sign_in.url.starts_with(&format!("https://login.microsoftonline.com/{segment}/oauth2/v2.0/authorize?client_id=00000000-1111-2222-3333-444444444444&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A{port}&response_mode=query&scope=")),
1145 "{}",
1146 sign_in.url
1147 );
1148 assert!(sign_in.url.contains("&scope=https%3A%2F%2Foutlook.office.com%2FIMAP.AccessAsUser.All%20https%3A%2F%2Foutlook.office.com%2FSMTP.Send%20offline_access%20openid%20email%20profile&"), "{}", sign_in.url);
1149 assert!(sign_in.url.contains(&format!("&code_challenge={}&code_challenge_method=S256&", loopback::challenge(&sign_in.verifier))));
1150 assert!(sign_in.url.ends_with("&login_hint=you%40example.org&prompt=select_account"), "{}", sign_in.url);
1151 assert!(sign_in.verifier.len() >= 43, "RFC 7636: 43 characters at least");
1152 assert!(!sign_in.url.contains("secret"), "no secret, ever");
1153 assert!(sign_in.listener.local_addr().unwrap().ip().is_ipv4(), "no IPv6 loopback for Microsoft");
1154 }
1155 assert_eq!(Authority::tenant(" Contoso.Example ").unwrap(), Some(Authority::Tenant("contoso.example".into())));
1157 assert_eq!(Authority::tenant("").unwrap(), None);
1158 for bad in ["evil.example/oauth", "x?y", "a@b", "../common", "-x"] {
1159 assert!(Authority::tenant(bad).is_err(), "{bad}");
1160 }
1161 }
1162
1163 #[test]
1167 fn microsofts_answer_ends_cleanly_in_the_browser() {
1168 for size in [800, 1060, 1500, 2200, 3000] {
1169 let sign_in = begin("id", &Authority::Common, "you@example.org").unwrap();
1170 let port = sign_in.listener.local_addr().unwrap().port();
1171 let target = format!("/?code=M.C5_BAY.2.U.{}&state={}", "x".repeat(400), sign_in.state);
1172 let browser = std::thread::spawn(move || crate::loopback::browser_answer(port, &target, size));
1173 assert!(wait_for_code(&sign_in, Duration::from_secs(10), &AtomicBool::new(false), None).unwrap().starts_with("M.C5_BAY.2.U."));
1174 let page = browser.join().unwrap();
1175 assert!(page.as_ref().is_ok_and(|p| p.starts_with("HTTP/1.1 200")), "{size} bytes: {page:?}");
1176 }
1177 }
1178
1179 #[test]
1182 fn microsoft_comes_back_here() {
1183 let sign_in = begin("id", &Authority::Common, "you@example.org").unwrap();
1184 let port = sign_in.listener.local_addr().unwrap().port();
1185 let state = sign_in.state.clone();
1186 let browser = std::thread::spawn(move || {
1187 let mut pages = Vec::new();
1188 for path in ["/favicon.ico".to_string(), "/?state=another&code=stolen".to_string(), "/?error=access_denied&state=another".to_string(), format!("/?code=M.C5_BAY.2.U.code&state={state}")] {
1189 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
1190 stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").as_bytes()).unwrap();
1191 let mut answer = String::new();
1192 let _ = stream.read_to_string(&mut answer);
1193 pages.push(answer);
1194 }
1195 pages
1196 });
1197 assert_eq!(wait_for_code(&sign_in, Duration::from_secs(10), &AtomicBool::new(false), None).unwrap(), "M.C5_BAY.2.U.code");
1198 let pages = browser.join().unwrap();
1199 assert!(pages[0].starts_with("HTTP/1.1 404"));
1200 assert!(pages[1].starts_with("HTTP/1.1 200") && pages[2].starts_with("HTTP/1.1 200"), "refused, said in the browser");
1201 let other = begin("id", &Authority::Common, "you@example.org").unwrap();
1203 assert_eq!(wait_for_code(&other, Duration::from_secs(10), &AtomicBool::new(true), None).err(), Some(SyncError::Said(CANCELLED, String::new())));
1204 let late = begin("id", &Authority::Common, "you@example.org").unwrap();
1205 let mut browser = std::net::TcpStream::connect(("127.0.0.1", late.listener.local_addr().unwrap().port())).unwrap();
1206 browser.write_all(format!("GET /?code=late&state={} HTTP/1.1\r\n\r\n", late.state).as_bytes()).unwrap();
1207 assert_eq!(wait_for_code(&late, Duration::ZERO, &AtomicBool::new(false), None).unwrap(), "late");
1208 let nothing = begin("id", &Authority::Common, "you@example.org").unwrap();
1209 assert_eq!(wait_for_code(¬hing, Duration::ZERO, &AtomicBool::new(false), None).err(), Some(SyncError::Said(TIMEOUT, String::new())));
1210 for (personal, said) in [(Some(true), DECLINED), (None, DECLINED_WORK)] {
1212 let declined = begin("id", &Authority::Common, "you@example.org").unwrap();
1213 let mut browser = std::net::TcpStream::connect(("127.0.0.1", declined.listener.local_addr().unwrap().port())).unwrap();
1214 let description = "AADSTS65004%3A+User+declined+to+consent+to+access+the+app.";
1215 browser.write_all(format!("GET /?error=access_denied&error_subcode=cancel&error_description={description}&state={} HTTP/1.1\r\n\r\n", declined.state).as_bytes()).unwrap();
1216 assert_eq!(wait_for_code(&declined, Duration::from_secs(10), &AtomicBool::new(false), personal).err(), Some(SyncError::Said(said, String::new())));
1217 }
1218 }
1219
1220 fn token_endpoint(answers: Vec<(&'static str, String)>) -> (String, std::thread::JoinHandle<Vec<String>>) {
1223 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1224 let port = listener.local_addr().unwrap().port();
1225 let server = std::thread::spawn(move || {
1226 let mut asked = Vec::new();
1227 for (status, answer) in answers {
1228 let (mut stream, _) = listener.accept().unwrap();
1229 let mut reader = BufReader::new(stream.try_clone().unwrap());
1230 let (mut length, mut line, mut head) = (0, String::new(), String::new());
1231 loop {
1232 line.clear();
1233 reader.read_line(&mut line).unwrap();
1234 head.push_str(&line);
1235 if line == "\r\n" {
1236 break;
1237 }
1238 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
1239 length = value.trim().parse().unwrap();
1240 }
1241 }
1242 let mut body = vec![0; length];
1243 reader.read_exact(&mut body).unwrap();
1244 asked.push(format!("{head}{}", String::from_utf8(body).unwrap()));
1245 stream.write_all(format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}", answer.len()).as_bytes()).unwrap();
1246 }
1247 asked
1248 });
1249 (format!("http://127.0.0.1:{port}/consumers/oauth2/v2.0/token"), server)
1250 }
1251
1252 #[test]
1256 fn the_code_brings_a_grant_of_the_right_kind() {
1257 let store = Memory::default();
1258 for (tid, personal, tenant, smtp) in [(CONSUMERS_TID, true, "consumers", SMTP_PERSONAL), ("0a1b2c3d-0000-4000-8000-00000000c0de", false, "0a1b2c3d-0000-4000-8000-00000000c0de", SMTP_WORK)] {
1259 let claims = format!(r#"{{"tid":"{tid}","preferred_username":"You@Example.org","email":"you@example.org"}}"#);
1260 let answer = format!(r#"{{"token_type":"Bearer","scope":"https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send","expires_in":4469,"access_token":"EwBIA.access","refresh_token":"M.C5_BAY.0.U.refresh","id_token":"{}"}}"#, id_token(&claims));
1261 let (endpoint, server) = token_endpoint(vec![("200 OK", answer)]);
1262 let sign_in = begin("app-id", &Authority::Common, "you@example.org").unwrap();
1263 let (port, state) = (sign_in.listener.local_addr().unwrap().port(), sign_in.state.clone());
1264 let (verifier, redirect) = (sign_in.verifier.clone(), sign_in.redirect.clone());
1265 let browser = std::thread::spawn(move || {
1266 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
1267 stream.write_all(format!("GET /?code=the-code&state={state} HTTP/1.1\r\n\r\n").as_bytes()).unwrap();
1268 let mut answer = String::new();
1269 let _ = stream.read_to_string(&mut answer);
1270 });
1271 let grant = finish_at(sign_in, "app-id", "You@Example.org", &AtomicBool::new(false), Some(&endpoint), &store).unwrap();
1272 browser.join().unwrap();
1273 let asked = server.join().unwrap();
1274 let body = asked[0].rsplit("\r\n\r\n").next().unwrap();
1275 assert_eq!(body, loopback::query(&[("client_id", "app-id"), ("scope", MAIL_SCOPES), ("code", "the-code"), ("redirect_uri", &redirect), ("grant_type", "authorization_code"), ("code_verifier", &verifier)]));
1276 assert!(!asked[0].contains("client_secret") && !asked[0].to_ascii_lowercase().contains("\norigin:"), "a public client: no secret, no Origin");
1277 assert_eq!((grant.personal, grant.tenant.as_str(), grant.signed_in_as.as_str()), (personal, tenant, "you@example.org"));
1278 assert_eq!(if grant.personal { Kind::Personal.smtp_host() } else { Kind::Work.smtp_host() }, smtp);
1279 let kept = load_from(&store, "microsoft-mail:you@example.org").unwrap().unwrap();
1281 assert_eq!(kept, grant);
1282 assert_eq!(remembered("microsoft-mail:you@example.org").as_deref(), Some("EwBIA.access"));
1283 assert!(!format!("{grant:?}").contains("M.C5_BAY"), "Debug never prints the token");
1284 }
1285 assert!(!gave_mail(&["https://outlook.office.com/IMAP.AccessAsUser.All".into(), "offline_access".into()]));
1287 assert!(gave_mail(&["IMAP.AccessAsUser.All".into(), "SMTP.Send".into()]), "as some answers write them");
1288 assert!(gave_mail(&[]), "no scope said: what was asked");
1289 }
1290
1291 #[test]
1294 fn refresh_tokens_rotate() {
1295 let store = Memory::default();
1296 let grant = Grant { client_id: "app-id".into(), tenant: "consumers".into(), personal: true, refresh_token: "RT1".into(), scopes: vec![], signed_in: 1, signed_in_as: "you@example.org".into() };
1297 save_in(&store, "microsoft-mail:you@example.org", &grant).unwrap();
1298 let (endpoint, server) = token_endpoint(vec![
1299 ("200 OK", r#"{"access_token":"AT1","expires_in":3600,"refresh_token":"RT2"}"#.into()),
1300 ("200 OK", r#"{"access_token":"AT2","expires_in":3600}"#.into()),
1301 ]);
1302 for expected in ["AT1", "AT2"] {
1303 let kept = load_from(&store, "microsoft-mail:you@example.org").unwrap().unwrap();
1304 assert_eq!(refreshed(&endpoint, &kept, &store, "microsoft-mail:you@example.org").unwrap(), (expected.to_string(), 3600));
1305 }
1306 let asked = server.join().unwrap();
1307 assert!(asked[0].ends_with(&loopback::query(&[("client_id", "app-id"), ("grant_type", "refresh_token"), ("refresh_token", "RT1"), ("scope", REFRESH_SCOPES)])), "{}", asked[0]);
1308 assert!(asked[1].contains("refresh_token=RT2"), "the newest sent: {}", asked[1]);
1309 let kept = load_from(&store, "microsoft-mail:you@example.org").unwrap().unwrap();
1310 assert_eq!((kept.refresh_token.as_str(), kept.signed_in), ("RT2", 1), "the newest kept, the sign-in's time with it");
1311 }
1312
1313 #[test]
1315 fn refusals_are_said_for_what_they_are() {
1316 let said = |error: &str, code: u64, personal: Option<bool>, step: Step| refusal(400, error, &[code], "", personal, step);
1317 let login = |id: &str| SyncError::Login(id.into());
1318 assert_eq!(said("invalid_grant", 50173, Some(true), Step::Refresh), login(AGAIN_PASSWORD));
1319 assert_eq!(said("invalid_grant", 700082, Some(true), Step::Refresh), login(AGAIN_UNUSED));
1320 assert_eq!(said("invalid_grant", 70008, Some(false), Step::Refresh), login(AGAIN_UNUSED));
1321 assert_eq!(said("invalid_grant", 70008, None, Step::Redeem), login(AGAIN_LATE), "a code a minute old");
1322 assert_eq!(said("invalid_grant", 54005, None, Step::Redeem), login(AGAIN_LATE));
1323 assert_eq!(said("invalid_grant", 70043, Some(false), Step::Refresh), login(AGAIN_FREQUENCY));
1324 assert_eq!(said("invalid_grant", 50076, Some(false), Step::Refresh), login(AGAIN));
1325 assert_eq!(said("invalid_grant", 90094, Some(false), Step::Redeem), login(ADMIN));
1326 assert_eq!(said("invalid_grant", 65001, Some(false), Step::Refresh), login(ADMIN));
1327 assert_eq!(said("invalid_grant", 65001, Some(true), Step::Refresh), login(AGAIN), "your own consent, withdrawn: give it again");
1328 assert_eq!(said("invalid_request", 53003, Some(false), Step::Refresh), login(BLOCKED));
1329 assert_eq!(said("invalid_request", 530035, Some(false), Step::Redeem), login(BLOCKED));
1330 assert_eq!(said("unauthorized_client", 700016, None, Step::Redeem), login(UNKNOWN_APP));
1331 assert_eq!(said("invalid_request", 50011, None, Step::Page), login(REDIRECT));
1332 assert_eq!(said("invalid_client", 7000218, None, Step::Redeem), login(CONFIDENTIAL));
1333 assert_eq!(said("invalid_request", 700025, None, Step::Redeem), login(SECRET_SENT));
1334 assert_eq!(said("invalid_request", 50194, None, Step::Page), login(ACCOUNT_TYPES));
1335 assert_eq!(said("access_denied", 65004, Some(true), Step::Page), SyncError::Said(DECLINED, String::new()));
1336 assert_eq!(refusal(400, "invalid_grant", &[], "AADSTS700082: The refresh token has expired due to inactivity.", Some(true), Step::Refresh), login(AGAIN_UNUSED));
1338 assert_eq!(refusal(400, "invalid_grant", &[], "", None, Step::Redeem), login(AGAIN_LATE));
1339 assert_eq!(refusal(503, "temporarily_unavailable", &[], "AADSTS90033: busy", None, Step::Refresh), SyncError::Server("Microsoft: 503 temporarily_unavailable AADSTS90033".into()));
1340 assert_eq!(refusal(0, "invalid_request", &[], "AADSTS90014: missing", None, Step::Page), SyncError::Server("Microsoft: invalid_request AADSTS90014".into()), "no status from Microsoft's page");
1341 for (id, expected) in [(AGAIN, "sign in"), (ADMIN, "administrator"), (BLOCKED, "rules"), (UNKNOWN_APP, "application"), (PASSWORD, "Sign in with Microsoft")] {
1343 let en = SyncError::Login(id.into()).sentence(&sioul_core::i18n::Translator::new("en"), "work");
1344 let fr = SyncError::Login(id.into()).sentence(&sioul_core::i18n::Translator::new("fr"), "travail");
1345 assert!(en.starts_with("work: ") && en.contains(expected) && !en.contains("microsoft-"), "{en}");
1346 assert!(fr.starts_with("travail") && !fr.contains("microsoft-") && fr != en, "{fr}");
1347 }
1348 for id in [AGAIN, AGAIN_PASSWORD, AGAIN_UNUSED, AGAIN_FREQUENCY, AGAIN_LATE, ADMIN, BLOCKED, UNKNOWN_APP, REDIRECT, CONFIDENTIAL, SECRET_SENT, ACCOUNT_TYPES, SCOPES_MISSING, PASSWORD] {
1349 assert!(message_of(id).is_some(), "{id}");
1350 assert!(SyncError::Login(id.into()).is_lasting(), "{id}: no retry hammers Microsoft");
1351 }
1352 }
1353
1354 fn long_token(length: usize) -> String {
1355 (0..length).map(|i| char::from(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!*$"[i % 68])).collect()
1356 }
1357
1358 fn work_grant(token: String) -> Grant {
1359 Grant { client_id: "app-id".into(), tenant: "0a1b2c3d-0000-4000-8000-00000000c0de".into(), personal: false, refresh_token: token, scopes: vec![IMAP_SCOPE.into(), SMTP_SCOPE.into()], signed_in: 1_760_000_000, signed_in_as: "you@example.org".into() }
1360 }
1361
1362 #[test]
1367 fn a_long_refresh_token_is_kept_in_parts() {
1368 let store = Memory::default();
1369 let name = "microsoft-mail:you@example.org";
1370 let grant = work_grant(long_token(4096));
1371 save_in(&store, name, &grant).unwrap();
1372 assert_eq!(load_from(&store, name).unwrap(), Some(grant.clone()));
1373 assert_eq!(part_names(&store, name).len(), 1, "the first entry and one part");
1374 let double = work_grant(long_token(8192));
1376 save_in(&store, name, &double).unwrap();
1377 assert_eq!((load_from(&store, name).unwrap(), part_names(&store, name).len()), (Some(double.clone()), 3));
1378 assert_eq!(store.0.lock().unwrap().len(), 5, "the parts of the token before are gone; its own noted beside");
1379 assert_eq!(generations(&store, name).unwrap(), [check_of(&double.refresh_token)]);
1380 assert!(save_in(&store, name, &work_grant(long_token(12288))).is_err());
1381 assert_eq!(load_from(&store, name).unwrap(), Some(double), "the one kept, kept");
1382 let short = work_grant("M.C5_BAY.short".into());
1384 save_in(&store, name, &short).unwrap();
1385 assert_eq!(load_from(&store, name).unwrap(), Some(short));
1386 assert_eq!(store.0.lock().unwrap().len(), 1, "no part left behind");
1387 save_in(&store, name, &grant).unwrap();
1389 let part = part_names(&store, name)[0].clone();
1390 store.set(&part, b"from another write").unwrap();
1391 assert_eq!(load_from(&store, name).unwrap(), None);
1392 save_in(&store, name, &grant).unwrap();
1394 store.forget(&part_names(&store, name)[0]).unwrap();
1395 assert_eq!(load_from(&store, name).unwrap(), None);
1396 save_in(&store, name, &work_grant("short".into())).unwrap();
1398 let mut kept: serde_json::Value = serde_json::from_slice(&store.get(name).unwrap().unwrap()).unwrap();
1399 kept.as_object_mut().unwrap().remove("check");
1400 store.set(name, &serde_json::to_vec(&kept).unwrap()).unwrap();
1401 assert_eq!(load_from(&store, name).unwrap(), None);
1402 save_in(&store, name, &grant).unwrap();
1404 forget_entries_in(&store, name).unwrap();
1405 assert!(store.0.lock().unwrap().is_empty());
1406 }
1407
1408 #[test]
1411 fn a_grant_of_the_first_format_is_read_and_renewed() {
1412 let store = Memory::default();
1413 let name = "microsoft-mail:you@example.org";
1414 let grant = work_grant(long_token(4096));
1415 save_in(&store, name, &grant).unwrap();
1416 let part = part_names(&store, name)[0].clone();
1418 let bytes = store.get(&part).unwrap().unwrap();
1419 store.forget(&part).unwrap();
1420 store.set(&format!("{name}#2"), &bytes).unwrap();
1421 assert_eq!(load_from(&store, name).unwrap(), Some(grant.clone()));
1422 let renewed = work_grant(long_token(4000));
1423 save_in(&store, name, &renewed).unwrap();
1424 assert_eq!(load_from(&store, name).unwrap(), Some(renewed));
1425 assert!(store.get(&format!("{name}#2")).unwrap().is_none(), "the first version's name, gone");
1426 }
1427
1428 struct Crossing<'a> {
1435 memory: &'a Memory,
1436 name: &'static str,
1437 between: Mutex<Option<Box<dyn FnOnce() + 'a>>>,
1438 after: Mutex<Option<Box<dyn FnOnce() + 'a>>>,
1439 refuse_part: bool,
1440 refuse_first: bool,
1441 reads: Mutex<u32>,
1442 miss_read: u32,
1443 written: Mutex<bool>,
1444 failing: Mutex<u32>,
1445 }
1446
1447 impl<'a> Crossing<'a> {
1448 fn over(memory: &'a Memory, name: &'static str) -> Crossing<'a> {
1449 Crossing {
1450 memory,
1451 name,
1452 between: Mutex::new(None),
1453 after: Mutex::new(None),
1454 refuse_part: false,
1455 refuse_first: false,
1456 reads: Mutex::new(0),
1457 miss_read: 0,
1458 written: Mutex::new(false),
1459 failing: Mutex::new(0),
1460 }
1461 }
1462 }
1463
1464 impl Store for Crossing<'_> {
1465 fn get(&self, name: &str) -> Result<Option<Vec<u8>>, SyncError> {
1466 if name == self.name && *self.written.lock().unwrap() {
1467 if let Some(after) = self.after.lock().unwrap().take() {
1468 after();
1469 }
1470 let mut failing = self.failing.lock().unwrap();
1471 if *failing > 0 {
1472 *failing -= 1;
1473 return Err(SyncError::Keyring("the stand-in's keyring did not answer".into()));
1474 }
1475 }
1476 if name == self.name {
1477 let mut reads = self.reads.lock().unwrap();
1478 *reads += 1;
1479 if *reads == self.miss_read {
1480 return Ok(None);
1481 }
1482 }
1483 self.memory.get(name)
1484 }
1485 fn set(&self, name: &str, value: &[u8]) -> Result<(), SyncError> {
1486 let first = name == self.name;
1487 if (first && self.refuse_first) || (!first && self.refuse_part) {
1488 return Err(SyncError::Keyring("refused by the stand-in".into()));
1489 }
1490 if first && let Some(between) = self.between.lock().unwrap().take() {
1491 between();
1492 }
1493 if first {
1494 *self.written.lock().unwrap() = true;
1495 }
1496 self.memory.set(name, value)
1497 }
1498 fn forget(&self, name: &str) -> Result<(), SyncError> {
1499 self.memory.forget(name)
1500 }
1501 }
1502
1503 #[test]
1508 fn a_rotation_never_loses_the_grant() {
1509 let name = "microsoft-mail:you@example.org";
1510 let old = work_grant(long_token(3000));
1511 let mine = work_grant(long_token(3100));
1512 let theirs = work_grant(long_token(3200));
1513 let kept = || {
1514 let memory = Memory::default();
1515 save_in(&memory, name, &old).unwrap();
1516 memory
1517 };
1518 let removed = |memory: &Memory| {
1520 forget_entries_in(memory, name).unwrap();
1521 assert!(memory.0.lock().unwrap().is_empty(), "left after removal: {:?}", memory.0.lock().unwrap().keys().collect::<Vec<_>>());
1522 };
1523 let memory = kept();
1525 let mut store = Crossing::over(&memory, name);
1526 store.between = Mutex::new(Some(Box::new(|| save_in(&memory, name, &theirs).unwrap())));
1527 save_in(&store, name, &mine).unwrap();
1528 assert_eq!(load_from(&memory, name).unwrap(), Some(mine.clone()), "the last one written, whole");
1529 removed(&memory);
1530 let memory = kept();
1532 let mut store = Crossing::over(&memory, name);
1533 store.after = Mutex::new(Some(Box::new(|| save_in(&memory, name, &theirs).unwrap())));
1534 save_in(&store, name, &mine).unwrap();
1535 assert_eq!(load_from(&memory, name).unwrap(), Some(theirs.clone()), "the last one written, whole");
1536 removed(&memory);
1537 let memory = kept();
1539 let mut store = Crossing::over(&memory, name);
1540 store.refuse_part = true;
1541 assert!(save_in(&store, name, &mine).is_err());
1542 assert_eq!(load_from(&memory, name).unwrap(), Some(old.clone()), "the one before, whole");
1543 assert_eq!(memory.0.lock().unwrap().len(), 3, "its first entry, its part, its generation noted");
1544 removed(&memory);
1545 let memory = kept();
1547 let mut store = Crossing::over(&memory, name);
1548 store.refuse_first = true;
1549 assert!(save_in(&store, name, &mine).is_err());
1550 assert_eq!(load_from(&memory, name).unwrap(), Some(old.clone()));
1551 assert_eq!(memory.0.lock().unwrap().len(), 3);
1552 removed(&memory);
1553 let memory = kept();
1556 let mut store = Crossing::over(&memory, name);
1557 store.miss_read = 2;
1558 assert!(save_in(&store, name, &mine).is_err());
1559 assert_eq!(load_from(&memory, name).unwrap(), Some(old.clone()), "the entry before, back");
1560 assert_eq!(memory.0.lock().unwrap().len(), 3);
1561 removed(&memory);
1562 let memory = kept();
1564 let store = Crossing::over(&memory, name);
1565 *store.failing.lock().unwrap() = 1;
1566 save_in(&store, name, &mine).unwrap();
1567 assert_eq!(load_from(&memory, name).unwrap(), Some(mine.clone()));
1568 assert_eq!(memory.0.lock().unwrap().len(), 3, "the one before gone");
1569 removed(&memory);
1570 let memory = kept();
1572 let store = Crossing::over(&memory, name);
1573 *store.failing.lock().unwrap() = 2;
1574 save_in(&store, name, &mine).unwrap();
1575 assert_eq!(load_from(&memory, name).unwrap(), Some(mine.clone()));
1576 assert_eq!(generations(&memory, name).unwrap(), [check_of(&old.refresh_token), check_of(&mine.refresh_token)]);
1577 save_in(&memory, name, &theirs).unwrap();
1578 assert_eq!(memory.0.lock().unwrap().len(), 4, "the next write forgets what it replaced; the older one, still noted, waits for removal");
1579 removed(&memory);
1580 }
1581
1582 #[cfg(windows)]
1590 #[test]
1591 fn windows_keeps_a_4_kb_token() {
1592 crate::secret::with_real_keyring(a_4_kb_token_through_the_system_keyring);
1593 }
1594
1595 #[cfg_attr(not(windows), allow(dead_code))]
1601 fn a_4_kb_token_through_the_system_keyring() {
1602 let login = format!("windows-test-{}@example.org", uuid::Uuid::new_v4());
1603 let long: String = (0..4096).map(|i| char::from(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!*$"[i % 68])).collect();
1604 let kept = Grant { client_id: "app-id".into(), tenant: "consumers".into(), personal: true, refresh_token: long, scopes: vec![], signed_in: 1, signed_in_as: login.clone() };
1605 let whole = keyring::Entry::new("sioul", &keyring_name(&login)).unwrap();
1607 assert!(whole.set_secret(&[b'x'; 2600]).is_err(), "Windows keeps at most 2560 bytes an entry");
1608 save_in(&Keyring, &keyring_name(&login), &kept).unwrap();
1609 let back = grant(&login);
1610 let names = part_names(&Keyring, &keyring_name(&login));
1611 assert!(!names.is_empty(), "a 4 KB token takes a part");
1612 forget(&login, true).unwrap();
1613 assert_eq!(back, Some(kept));
1614 assert_eq!(grant(&login), None, "forgotten, parts and all");
1615 for name in names.into_iter().chain([generations_name(&keyring_name(&login))]) {
1616 assert!(crate::secret::named_bytes(&name).unwrap().is_none(), "{name}: forgotten");
1617 }
1618 }
1619
1620 #[test]
1626 fn another_login_is_asked_and_never_shared() {
1627 use sioul_core::config::Account;
1628 let store = Memory::default();
1629 let grant = Grant { signed_in_as: "main@contoso.example".into(), ..work_grant("RT".into()) };
1630 let account = || Account::imap("work", "alias@contoso.example", IMAP_HOST, 993, sioul_core::config::Security::Tls, None);
1631 let address = "alias@contoso.example";
1632 let fresh = |store: &Memory| {
1633 store.0.lock().unwrap().clear();
1634 save_in(store, &keyring_name(address), &grant).unwrap();
1635 };
1636 fresh(&store);
1638 let (opened, other) = open_mailbox(&store, account(), &grant, address, None, &[], |_| Ok(())).unwrap();
1639 assert_eq!((opened.username, other), (None, None));
1640 fresh(&store);
1642 let opens_as_main = |a: &Account| if a.login() == Some("main@contoso.example") { Ok(()) } else { Err(SyncError::Login(AGAIN.into())) };
1643 let (opened, other) = open_mailbox(&store, account(), &grant, address, None, &[], opens_as_main).unwrap();
1644 assert_eq!((opened.username.as_deref(), other.as_deref()), (Some("main@contoso.example"), Some("main@contoso.example")));
1645 assert!(load_from(&store, &keyring_name("main@contoso.example")).unwrap().is_some() && load_from(&store, &keyring_name(address)).unwrap().is_none());
1646 fresh(&store);
1648 let (_, other) = open_mailbox(&store, account(), &grant, address, Some("main@contoso.example"), &[], opens_as_main).unwrap();
1649 assert_eq!(other, None);
1650 fresh(&store);
1652 let theirs = work_grant("THEIRS".into());
1653 save_in(&store, &keyring_name("main@contoso.example"), &theirs).unwrap();
1654 let refused = open_mailbox(&store, account(), &grant, address, None, &["main@contoso.example".into()], opens_as_main).err();
1655 assert_eq!(refused, Some(SyncError::Said(LOGIN_TAKEN, "main@contoso.example".into())));
1656 assert_eq!(load_from(&store, &keyring_name("main@contoso.example")).unwrap(), Some(theirs), "the other account's sign-in, untouched");
1657 assert!(load_from(&store, &keyring_name(address)).unwrap().is_none());
1658 fresh(&store);
1660 let refused = open_mailbox(&store, account(), &grant, address, None, &[], |_| Err(SyncError::Login(AGAIN.into()))).err();
1661 assert_eq!(refused, Some(SyncError::Said(OTHER_ACCOUNT, "main@contoso.example".into())));
1662 assert!(store.0.lock().unwrap().is_empty());
1663 assert_eq!(refuse_taken(" Alias@Contoso.example ", &["alias@contoso.example".into()]).err(), Some(SyncError::Said(LOGIN_TAKEN, "alias@contoso.example".into())));
1665 assert!(refuse_taken("alias@contoso.example", &["main@contoso.example".into()]).is_ok());
1666 let old = work_grant("OLD".into());
1668 let signed = |store: &Memory| {
1669 store.0.lock().unwrap().clear();
1670 save_in(store, &keyring_name("old@contoso.example"), &old).unwrap();
1671 save_in(store, &keyring_name("main@contoso.example"), &grant).unwrap();
1672 let mut account = account();
1673 account.username = Some("main@contoso.example".into());
1674 Signed { account, grant: grant.clone(), other_login: Some("main@contoso.example".into()), before: Some("old@contoso.example".into()) }
1675 };
1676 let declined = signed(&store).answer_in(&store, false).err();
1677 assert_eq!(declined, Some(SyncError::Said(OTHER_DECLINED, "main@contoso.example".into())));
1678 assert_eq!(load_from(&store, &keyring_name("old@contoso.example")).unwrap(), Some(old.clone()));
1679 assert!(load_from(&store, &keyring_name("main@contoso.example")).unwrap().is_none());
1680 let (kept, _) = signed(&store).answer_in(&store, true).unwrap();
1682 assert_eq!(kept.login(), Some("main@contoso.example"));
1683 assert!(load_from(&store, &keyring_name("old@contoso.example")).unwrap().is_none());
1684 assert_eq!(load_from(&store, &keyring_name("main@contoso.example")).unwrap(), Some(grant.clone()));
1685 }
1686
1687 #[test]
1689 fn closed_mailboxes_and_switched_off_sending_are_said() {
1690 assert_eq!(not_connected(true, "User is authenticated but not connected."), Some(SyncError::Said(NOT_CONNECTED_PERSONAL, "User is authenticated but not connected.".into())));
1691 assert_eq!(not_connected(false, "user is authenticated but not connected"), Some(SyncError::Said(NOT_CONNECTED_WORK, "user is authenticated but not connected".into())));
1692 assert_eq!(not_connected(true, "AUTHENTICATE failed."), None);
1693 let off = "535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Tenant. Visit https://aka.ms/smtp_auth_disabled for more information.";
1694 assert_eq!(smtp_refused(off), Some(SyncError::Said(SMTP_OFF, off.into())));
1695 assert_eq!(smtp_refused("535 5.7.3 Authentication unsuccessful"), None);
1696 let en = sioul_core::i18n::Translator::new("en");
1698 assert!(SyncError::Said(SMTP_OFF, off.into()).sentence(&en, "work").contains("administrator"));
1699 assert!(SyncError::Said(NOT_CONNECTED_PERSONAL, String::new()).sentence(&en, "home").contains("IMAP"));
1700 assert!(is_imap_host("outlook.office365.com") && is_imap_host("Outlook.Office365.com"));
1702 for host in ["outlook.office365.com.evil.example", "evil-outlook.office365.com", "imap.example.org", "outlook.office365.com."] {
1703 assert!(!is_imap_host(host), "{host}");
1704 }
1705 assert!(is_smtp_host("smtp-mail.outlook.com") && is_smtp_host("smtp.office365.com") && !is_smtp_host("smtp.office365.com.evil.example"));
1706 let url = admin_consent_url("app-id", "contoso.example");
1708 assert!(url.starts_with("https://login.microsoftonline.com/contoso.example/v2.0/adminconsent?client_id=app-id&scope=https%3A%2F%2Foutlook.office.com%2FIMAP.AccessAsUser.All%20"), "{url}");
1709 assert!(admin_consent_url("app-id", "x/../common").starts_with("https://login.microsoftonline.com/organizations/"));
1710 }
1711}