tenseleyflow/rcal / f21746c

Browse files

Add Microsoft auth diagnostics

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f21746cb63806c3953df58efc60ff4aef10e653f
Parents
48e8ce6
Tree
a7fc3ee

2 changed files

StatusFile+-
M src/cli.rs 99 1
M src/providers.rs 194 3
src/cli.rsmodified
@@ -28,7 +28,7 @@ use crate::{
2828
     },
2929
     providers::{
3030
         KeyringMicrosoftTokenStore, MicrosoftProviderConfig, MicrosoftProviderRuntime,
31
-        ProviderConfig, ProviderError, ReqwestMicrosoftHttpClient, list_calendars,
31
+        ProviderConfig, ProviderError, ReqwestMicrosoftHttpClient, inspect_token, list_calendars,
3232
         login_device_code_or_browser, logout,
3333
     },
3434
     reminders::{
@@ -54,6 +54,7 @@ const HELP: &str = concat!(
5454
     "  rcal config init [--path PATH] [--force]\n\n",
5555
     "  rcal providers microsoft auth login --account ID [--browser]\n",
5656
     "  rcal providers microsoft auth logout --account ID\n",
57
+    "  rcal providers microsoft auth inspect --account ID\n",
5758
     "  rcal providers microsoft calendars list --account ID\n",
5859
     "  rcal providers microsoft sync [--account ID]\n",
5960
     "  rcal providers microsoft status\n\n",
@@ -154,6 +155,9 @@ pub enum MicrosoftCliAction {
154155
     AuthLogout {
155156
         account: String,
156157
     },
158
+    AuthInspect {
159
+        account: String,
160
+    },
157161
     CalendarsList {
158162
         account: String,
159163
         config: MicrosoftProviderConfig,
@@ -841,6 +845,12 @@ where
841845
                 MicrosoftCliAction::AuthLogout { account },
842846
             )))
843847
         }
848
+        "inspect" => {
849
+            let account = parse_required_account(args)?;
850
+            Ok(CliAction::Providers(ProviderCliAction::Microsoft(
851
+                MicrosoftCliAction::AuthInspect { account },
852
+            )))
853
+        }
844854
         _ => Err(CliError::UnknownProviderCommand(format!(
845855
             "microsoft auth {command}"
846856
         ))),
@@ -1244,6 +1254,71 @@ fn run_microsoft_action(
12441254
         MicrosoftCliAction::AuthLogout { account } => logout(&account, &token_store).map(|()| {
12451255
             let _ = writeln!(stdout, "removed Microsoft credentials for '{account}'");
12461256
         }),
1257
+        MicrosoftCliAction::AuthInspect { account } => {
1258
+            inspect_token(&account, &token_store).map(|inspection| {
1259
+                let _ = writeln!(
1260
+                    stdout,
1261
+                    "account={} authenticated=true",
1262
+                    inspection.account_id
1263
+                );
1264
+                let _ = writeln!(
1265
+                    stdout,
1266
+                    "aud={}",
1267
+                    inspection.audience.as_deref().unwrap_or("<missing>")
1268
+                );
1269
+                let _ = writeln!(
1270
+                    stdout,
1271
+                    "scp={}",
1272
+                    inspection.scopes.as_deref().unwrap_or("<missing>")
1273
+                );
1274
+                let _ = writeln!(
1275
+                    stdout,
1276
+                    "roles={}",
1277
+                    if inspection.roles.is_empty() {
1278
+                        "<missing>".to_string()
1279
+                    } else {
1280
+                        inspection.roles.join(",")
1281
+                    }
1282
+                );
1283
+                let _ = writeln!(
1284
+                    stdout,
1285
+                    "tid={}",
1286
+                    inspection.tenant_id.as_deref().unwrap_or("<missing>")
1287
+                );
1288
+                let _ = writeln!(
1289
+                    stdout,
1290
+                    "iss={}",
1291
+                    inspection.issuer.as_deref().unwrap_or("<missing>")
1292
+                );
1293
+                let _ = writeln!(
1294
+                    stdout,
1295
+                    "appid={}",
1296
+                    inspection.app_id.as_deref().unwrap_or("<missing>")
1297
+                );
1298
+                let _ = writeln!(
1299
+                    stdout,
1300
+                    "azp={}",
1301
+                    inspection
1302
+                        .authorized_party
1303
+                        .as_deref()
1304
+                        .unwrap_or("<missing>")
1305
+                );
1306
+                let _ = writeln!(
1307
+                    stdout,
1308
+                    "jwt_exp={}",
1309
+                    inspection
1310
+                        .jwt_expires_at_epoch_seconds
1311
+                        .map(|value| value.to_string())
1312
+                        .unwrap_or_else(|| "<missing>".to_string())
1313
+                );
1314
+                let _ = writeln!(
1315
+                    stdout,
1316
+                    "stored_exp={}",
1317
+                    inspection.stored_expires_at_epoch_seconds
1318
+                );
1319
+                let _ = writeln!(stdout, "has_refresh_token={}", inspection.has_refresh_token);
1320
+            })
1321
+        }
12471322
         MicrosoftCliAction::CalendarsList { account, config } => {
12481323
             let Some(account_config) = config.account(&account) else {
12491324
                 return provider_error_exit(
@@ -2243,6 +2318,29 @@ create_event = ["n"]
22432318
             }))
22442319
         );
