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}