gcloud_identity_token/
cache.rs

1//! Token caching utility.
2//!
3//! This stores OAuth tokens securely using the system keyring (by default) or
4//! to a file if the `GCLOUD_IDENTITY_TOKEN_PATH` environment variable is set.
5//!
6//! The keyring entry is namespaced under the service `gcloud-identity-token`
7//! and the keyring "username" is extracted from the ID token's email field.
8
9use crate::config::SavedToken;
10use anyhow::Result;
11use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
12use keyring::Entry;
13use serde::Deserialize;
14use std::{fs, path::PathBuf};
15
16const SERVICE: &str = env!("CARGO_PKG_NAME");
17
18/// Claims in a Google ID token. Used to extract the email address.
19#[derive(Debug, Deserialize)]
20struct IdTokenClaims {
21    email: String,
22}
23
24/// Extracts the email address from a Google-provided ID token.
25///
26/// Returns `None` if the token is malformed or does not include `email`.
27fn extract_email_from_id_token(id_token: &str) -> Option<String> {
28    let parts: Vec<&str> = id_token.split('.').collect();
29    if parts.len() != 3 {
30        return None;
31    }
32
33    let payload = URL_SAFE_NO_PAD.decode(parts[1]).ok()?;
34    let claims: IdTokenClaims = serde_json::from_slice(&payload).ok()?;
35    Some(claims.email)
36}
37
38/// Loads a cached token from either a file or the system keyring.
39///
40/// - If `GCLOUD_IDENTITY_TOKEN_PATH` is set, the token will be loaded from the specified file.
41/// - Otherwise, it attempts to read from the OS keyring using a fixed service name
42///   and the default user identifier `"default"`.
43///
44/// # Returns
45///
46/// An optional `SavedToken` if the token was found and deserialized.
47pub fn load_cached_token() -> Option<SavedToken> {
48    if let Ok(env_path) = std::env::var("GCLOUD_IDENTITY_TOKEN_PATH") {
49        let path = PathBuf::from(env_path);
50        let data = fs::read_to_string(path).ok()?;
51        return serde_json::from_str(&data).ok();
52    }
53
54    let user = fs::read_to_string(email_hint_path()).unwrap_or_else(|_| "default".to_string());
55    let entry = Entry::new(SERVICE, &user).ok()?;
56    let json = entry.get_password().ok()?;
57    serde_json::from_str(&json).ok()
58}
59
60/// Saves a token to either a file or the system keyring.
61///
62/// - If `GCLOUD_IDENTITY_TOKEN_PATH` is set, the token will be saved to that file path.
63/// - Otherwise, it saves to the keyring using the `email` field in the ID token as the user ID.
64///   If the email cannot be extracted, it falls back to `"default"`.
65///
66/// # Errors
67///
68/// Returns an error if the token cannot be serialized or stored.
69pub fn save_token(token: &SavedToken) -> Result<()> {
70    if let Ok(env_path) = std::env::var("GCLOUD_IDENTITY_TOKEN_PATH") {
71        let path = PathBuf::from(env_path);
72        fs::create_dir_all(path.parent().unwrap())?;
73        fs::write(path, serde_json::to_string_pretty(token)?)?;
74        return Ok(());
75    }
76
77    let user =
78        extract_email_from_id_token(&token.id_token).unwrap_or_else(|| "default".to_string());
79    fs::write(email_hint_path(), &user)?;
80
81    let json = serde_json::to_string(token)?;
82    let entry = Entry::new(SERVICE, &user)?;
83    entry.set_password(&json)?;
84    Ok(())
85}
86
87/// Deletes a token from the system keyring.
88///
89/// Only applies if you're using the default `"default"` user ID. For more dynamic handling,
90/// you'd want to extract the appropriate email-based user name from the current context.
91pub fn delete_token() -> Result<()> {
92    let user = fs::read_to_string(email_hint_path()).unwrap_or_else(|_| "default".to_string());
93    let entry = Entry::new(SERVICE, &user)?;
94    entry.delete_password()?;
95    Ok(())
96}
97
98fn email_hint_path() -> PathBuf {
99    dirs::home_dir()
100        .expect("no home dir")
101        .join(".cache")
102        .join(format!("{}.email", env!("CARGO_PKG_NAME")))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_load_save_with_file_cache() {
111        unsafe {
112            std::env::set_var("GCLOUD_IDENTITY_TOKEN_PATH", "/tmp/test_token.json");
113        }
114
115        let token = SavedToken {
116            refresh_token: "r".into(),
117            access_token: "a".into(),
118            id_token: encode_dummy_id_token_with_email("test@example.com"),
119            token_expiry: chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z").unwrap().with_timezone(&chrono::Utc),
120        };
121
122        save_token(&token).unwrap();
123        let loaded = load_cached_token().unwrap();
124        assert_eq!(loaded.refresh_token, "r");
125        assert_eq!(loaded.id_token, token.id_token);
126    }
127
128    /// Creates a fake but structurally valid JWT with an email field in the payload.
129    fn encode_dummy_id_token_with_email(email: &str) -> String {
130        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
131        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
132            .encode(format!(r#"{{"email":"{}"}}"#, email));
133        let signature = "";
134        format!("{}.{}.{}", header, payload, signature)
135    }
136}