Skip to main content

pidge_client/auth/
config.rs

1//! Compile-time constants and runtime overrides for the pidge OAuth app.
2
3/// The pidge app's `client_id` in Microsoft Entra.
4///
5/// Empty string means "not yet provisioned". Set by `scripts/register-pidge-app.sh`
6/// after registering the app in Entra. Until then, set the `PIDGE_CLIENT_ID` env var
7/// for development.
8pub const APP_CLIENT_ID: &str = "e49f90dc-c265-4392-b62f-b26704f9088f";
9
10/// Microsoft Graph delegated scopes pidge requests at sign-in.
11/// Locked in at app registration time; changing them later requires updating
12/// the Entra app permissions AND triggering incremental consent on existing
13/// accounts.
14///
15/// `openid` is included so Microsoft returns an `id_token` from the token
16/// endpoint; we decode it (no signature check) to extract the user's tenant
17/// for the Account record. `profile` is harmless and is what MSAL clients
18/// always request alongside `openid`.
19pub const SCOPES: &[&str] = &[
20    "openid",
21    "profile",
22    "offline_access",
23    "User.Read",
24    "Mail.ReadWrite",
25    "Mail.Send",
26    "Calendars.ReadWrite",
27    "MailboxSettings.ReadWrite",
28];
29
30/// Scopes added to [`SCOPES`] after accounts were already signed in.
31///
32/// A refresh may only ask for scopes the account consented to: Microsoft
33/// refuses a refresh that asks for more (`invalid_grant`, consent required),
34/// which pidge reads as an expired session, signing the account out. So a
35/// refresh asks for one of these only when the token's own grant names it
36/// (see [`refresh_scope_string`]); only an interactive sign-in asks for them
37/// outright, which is where the user can consent.
38pub const LATER_SCOPES: &[&str] = &["MailboxSettings.ReadWrite"];
39
40/// Microsoft identity platform endpoints (common = multi-tenant + personal MSA).
41pub const AUTHORITY: &str = "https://login.microsoftonline.com/common";
42pub const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode";
43pub const TOKEN_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
44
45/// Microsoft Graph base URL.
46pub const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
47
48/// Resolved client_id: env var wins, otherwise the compile-time constant (if non-empty).
49pub fn client_id() -> Option<String> {
50    if let Ok(v) = std::env::var("PIDGE_CLIENT_ID")
51        && !v.is_empty()
52    {
53        return Some(v);
54    }
55    if APP_CLIENT_ID.is_empty() {
56        None
57    } else {
58        Some(APP_CLIENT_ID.to_string())
59    }
60}
61
62/// The space-separated scope string sent to Microsoft.
63pub fn scope_string() -> String {
64    SCOPES.join(" ")
65}
66
67/// Whether a granted scope string (as Microsoft returns it: space-separated,
68/// with or without the `https://graph.microsoft.com/` prefix) includes `scope`.
69pub fn grants(granted: &str, scope: &str) -> bool {
70    granted.split_whitespace().any(|g| {
71        g.rsplit('/')
72            .next()
73            .unwrap_or(g)
74            .eq_ignore_ascii_case(scope)
75    })
76}
77
78/// The scope string to refresh a token with: `requested` (the sign-in
79/// scopes) without any of [`LATER_SCOPES`] the token's grant lacks. A token
80/// stored before grants were recorded (`granted` is `None`) predates every
81/// later scope, so it is refreshed with the original set.
82pub fn refresh_scope_string(requested: &str, granted: Option<&str>) -> String {
83    requested
84        .split_whitespace()
85        .filter(|s| {
86            !LATER_SCOPES.iter().any(|l| l.eq_ignore_ascii_case(s))
87                || granted.is_some_and(|g| grants(g, s))
88        })
89        .collect::<Vec<_>>()
90        .join(" ")
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn grants_reads_microsofts_scope_spellings() {
99        let g = "openid https://graph.microsoft.com/Mail.ReadWrite https://graph.microsoft.com/MailboxSettings.ReadWrite";
100        assert!(grants(g, "MailboxSettings.ReadWrite"));
101        assert!(grants(
102            "mailboxsettings.readwrite",
103            "MailboxSettings.ReadWrite"
104        ));
105        assert!(!grants(g, "Calendars.ReadWrite"));
106    }
107
108    #[test]
109    fn a_refresh_never_asks_for_a_later_scope_the_token_was_not_granted() {
110        let all = scope_string();
111        assert!(all.contains("MailboxSettings.ReadWrite"));
112        // A token from before the scope existed: refreshed with the old set.
113        let old = refresh_scope_string(&all, None);
114        assert!(!old.contains("MailboxSettings"), "{old}");
115        assert!(old.contains("Mail.ReadWrite") && old.contains("offline_access"));
116        let old = refresh_scope_string(&all, Some("User.Read Mail.ReadWrite Mail.Send"));
117        assert!(!old.contains("MailboxSettings"), "{old}");
118        // Granted at a later sign-in: kept.
119        let new = refresh_scope_string(
120            &all,
121            Some("https://graph.microsoft.com/MailboxSettings.ReadWrite"),
122        );
123        assert_eq!(new, all);
124    }
125}