@@ -1326,6 +1326,7 @@ pub struct MicrosoftHttpRequest { |
| 1326 | 1326 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 1327 | 1327 | pub struct MicrosoftHttpResponse { |
| 1328 | 1328 | pub status: u16, |
| 1329 | + pub headers: Vec<(String, String)>, |
| 1329 | 1330 | pub body: String, |
| 1330 | 1331 | } |
| 1331 | 1332 | |
@@ -1355,10 +1356,24 @@ impl MicrosoftHttpClient for ReqwestMicrosoftHttpClient { |
| 1355 | 1356 | .send() |
| 1356 | 1357 | .map_err(|err| ProviderError::Http(err.to_string()))?; |
| 1357 | 1358 | 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(); |
| 1358 | 1369 | let body = response |
| 1359 | 1370 | .text() |
| 1360 | 1371 | .map_err(|err| ProviderError::Http(err.to_string()))?; |
| 1361 | | - Ok(MicrosoftHttpResponse { status, body }) |
| 1372 | + Ok(MicrosoftHttpResponse { |
| 1373 | + status, |
| 1374 | + headers, |
| 1375 | + body, |
| 1376 | + }) |
| 1362 | 1377 | } |
| 1363 | 1378 | } |
| 1364 | 1379 | |
@@ -1392,6 +1407,56 @@ pub fn logout( |
| 1392 | 1407 | token_store.delete(account_id) |
| 1393 | 1408 | } |
| 1394 | 1409 | |
| 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 | + |
| 1395 | 1460 | fn login_device_code( |
| 1396 | 1461 | account: &MicrosoftAccountConfig, |
| 1397 | 1462 | http: &dyn MicrosoftHttpClient, |
@@ -1751,14 +1816,28 @@ fn parse_graph_empty_success(response: MicrosoftHttpResponse) -> Result<(), Prov |
| 1751 | 1816 | } |
| 1752 | 1817 | |
| 1753 | 1818 | 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()); |
| 1754 | 1824 | if let Ok(value) = serde_json::from_str::<Value>(&response.body) |
| 1755 | 1825 | && let Some(error) = value.get("error") |
| 1756 | 1826 | { |
| 1757 | 1827 | let code = graph_string(error, "code").unwrap_or_else(|| response.status.to_string()); |
| 1758 | 1828 | 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), |
| 1760 | 1840 | } |
| 1761 | | - format!("HTTP {}: {}", response.status, response.body) |
| 1762 | 1841 | } |
| 1763 | 1842 | |
| 1764 | 1843 | fn parse_oauth_json(response: MicrosoftHttpResponse) -> Result<Value, ProviderError> { |
@@ -1781,6 +1860,18 @@ fn token_from_response(response: MicrosoftHttpResponse) -> Result<MicrosoftToken |
| 1781 | 1860 | }) |
| 1782 | 1861 | } |
| 1783 | 1862 | |
| 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 | + |
| 1784 | 1875 | fn required_json_string(value: &Value, key: &str) -> Result<String, ProviderError> { |
| 1785 | 1876 | graph_string(value, key) |
| 1786 | 1877 | .ok_or_else(|| ProviderError::Auth(format!("OAuth response is missing {key}"))) |
@@ -2335,6 +2426,33 @@ fn base64_url_no_pad(bytes: &[u8]) -> String { |
| 2335 | 2426 | output |
| 2336 | 2427 | } |
| 2337 | 2428 | |
| 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 | + |
| 2338 | 2456 | fn open_browser(url: &str) -> Result<(), ProviderError> { |
| 2339 | 2457 | #[cfg(target_os = "macos")] |
| 2340 | 2458 | let result = Command::new("open").arg(url).status(); |
@@ -2524,9 +2642,23 @@ mod tests { |
| 2524 | 2642 | fn json(status: u16, value: Value) -> MicrosoftHttpResponse { |
| 2525 | 2643 | MicrosoftHttpResponse { |
| 2526 | 2644 | status, |
| 2645 | + headers: Vec::new(), |
| 2527 | 2646 | body: value.to_string(), |
| 2528 | 2647 | } |
| 2529 | 2648 | } |
| 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 | + } |
| 2530 | 2662 | } |
| 2531 | 2663 | |
| 2532 | 2664 | impl MicrosoftHttpClient for RecordingHttpClient { |
@@ -2701,6 +2833,65 @@ mod tests { |
| 2701 | 2833 | ); |
| 2702 | 2834 | } |
| 2703 | 2835 | |
| 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 | + |
| 2704 | 2895 | #[test] |
| 2705 | 2896 | fn sync_writes_selected_calendar_cache_and_renders_provider_event() { |
| 2706 | 2897 | let cache_file = temp_path("sync/microsoft-cache.json"); |