22452320
 
2321
+        let inspect_action = parse_args_with_config(
2322
+            [
2323
+                arg("providers"),
2324
+                arg("microsoft"),
2325
+                arg("auth"),
2326
+                arg("inspect"),
2327
+                arg("--account"),
2328
+                arg("work"),
2329
+            ],
2330
+            today.into(),
2331
+            user_config.clone(),
2332
+            None,
2333
+        )
2334
+        .expect("inspect parses");
2335
+        assert_eq!(
2336
+            inspect_action,
2337
+            CliAction::Providers(ProviderCliAction::Microsoft(
2338
+                MicrosoftCliAction::AuthInspect {
2339
+                    account: "work".to_string(),
2340
+                },
2341
+            ))
2342
+        );
2343
+
22462344
         let login_action = parse_args_with_config(
22472345
             [
22482346
                 arg("providers"),
src/providers.rsmodified
@@ -1326,6 +1326,7 @@ pub struct MicrosoftHttpRequest {
13261326
 #[derive(Debug, Clone, PartialEq, Eq)]
13271327
 pub struct MicrosoftHttpResponse {
13281328
     pub status: u16,
1329
+    pub headers: Vec<(String, String)>,
13291330
     pub body: String,
13301331
 }
13311332
 
@@ -1355,10 +1356,24 @@ impl MicrosoftHttpClient for ReqwestMicrosoftHttpClient {
13551356
             .send()
13561357
             .map_err(|err| ProviderError::Http(err.to_string()))?;
13571358
         let status = response.status().as_u16();
1359
+        let headers = response
1360
+            .headers()
1361
+            .iter()
1362
+            .map(|(name, value)| {
1363
+                (
1364
+                    name.as_str().to_string(),
1365
+                    value.to_str().unwrap_or_default().to_string(),
1366
+                )
1367
+            })
1368
+            .collect();
13581369
         let body = response
13591370
             .text()
13601371
             .map_err(|err| ProviderError::Http(err.to_string()))?;
1361
-        Ok(MicrosoftHttpResponse { status, body })
1372
+        Ok(MicrosoftHttpResponse {
1373
+            status,
1374
+            headers,
1375
+            body,
1376
+        })
13621377
     }
13631378
 }
13641379
 
@@ -1392,6 +1407,56 @@ pub fn logout(
13921407
     token_store.delete(account_id)
13931408
 }
13941409
 
1410
+#[derive(Debug, Clone, PartialEq, Eq)]
1411
+pub struct MicrosoftTokenInspection {
1412
+    pub account_id: String,
1413
+    pub stored_expires_at_epoch_seconds: u64,
1414
+    pub jwt_expires_at_epoch_seconds: Option<i64>,
1415
+    pub audience: Option<String>,
1416
+    pub scopes: Option<String>,
1417
+    pub roles: Vec<String>,
1418
+    pub tenant_id: Option<String>,
1419
+    pub issuer: Option<String>,
1420
+    pub app_id: Option<String>,
1421
+    pub authorized_party: Option<String>,
1422
+    pub has_refresh_token: bool,
1423
+}
1424
+
1425
+pub fn inspect_token(
1426
+    account_id: &str,
1427
+    token_store: &dyn MicrosoftTokenStore,
1428
+) -> Result<MicrosoftTokenInspection, ProviderError> {
1429
+    let token = token_store.load(account_id)?.ok_or_else(|| {
1430
+        ProviderError::Auth(format!(
1431
+            "Microsoft account '{account_id}' is not authenticated"
1432
+        ))
1433
+    })?;
1434
+    let claims = access_token_claims(&token.access_token)?;
1435
+    Ok(MicrosoftTokenInspection {
1436
+        account_id: account_id.to_string(),
1437
+        stored_expires_at_epoch_seconds: token.expires_at_epoch_seconds,
1438
+        jwt_expires_at_epoch_seconds: graph_i64(&claims, "exp"),
1439
+        audience: graph_string(&claims, "aud"),
1440
+        scopes: graph_string(&claims, "scp"),
1441
+        roles: claims
1442
+            .get("roles")
1443
+            .and_then(Value::as_array)
1444
+            .map(|roles| {
1445
+                roles
1446
+                    .iter()
1447
+                    .filter_map(Value::as_str)
1448
+                    .map(ToString::to_string)
1449
+                    .collect()
1450
+            })
1451
+            .unwrap_or_default(),
1452
+        tenant_id: graph_string(&claims, "tid"),
1453
+        issuer: graph_string(&claims, "iss"),
1454
+        app_id: graph_string(&claims, "appid"),
1455
+        authorized_party: graph_string(&claims, "azp"),
1456
+        has_refresh_token: !token.refresh_token.is_empty(),
1457
+    })
1458
+}
1459
+
13951460
 fn login_device_code(
13961461
     account: &MicrosoftAccountConfig,
13971462
     http: &dyn MicrosoftHttpClient,
@@ -1751,14 +1816,28 @@ fn parse_graph_empty_success(response: MicrosoftHttpResponse) -> Result<(), Prov
17511816
 }
17521817
 
17531818
 fn graph_error_message(response: MicrosoftHttpResponse) -> String {
1819
+    let www_authenticate = response
1820
+        .headers
1821
+        .iter()
1822
+        .find(|(name, _)| name.eq_ignore_ascii_case("www-authenticate"))
1823
+        .map(|(_, value)| value.as_str());
17541824
     if let Ok(value) = serde_json::from_str::<Value>(&response.body)
17551825
         && let Some(error) = value.get("error")
17561826
     {
17571827
         let code = graph_string(error, "code").unwrap_or_else(|| response.status.to_string());
17581828
         let message = graph_string(error, "message").unwrap_or_else(|| response.body.clone());
1759
-        return format!("{code}: {message}");
1829
+        return match www_authenticate {
1830
+            Some(header) if !header.is_empty() => format!("{code}: {message} ({header})"),
1831
+            _ => format!("{code}: {message}"),
1832
+        };
1833
+    }
1834
+    match (response.body.trim(), www_authenticate) {
1835
+        ("", Some(header)) if !header.is_empty() => format!("HTTP {}: {header}", response.status),
1836
+        (body, Some(header)) if !header.is_empty() => {
1837
+            format!("HTTP {}: {body} ({header})", response.status)
1838
+        }
1839
+        (body, _) => format!("HTTP {}: {body}", response.status),
17601840
     }
1761
-    format!("HTTP {}: {}", response.status, response.body)
17621841
 }
17631842
 
17641843
 fn parse_oauth_json(response: MicrosoftHttpResponse) -> Result<Value, ProviderError> {
@@ -1781,6 +1860,18 @@ fn token_from_response(response: MicrosoftHttpResponse) -> Result<MicrosoftToken
17811860
     })
17821861
 }
