Skip to main content

sioul_sync/
microsoft.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Microsoft's mail: Outlook.com, Hotmail, Live and MSN (personal accounts),
5//! and Microsoft 365 (work or school accounts), over IMAP and SMTP signed in
6//! with OAuth 2.0 (docs/microsoft.md). Microsoft takes no password and no app
7//! password from mail programs any more.
8//!
9//! The sign-in is a native app's (RFC 8252): your browser opens on
10//! Microsoft's page, Microsoft sends its answer back to this device
11//! (`loopback.rs`, on 127.0.0.1 only: Microsoft takes no IPv6 loopback), and
12//! PKCE (RFC 7636) proves it is the same program asking. No client secret,
13//! ever: Sioul is a public client, and Microsoft refuses a secret from one.
14//! The application is Sioul's own when the build was given one
15//! (`SIOUL_MICROSOFT_CLIENT_ID`), else yours, registered once in Microsoft Entra.
16//!
17//! What stays: the grant (the application, the tenant, the refresh token), in
18//! the system keyring only, split over a few entries when it is longer than
19//! Windows keeps in one; the access token, in memory. Microsoft gives a new
20//! refresh token at each use and asks to keep the newest: it is written back
21//! each time. Microsoft offers no way for an app to give a token back:
22//! removing an account forgets it here, and you withdraw the consent on your
23//! account's page (`forget` says which).
24//!
25//! A token goes to Microsoft's sign-in (`login.microsoftonline.com`), and to
26//! the account's own mail servers, each named exactly (`is_imap_host`,
27//! `is_smtp_host`): never to a server a configuration names otherwise.
28
29use 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
40/// Microsoft's sign-in: its page, its token endpoint.
41pub const LOGIN: &str = "https://login.microsoftonline.com";
42/// The tenant every personal Microsoft account signs in to: an ID token
43/// whose `tid` is this one is a personal account's.
44pub const CONSUMERS_TID: &str = "9188040d-6c67-4c5b-b112-36a304b66dad";
45/// Reading mail over IMAP.
46pub const IMAP_SCOPE: &str = "https://outlook.office.com/IMAP.AccessAsUser.All";
47/// Sending over SMTP.
48pub const SMTP_SCOPE: &str = "https://outlook.office.com/SMTP.Send";
49/// What the sign-in asks, all at once: reading, sending, staying signed in,
50/// and who you are (no other permission: Microsoft's terms ask the fewest).
51pub const MAIL_SCOPES: &str = "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access openid email profile";
52/// What a refresh asks: the mail's alone (one resource per token).
53pub const REFRESH_SCOPES: &str = "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access";
54/// IMAP for every account, on 993, encrypted from the first byte.
55pub const IMAP_HOST: &str = MICROSOFT_IMAP;
56/// SMTP for personal accounts, on 587 with STARTTLS (Microsoft has no 465).
57pub const SMTP_PERSONAL: &str = MICROSOFT_SMTP_PERSONAL;
58/// SMTP for work and school accounts, on 587 with STARTTLS.
59pub const SMTP_WORK: &str = MICROSOFT_SMTP_WORK;
60pub const SMTP_PORT: u16 = 587;
61/// Where a personal account withdraws an app's access.
62pub const CONSENT_PERSONAL: &str = "https://account.live.com/consent/Manage";
63/// Where a work or school account reviews and removes the apps that have access.
64pub const CONSENT_WORK: &str = "https://myapps.microsoft.com";
65/// Microsoft's page on Outlook.com's IMAP switch (off by default) and its settings.
66pub const IMAP_HELP: &str = "https://support.microsoft.com/en-us/office/pop-imap-and-smtp-settings-for-outlook-com-d088b986-291d-42b8-9564-9c414e2aa040";
67/// Microsoft's page on Authenticated SMTP switched off by an organisation.
68pub const SMTP_AUTH_HELP: &str = "https://aka.ms/smtp_auth_disabled";
69
70// What Microsoft's refusals become (`SyncError::Login`, each said by its own
71// sentence: `message_of`): what to do, never a code alone.
72/// Microsoft asks you to sign in again (the access withdrawn, a second factor wanted…).
73pub const AGAIN: &str = "microsoft-again";
74/// The same after a password changed or reset.
75pub const AGAIN_PASSWORD: &str = "microsoft-again-password";
76/// The same after 90 days without use.
77pub const AGAIN_UNUSED: &str = "microsoft-again-unused";
78/// The same because your organisation asks you to sign in again every so often.
79pub const AGAIN_FREQUENCY: &str = "microsoft-again-frequency";
80/// Microsoft's answer reached Sioul after its code had ended (a code lasts about a minute).
81pub const AGAIN_LATE: &str = "microsoft-again-late";
82/// Your organisation must approve Sioul once.
83pub const ADMIN: &str = "microsoft-admin";
84/// Your organisation's rules block this app or this sign-in.
85pub const BLOCKED: &str = "microsoft-blocked";
86/// Microsoft does not know the application (a mistyped id, or a build given a wrong one).
87pub const UNKNOWN_APP: &str = "microsoft-unknown-app";
88/// The application has no `http://127.0.0.1` redirect under "Mobile and desktop applications".
89pub const REDIRECT: &str = "microsoft-redirect";
90/// The application's registration takes it for a confidential (web) client.
91pub const CONFIDENTIAL: &str = "microsoft-confidential";
92/// A secret was sent: a fault of Sioul's, never meant to happen.
93pub const SECRET_SENT: &str = "microsoft-secret-sent";
94/// The application's registration does not take this kind of account.
95pub const ACCOUNT_TYPES: &str = "microsoft-account-types";
96/// Microsoft's page gave reading or sending, not both.
97pub const SCOPES_MISSING: &str = "microsoft-scopes";
98/// A password refused by Microsoft's IMAP server, which takes only its own sign-in.
99pub const PASSWORD: &str = "microsoft-password";
100
101// What Sioul says itself (`SyncError::Said`, the message id and what it names).
102/// Cancel pressed while Microsoft's page was open.
103pub const CANCELLED: &str = "microsoft-cancelled";
104/// No answer from Microsoft's page within five minutes.
105pub const TIMEOUT: &str = "microsoft-timeout";
106/// You declined on Microsoft's page (a personal account).
107pub const DECLINED: &str = "microsoft-declined";
108/// Microsoft's page ended without access for a work account: declined, or an approval wanted.
109pub const DECLINED_WORK: &str = "microsoft-declined-work";
110/// Signed in, but the mailbox stays closed to IMAP: Outlook.com's switch, or an activity to confirm.
111pub const NOT_CONNECTED_PERSONAL: &str = "sync-said-microsoft-not-connected-personal";
112/// Signed in, but the mailbox stays closed to IMAP: the organisation's choice, or another account chosen.
113pub const NOT_CONNECTED_WORK: &str = "sync-said-microsoft-not-connected-work";
114/// The organisation switched off sending by mail programs (Authenticated SMTP).
115pub const SMTP_OFF: &str = "sync-said-microsoft-smtp-off";
116/// An account signed in with Microsoft whose configuration names another server: nothing sent there.
117pub const OTHER_HOST: &str = "sync-said-microsoft-other-host";
118/// The mailbox did not open with the account Microsoft's page signed in (that account follows).
119pub const OTHER_ACCOUNT: &str = "microsoft-other-account";
120/// The mailbox refused the access Microsoft had just given, or stayed closed to IMAP: its switch, most often.
121pub const MAILBOX_REFUSED: &str = "microsoft-mailbox-refused";
122/// Another of your accounts already signs in to Microsoft as this login (it follows): two
123/// accounts never share one sign-in, so that removing one cannot sign the other out.
124pub const LOGIN_TAKEN: &str = "microsoft-login-taken";
125/// You said No to the mailbox of the login Microsoft's page signed in (it follows): nothing kept.
126pub const OTHER_DECLINED: &str = "microsoft-other-declined";
127
128/// The message that says a `SyncError::Login` of this module ("sync-error-microsoft-again").
129pub(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
149/// Sioul's own application, when the build was given one
150/// (`SIOUL_MICROSOFT_CLIENT_ID`): an id, no secret (Microsoft refuses a
151/// secret from a program on your device). The same on every system, the
152/// phone's included. None: each person gives an application of their own.
153pub fn built_in() -> Option<&'static str> {
154    option_env!("SIOUL_MICROSOFT_CLIENT_ID").map(str::trim).filter(|id| !id.is_empty())
155}
156
157/// What discovery makes of an address before anyone signs in: a personal
158/// account's (Outlook.com's domains, or a domain whose mail Outlook.com
159/// receives), a work or school account's (Microsoft 365), or not known.
160/// The ID token's `tid` settles it at sign-in.
161#[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    /// The sending server of this kind (work's when not known: Microsoft 365's).
186    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/// Whose accounts the sign-in takes: the `{tenant}` of Microsoft's endpoints.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum Authority {
197    /// Personal Microsoft accounts only.
198    Consumers,
199    /// Work and school accounts only.
200    Organizations,
201    /// Either; Microsoft's page lets you choose.
202    Common,
203    /// One organisation's directory, by its id or one of its domains (an
204    /// application registered for that organisation alone needs it).
205    Tenant(String),
206}
207
208impl Authority {
209    /// The authority a kind of address signs in with.
210    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    /// A directory given by hand: its id (a GUID) or a domain, nothing else
219    /// (it goes into the path of Microsoft's address). None when empty.
220    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    /// The path segment.
229    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
239/// A directory's id or domain as Microsoft's paths take it: letters, digits,
240/// dots and hyphens, at most a domain's length; nothing that could reach
241/// another path or host.
242fn 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
246/// Whether an IMAP server is Microsoft's, named exactly: an access token is given to no other.
247pub fn is_imap_host(host: &str) -> bool {
248    host.eq_ignore_ascii_case(IMAP_HOST)
249}
250
251/// Whether an SMTP server is one of Microsoft's two, named exactly.
252pub 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/// What the keyring keeps for an address signed in with Microsoft.
257#[derive(Clone, Default, PartialEq, Eq)]
258pub struct Grant {
259    /// The application signed in with: Sioul's, or yours.
260    pub client_id: String,
261    /// Where refreshes go: `consumers` for a personal account, the
262    /// organisation's id (`tid`) for a work or school one.
263    pub tenant: String,
264    /// A personal account (Outlook.com, Hotmail, Live, MSN).
265    pub personal: bool,
266    pub refresh_token: String,
267    pub scopes: Vec<String>,
268    /// When you signed in on Microsoft's page, Unix seconds: kept through
269    /// refreshes, so that it marks a sign-in (`sasl::OAuth::mark`).
270    pub signed_in: i64,
271    /// Who Microsoft's page signed in: its address (the ID token's `email`,
272    /// else `preferred_username`); maybe not the address given (an alias).
273    pub signed_in_as: String,
274}
275
276/// Printed without the refresh token, should it ever be.
277impl 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/// The grant as the keyring keeps it: the refresh token's first part in
291/// this entry, the rest in `parts` more, and the start of the whole token's
292/// SHA-256, its `check`. The parts are named after the check
293/// (`<name>#<check>#2`, `#3`…): each token's parts are its own, written before
294/// the entry that names them, so that a rotation cut short, or two crossing,
295/// never leave this entry naming parts it does not have (the review of 15
296/// October 2026, L2); every generation with parts is noted beside it until
297/// its parts are gone (`generations`), so that removal forgets them all. A
298/// grant without its check is no grant.
299#[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
314/// At most this many entries after the first: a token of about 9 KB, several times the longest reported.
315const MOST_PARTS: usize = 3;
316
317/// Its entry in the keyring: `microsoft-mail:<login>`, the login lowercased.
318fn keyring_name(login: &str) -> String {
319    format!("microsoft-mail:{}", login.trim().to_lowercase())
320}
321
322/// Where grants are kept: the system keyring, or, in tests, memory.
323trait 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
329/// The system keyring, through `secret.rs`.
330struct 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
349/// A part's name: the entry's, the check of the token it belongs to, its place (2, 3, 4).
350fn part_name(name: &str, check: &str, index: usize) -> String {
351    format!("{name}#{check}#{index}")
352}
353
354/// A part's name as the first version of this format had it (`<name>#2`…),
355/// before parts were named after their token: read when the named one is
356/// missing, and forgotten at the next write.
357fn legacy_part_name(name: &str, index: usize) -> String {
358    format!("{name}#{index}")
359}
360
361/// What the first entry under `name` says, read as is.
362fn 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/// The names of the parts the first entry under `name` holds (the
367/// generation in use); none when it holds the whole token, or there is none.
368#[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
373/// Forgets a generation's parts: whether every one is gone.
374fn 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
378/// The checks of the generations whose parts may still be in the keyring,
379/// the one in use included (`<name>#generations`): each write notes its
380/// own before it writes a part, and the one it replaces; one is struck off
381/// only once its parts are gone. Two rotations crossing (the window and a
382/// command, each renewing) can leave a generation that no first entry names
383/// any more: removal still finds it here (the re-check of 15 October 2026,
384/// L2-bis). A short token has no part, and is not noted.
385fn generations_name(name: &str) -> String {
386    format!("{name}#generations")
387}
388
389/// At most this many generations noted: past them, the oldest are dropped
390/// (a crossing leaves one, at most, and each write strikes off what it replaced).
391const 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
397/// Notes a generation that has, or is about to have, parts. Read once more
398/// after a passing error; not noted when the keyring does not answer.
399fn 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(&noted) {
408        let _ = store.set(&generations_name(name), &bytes);
409    }
410}
411
412/// Strikes off generations whose parts are gone; the list itself goes with the last.
413fn 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(&noted) {
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
433/// A grant as the keyring keeps it: the first entry at most
434/// `secret::ENTRY_BYTES` long, the token's rest in parts as long.
435fn 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    // What the first entry holds besides the token, with room for a part count.
451    let mut first = token.len().min(limit.saturating_sub(json(&kept)?.len()));
452    // A token with characters JSON escapes takes more room than its length: less of it, then.
453    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
481/// Writes a grant under `name` without ever leaving it lost: the new token's
482/// parts first, under names of their own, noted beside it (`generations`);
483/// then the first entry, which names them (the pointer); then it is read
484/// back whole before the generation it replaced is forgotten. A write that
485/// fails takes back what it wrote, and the grant kept before stays whole;
486/// one that does not read back whole puts the entry it replaced back. A
487/// read-back that the keyring does not answer, twice, leaves everything as
488/// written: what it replaced, noted, goes at the next write or at removal.
489/// Two writes crossing (the window and a command, each renewing the token)
490/// leave the one written last, whole, and every part of the other noted.
491fn 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    // Undoes this write's parts, struck off once gone.
495    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    // What the pointer names until now, read the moment before it is replaced: its generation goes once this one is in place, and is noted meanwhile.
510    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    // Read back; once more after a passing error, which is not a grant "not whole".
521    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        // This one, whole: the generation it replaced goes, and the first version's names.
530        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        // Another process wrote after this one, whole: this one's parts serve nothing.
540        Some(_) => {
541            take_back();
542            Ok(())
543        }
544        // Not whole: the entry it replaced comes back, whose parts were never touched.
545        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
555/// The grant kept under `name`, its token joined again from its parts; None
556/// when there is none, or when its parts do not make the token it was:
557/// then you sign in again, rather than Sioul sending a token that is not one.
558fn 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            // The first version's name, checked the same.
569            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
590/// The grant kept on this device for a login (the account's address, or the
591/// user name its mailbox opened with).
592pub fn grant(login: &str) -> Option<Grant> {
593    load_from(&Keyring, &keyring_name(login)).ok().flatten()
594}
595
596/// An application of your own kept on this device for an address (its id,
597/// and the directory refreshes go to when it was given one): to sign in
598/// with again. Sioul's own is not "yours".
599pub 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
603/// A sign-in under way: the page to open, and the door Microsoft comes back to.
604pub struct SignIn {
605    listener: TcpListener,
606    verifier: String,
607    state: String,
608    redirect: String,
609    authority: Authority,
610    /// The page to open in your browser.
611    pub url: String,
612}
613
614/// Opens the door on this device (127.0.0.1 only) and makes Microsoft's page
615/// to open: PKCE's challenge (S256), a random state, your address as a hint,
616/// and the account chooser, since a browser often holds more than one
617/// Microsoft account, and a personal and a work account may share an address.
618pub 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/// When in the sign-in a refusal came: it says different things.
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644enum Step {
645    /// Microsoft's page sent you back with an error.
646    Page,
647    /// The code traded for tokens.
648    Redeem,
649    /// A refresh token traded for an access token.
650    Refresh,
651}
652
653/// The first `AADSTS<digits>` of Entra's description.
654fn 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
660/// What Microsoft's refusal means, as Sioul says it: OAuth's `error`,
661/// Entra's codes (`error_codes`, else the description's AADSTS), the
662/// account's kind when known, and the step.
663fn 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        // A second factor asked: only Microsoft's page can.
672        (_, 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        // Its words as they came: the status (none from Microsoft's page), the error, the code.
685        _ => {
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
693/// Posts a form to Microsoft's token endpoint (or a stand-in in tests):
694/// HTTPS only, no redirect followed (a refresh token goes nowhere else), no
695/// secret. The answer's JSON, else its refusal said (`refusal`).
696fn 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
712/// What the ID token says, from its payload (it came straight from
713/// Microsoft's token endpoint over TLS; Sioul only reads who signed in, and
714/// the server's own login decides): `tid`, and the address.
715fn 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    // An address: `email`, else `preferred_username` when it is one (a phone number is not).
720    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
724/// Whether Microsoft gave what mail needs: reading and sending. An answer
725/// without its scopes gives what was asked (Microsoft says so).
726fn 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
735/// The answer Microsoft's page sends back to this device: its code, checked
736/// against the state. An answer whose state is not this sign-in's is
737/// refused, and the wait goes on: a page from an earlier sign-in, or anyone
738/// else, cannot end it. An error is read only from an answer whose state is.
739fn 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        // On a phone, the way back to Sioul, whose code lasts a minute.
743        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
752/// Waits for Microsoft's answer (five minutes at most, or until `stop`),
753/// trades its code for tokens at once (a code lasts about a minute), reads
754/// who signed in and whether the account is a personal or a work one, and
755/// keeps the grant in the keyring under `address`. The access token is kept
756/// in memory. Who signed in is not checked against the address here: an
757/// alias, a shared mailbox, a phone number's sign-in differ; the mail
758/// server's own login decides (the window's `outlook.rs`).
759pub 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
763/// `finish`, the code traded at `token_url` (Microsoft's, or a stand-in in
764/// tests), the grant kept in `store`.
765fn 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    // Personal or work: the token's tenant says; without one, the authority asked.
786    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
803/// The grant kept for `from` moved to `to` (the user name a mailbox opened
804/// with, when it is not the address given), its access token with it.
805pub 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
823/// Access tokens, by grant (its keyring name), until about when they end; in memory only.
824static 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
836/// The access token kept in memory under a grant's name, while it lasts.
837fn 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
841/// A local stand-in's token, in test builds only (`insecure-test-tls`, never
842/// in Sioul's builds): with it, an account signed in with Microsoft may be
843/// pointed at a local IMAP and SMTP server for a test (`OAuth::may_reach`).
844pub(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
852/// An access token for a login's mail (IMAP and SMTP alike): the one in
853/// memory while it lasts, else a new one from the refresh token, whose
854/// successor is kept. `fresh` asks a new one (after a refusal).
855pub 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    // No grant kept: Microsoft's page again.
864    let grant = load_from(&Keyring, &key)?.ok_or_else(|| SyncError::Login(AGAIN.into()))?;
865    // The directory kept, checked as a typed one is: it goes into the path of Microsoft's address.
866    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
873/// A new access token traded for a grant's refresh token at `endpoint`, and
874/// how long it lasts, in seconds. Microsoft's answer brings a new refresh
875/// token: kept in `store` in place of the old one (Microsoft asks it; the old
876/// one keeps working until its own end, so a write that fails is said and
877/// does not stop the mail, and two of Sioul's processes refreshing at once
878/// do not lock each other out: the last one written stays).
879fn 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
900/// Forgets the grant under `name`: its parts (the generation in use, every
901/// generation noted beside it, and the first version's names), then the
902/// first entry; and its access token.
903fn 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    // Every generation noted, a crossing's left behind included; the list
913    // kept when the keyring does not answer it, twice, rather than lost.
914    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
924/// Forgets a login's grant on this device, keyring and memory. Microsoft is
925/// not asked anything (it offers apps no way to give a token back): the
926/// page where you withdraw Sioul's access comes back, a personal account's
927/// or a work account's.
928pub 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
933/// Whether a text is an application's id as Microsoft gives them: a GUID
934/// (`00000000-0000-0000-0000-000000000000`). Anything else (a secret pasted
935/// in its place) is refused before Microsoft is asked.
936pub 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
941/// The application to sign in with: the id given (yours), else yours kept
942/// on this device for this address, else Sioul's own. None when this build
943/// has none and none is given or kept: then each person gives their own.
944pub 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
952/// The authority a sign-in goes to: the directory given (an application of
953/// your own registered for one organisation), else the kept grant's (a
954/// personal account's, or its organisation's), else the kind discovery found.
955pub 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
966/// What a sign-in made of an account, kept only once answered (`answer`).
967pub struct Signed {
968    /// The account, at Microsoft's servers, signed in with Microsoft.
969    pub account: sioul_core::config::Account,
970    pub grant: Grant,
971    /// The mailbox opened only under this login, the one Microsoft's page
972    /// signed in, not the address given (an alias's mailbox, or another of
973    /// your accounts chosen on Microsoft's page): never kept silently. The
974    /// caller names both addresses and asks first. None: the address given
975    /// opened, or the account already signed in as that login.
976    pub other_login: Option<String>,
977    /// The login the account signed in as before, when another: its grant
978    /// is forgotten once this sign-in is kept.
979    before: Option<String>,
980}
981
982impl Signed {
983    /// Your answer to `other_login`'s question (Yes when none was asked).
984    /// Yes: the account as signed in, the grant it had before under another
985    /// login forgotten. No: the grant just kept forgotten, the account as it
986    /// was, `OTHER_DECLINED` said.
987    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
1004/// Refuses a login that another of your accounts already signs in to
1005/// Microsoft as (`taken`: their logins, lowercased): each grant is kept
1006/// under its login, and two accounts sharing one would sign each other out.
1007fn 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
1011/// Signs an address's mail in with Microsoft, or signs it in again:
1012/// Microsoft's page (`show` opens it: the window's browser, the terminal's
1013/// line), its answer, then the mailbox itself tried with the access given,
1014/// before anything is kept (`open_mailbox`). `account` comes back at
1015/// Microsoft's servers, signed in with Microsoft, its sending server and its
1016/// kind those the sign-in found. `taken`: the logins your other Microsoft
1017/// accounts sign in as, refused here (the address before Microsoft's page,
1018/// the login it signed in after). A mailbox that opens only under another
1019/// login than the address comes back with `other_login`, for the person's
1020/// Yes first; every sign-in ends with its answer (`Signed::answer`). When
1021/// the mailbox does not open, nothing is kept.
1022pub 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    // The address is where the new grant is first kept: never over another account's.
1027    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    // Its kind, from the ID token's tenant, kept apart from the sending server (`Account::is_microsoft_work`).
1040    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
1045/// The mailbox tried with the grant just kept under `address`, before
1046/// anything else is kept: as the address; refused there (the token refused,
1047/// or IMAP closed), as the login Microsoft's page signed in, once, the grant
1048/// moved there first, unless another account already uses it. Comes back
1049/// with the account and, when it opened only under that other login (and
1050/// the account did not already sign in as it, `before`), that login, for
1051/// the person's Yes. Nothing kept when it does not open.
1052fn 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    // The mailbox's own refusals: the token refused, or IMAP closed. Anything else is said as it is.
1055    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        // Refused as the address: as the account Microsoft's page signed in, once.
1060        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            // Moved, or forgotten: never a grant left behind under the address.
1066            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            // Asked once, when it was first kept: not again at a sign-in again.
1073            (before != Some(other.as_str())).then(|| other.clone())
1074        }
1075        // Nothing kept: the mailbox's switch, then a sign-in again.
1076        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
1084/// The link an organisation's administrator opens to approve Sioul for
1085/// everyone there, once: Microsoft's admin consent, for the organisation's
1086/// directory (its id, or the domain of the address; never `common`).
1087pub 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
1092/// Microsoft's IMAP refusal that is not about the token: "User is
1093/// authenticated but not connected", the mailbox closed to IMAP. Said for
1094/// what it is, with what to do; no second token is asked.
1095pub(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
1102/// Microsoft's SMTP refusal that no token changes: Authenticated SMTP
1103/// switched off for the organisation or the mailbox (`5.7.139`).
1104pub(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    /// The keyring, stood in for in memory: no test reaches the system's.
1115    #[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    /// Part 8, 1: Microsoft's page, as the design asks it.
1138    #[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        // A directory given by hand: an id or a domain, nothing that reaches another path.
1156        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    /// The re-check of 15 October 2026, L1-bis: Microsoft's answer as a
1164    /// browser sends it, with its headers (800 to 3,000 bytes), ends in its
1165    /// page and an orderly close, never a reset.
1166    #[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    /// Part 8, 2: the door. A favicon first; an answer of another sign-in
1180    /// refused while the wait goes on; Cancel; an answer read late; a refusal.
1181    #[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        // Cancel ends the wait; so does the time, an answer waiting being read first.
1202        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(&nothing, Duration::ZERO, &AtomicBool::new(false), None).err(), Some(SyncError::Said(TIMEOUT, String::new())));
1210        // Declined on Microsoft's page: said as such, a work account's with the approval it may have wanted.
1211        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    /// A stand-in for Microsoft's token endpoint: each answer in turn; what
1221    /// was asked comes back.
1222    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    /// Part 8, 3: the code traded for tokens, as a public client does: the
1253    /// verifier, the same redirect, the scopes, no secret. The ID token's
1254    /// tenant tells a personal account (Outlook.com's SMTP) from a work one.
1255    #[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            // Kept under the address, lowercased; the access token in memory.
1280            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        // Reading without sending, or the reverse, is not mail.
1286        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    /// Part 8, 4: each refresh brings a new refresh token, kept in place of
1292    /// the old one and sent the next time; an answer without one keeps it.
1293    #[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    /// Part 8, 5: Microsoft's refusals, each said by its sentence.
1314    #[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        // The code read from the description when no list comes.
1337        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        // Each has its sentence, in both languages.
1342        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    /// Part 8, 6: a 4 KB refresh token (longer than any seen) kept in parts,
1363    /// each within what Windows keeps (the stand-in refuses more), joined
1364    /// again whole; a shorter one after it leaves no part behind; parts that
1365    /// do not make the token give no grant, nor does an entry without its check.
1366    #[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        // Twice as long: three parts; a token longer than Sioul keeps is refused, not cut.
1375        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        // Shorter: one entry, the parts gone.
1383        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        // A part from another write: no grant, rather than a token that is not one.
1388        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        // A part missing: the same.
1393        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        // An entry that does not say its check: no grant either (never joined unchecked).
1397        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        // Forgotten: the entry and its parts, nothing left.
1403        save_in(&store, name, &grant).unwrap();
1404        forget_entries_in(&store, name).unwrap();
1405        assert!(store.0.lock().unwrap().is_empty());
1406    }
1407
1408    /// The first version's part names (`<name>#2`, before parts were named
1409    /// after their token) are read, checked the same, and gone at the next write.
1410    #[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        // Rewritten as the first version wrote it: its part under `#2`.
1417        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    /// A store standing in for the keyring, around the memory one: it runs
1429    /// another process's whole rotation once, the moment this one is about to
1430    /// write its first entry (`between`), or once it has written it and is
1431    /// about to read it back (`after`); or it refuses a part, or the first
1432    /// entry; or, once, reads the first entry back as missing; or fails the
1433    /// first entry's reads after it was written, so many times (`failing`).
1434    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    /// The review of 15 October 2026, L2: two rotations crossing (another
1504    /// process's whole rotation between this one's parts and its first
1505    /// entry), and rotations whose writes fail at each step, never lose the
1506    /// grant: the last one written stays whole, or the one before does.
1507    #[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        // After each, removal leaves nothing: no part of any token, crossing's included (the re-check, L2-bis).
1519        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        // Crossing: theirs, written whole between my parts and my first entry; mine, written last, stays whole.
1524        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        // The other order: theirs, written whole after my first entry, before I read it back; theirs stays whole.
1531        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        // A part refused: nothing changes, nothing of mine is left.
1538        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        // The first entry refused, after my parts: the one before stays, my parts go.
1546        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        // Written, but not read back whole (the second read of the first entry,
1554        // after the one that names what it replaces): the entry before comes back.
1555        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        // The keyring not answering the read-back once: read again, and the rotation goes through.
1563        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        // Not answering twice: nothing undone, mine in place (never "not whole"), the one before noted for later.
1571        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    /// Part 8, 6, in the system's own keyring: Windows' Credential Manager
1583    /// keeps 2560 bytes an entry, which a Microsoft grant passes. Run in the
1584    /// Windows job of the CI (build.yml tests sioul-sync), under a name of
1585    /// its own, removed after; never on another system, whose keyring is the
1586    /// person's own (the round trip above covers the splitting everywhere).
1587    /// The keyring reached on purpose, whatever `no-keyring` a command
1588    /// testing several crates turns on (`secret::with_real_keyring`).
1589    #[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    /// The Windows test's body, compiled on every system so that a slip in
1596    /// it (a name shadowed, a type changed) fails every build, not only
1597    /// Windows' (the review of 15 October 2026: a binding named `grant` hid
1598    /// the function `grant()`, and only the Windows job saw it); run on
1599    /// Windows alone.
1600    #[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        // As one entry it does not fit: refused at Windows' limit (`CRED_MAX_CREDENTIAL_BLOB_SIZE`).
1606        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    /// The review of 15 October 2026, L3: a mailbox that opens only under
1621    /// the login Microsoft's page signed in comes back for the person's Yes,
1622    /// never kept silently (unless the account already used it); a login
1623    /// another account uses is refused, its grant untouched; nothing is kept
1624    /// when the mailbox does not open.
1625    #[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        // Opens as the address: nothing asked.
1637        fresh(&store);
1638        let (opened, other) = open_mailbox(&store, account(), &grant, address, None, &[], |_| Ok(())).unwrap();
1639        assert_eq!((opened.username, other), (None, None));
1640        // Opens only as the login Microsoft signed in: asked, the grant moved there.
1641        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        // Signed in again, the account already under that login: not asked again.
1647        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        // That login is another account's: refused, its grant untouched, nothing of this one kept.
1651        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        // Opens under neither: nothing kept.
1659        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        // The address itself another account's: refused before Microsoft's page.
1664        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        // The answer. No: the new sign-in forgotten, the one the account had before under another login kept.
1667        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        // Yes: kept, the one before forgotten.
1681        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    /// Part 8, 8 and 9: Microsoft's refusals that no token changes.
1688    #[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        // Said in both languages, with what to do.
1697        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        // Only Microsoft's servers, named exactly, are given a token.
1701        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        // The administrator's link: the organisation's directory, never `common`.
1707        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}