17831862
 
1863
+fn access_token_claims(access_token: &str) -> Result<Value, ProviderError> {
1864
+    let payload = access_token.split('.').nth(1).ok_or_else(|| {
1865
+        ProviderError::Auth("stored Microsoft access token is not a JWT".to_string())
1866
+    })?;
1867
+    let bytes = base64_url_decode_no_pad(payload).ok_or_else(|| {
1868
+        ProviderError::Auth("stored Microsoft access token has invalid JWT encoding".to_string())
1869
+    })?;
1870
+    serde_json::from_slice(&bytes).map_err(|err| {
1871
+        ProviderError::Auth(format!("stored Microsoft access token is invalid: {err}"))
1872
+    })
1873
+}
1874
+
17841875
 fn required_json_string(value: &Value, key: &str) -> Result<String, ProviderError> {
17851876
     graph_string(value, key)
17861877
         .ok_or_else(|| ProviderError::Auth(format!("OAuth response is missing {key}")))
@@ -2335,6 +2426,33 @@ fn base64_url_no_pad(bytes: &[u8]) -> String {
23352426
     output
23362427
 }
23372428
 
2429
+fn base64_url_decode_no_pad(input: &str) -> Option<Vec<u8>> {
2430
+    let mut output = Vec::new();
2431
+    let mut buffer = 0_u32;
2432
+    let mut bits = 0_u8;
2433
+    for byte in input.bytes() {
2434
+        if byte == b'=' {
2435
+            break;
2436
+        }
2437
+        let value = match byte {
2438
+            b'A'..=b'Z' => byte - b'A',
2439
+            b'a'..=b'z' => byte - b'a' + 26,
2440
+            b'0'..=b'9' => byte - b'0' + 52,
2441
+            b'-' => 62,
2442
+            b'_' => 63,
2443
+            _ => return None,
2444
+        } as u32;
2445
+        buffer = (buffer << 6) | value;
2446
+        bits += 6;
2447
+        if bits >= 8 {
2448
+            bits -= 8;
2449
+            output.push(((buffer >> bits) & 0xff) as u8);
2450
+            buffer &= (1 << bits) - 1;
2451
+        }
2452
+    }
2453
+    Some(output)
2454
+}
2455
+
23382456
 fn open_browser(url: &str) -> Result<(), ProviderError> {
23392457
     #[cfg(target_os = "macos")]
23402458
     let result = Command::new("open").arg(url).status();
@@ -2524,9 +2642,23 @@ mod tests {
25242642
         fn json(status: u16, value: Value) -> MicrosoftHttpResponse {
25252643
             MicrosoftHttpResponse {
25262644
                 status,
2645
+                headers: Vec::new(),
25272646
                 body: value.to_string(),
25282647
             }
25292648
         }
2649
+
2650
+        fn text_with_header(
2651
+            status: u16,
2652
+            body: &str,
2653
+            name: &str,
2654
+            value: &str,
2655
+        ) -> MicrosoftHttpResponse {
2656
+            MicrosoftHttpResponse {
2657
+                status,
2658
+                headers: vec![(name.to_string(), value.to_string())],
2659
+                body: body.to_string(),
2660
+            }
2661
+        }
25302662
     }
25312663
 
25322664
     impl MicrosoftHttpClient for RecordingHttpClient {
@@ -2701,6 +2833,65 @@ mod tests {
27012833
         );
27022834
     }
27032835
 
2836
+    #[test]
2837
+    fn graph_errors_include_www_authenticate_header_when_body_is_empty() {
2838
+        let response = RecordingHttpClient::text_with_header(
2839
+            401,
2840
+            "",
2841
+            "WWW-Authenticate",
2842
+            "Bearer error=\"invalid_token\", error_description=\"Invalid audience\"",
2843
+        );
2844
+
2845
+        let err = parse_graph_success_json(response).expect_err("401 fails");
2846
+
2847
+        assert_eq!(
2848
+            err.to_string(),
2849
+            "Microsoft Graph error: HTTP 401: Bearer error=\"invalid_token\", error_description=\"Invalid audience\""
2850
+        );
2851
+    }
2852
+
2853
+    #[test]
2854
+    fn inspect_token_reports_safe_jwt_claims_without_token_body() {
2855
+        let store = MemoryTokenStore::default();
2856
+        let claims = json!({
2857
+            "aud": "https://graph.microsoft.com",
2858
+            "scp": "User.Read Calendars.ReadWrite",
2859
+            "tid": "tenant-id",
2860
+            "iss": "https://sts.windows.net/tenant-id/",
2861
+            "appid": "app-id",
2862
+            "azp": "authorized-party",
2863
+            "exp": 1_777_000_000
2864
+        });
2865
+        let access_token = format!(
2866
+            "{}.{}.signature",
2867
+            base64_url_no_pad(br#"{"alg":"none"}"#),
2868
+            base64_url_no_pad(claims.to_string().as_bytes())
2869
+        );
2870
+        store.tokens.borrow_mut().insert(
2871
+            "work".to_string(),
2872
+            MicrosoftToken {
2873
+                access_token,
2874
+                refresh_token: "refresh".to_string(),
2875
+                expires_at_epoch_seconds: 1_777_000_100,
2876
+            },
2877
+        );
2878
+
2879
+        let inspection = inspect_token("work", &store).expect("token inspects");
2880
+
2881
+        assert_eq!(inspection.account_id, "work");
2882
+        assert_eq!(
2883
+            inspection.audience.as_deref(),
2884
+            Some("https://graph.microsoft.com")
2885
+        );
2886
+        assert_eq!(
2887
+            inspection.scopes.as_deref(),
2888
+            Some("User.Read Calendars.ReadWrite")
2889
+        );
2890
+        assert_eq!(inspection.tenant_id.as_deref(), Some("tenant-id"));
2891
+        assert_eq!(inspection.jwt_expires_at_epoch_seconds, Some(1_777_000_000));
2892
+        assert!(inspection.has_refresh_token);
2893
+    }
2894
+
27042895
     #[test]
27052896
     fn sync_writes_selected_calendar_cache_and_renders_provider_event() {
27062897
         let cache_file = temp_path("sync/microsoft-cache.json");