Skip to main content

smbcloud_ascapi_play/
auth.rs

1use std::path::Path;
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3
4use jsonwebtoken::{Algorithm, EncodingKey, Header};
5use serde::{Deserialize, Serialize};
6use tokio::sync::Mutex;
7
8use crate::error::{Error, Result};
9
10pub const SCOPE: &str = "https://www.googleapis.com/auth/androidpublisher";
11const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
12const JWT_LIFETIME_SECS: u64 = 3600;
13/// Refresh this long before the token expires.
14const REFRESH_MARGIN: Duration = Duration::from_secs(60);
15
16/// The fields of a Google service-account key file this crate uses.
17#[derive(Clone, Deserialize)]
18pub struct ServiceAccount {
19    pub client_email: String,
20    private_key: String,
21    #[serde(default)]
22    token_uri: Option<String>,
23}
24
25// Hand-written so `{:?}` can never print the private key.
26impl std::fmt::Debug for ServiceAccount {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.debug_struct("ServiceAccount")
29            .field("client_email", &self.client_email)
30            .field("private_key", &"<redacted>")
31            .finish()
32    }
33}
34
35impl ServiceAccount {
36    pub fn from_json(json: &str) -> Result<Self> {
37        let account: Self = serde_json::from_str(json).map_err(|_| Error::ServiceAccountInvalid)?;
38        if account.client_email.is_empty() || account.private_key.is_empty() {
39            return Err(Error::ServiceAccountInvalid);
40        }
41        Ok(account)
42    }
43
44    pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self> {
45        let path = path.as_ref();
46        let json = std::fs::read_to_string(path).map_err(|source| Error::ServiceAccountRead {
47            path: path.display().to_string(),
48            source,
49        })?;
50        Self::from_json(&json)
51    }
52
53    pub fn token_uri(&self) -> &str {
54        self.token_uri.as_deref().unwrap_or(DEFAULT_TOKEN_URI)
55    }
56
57    /// Sign the RS256 assertion exchanged for an access token.
58    pub fn assertion(&self, now_secs: u64) -> Result<String> {
59        let claims = Claims::new(&self.client_email, self.token_uri(), now_secs);
60        let mut header = Header::new(Algorithm::RS256);
61        header.typ = Some("JWT".to_string());
62        let key = EncodingKey::from_rsa_pem(self.private_key.as_bytes())?;
63        Ok(jsonwebtoken::encode(&header, &claims, &key)?)
64    }
65}
66
67#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
68pub(crate) struct Claims {
69    pub iss: String,
70    pub scope: String,
71    pub aud: String,
72    pub iat: u64,
73    pub exp: u64,
74}
75
76impl Claims {
77    pub(crate) fn new(email: &str, audience: &str, now_secs: u64) -> Self {
78        Self {
79            iss: email.to_string(),
80            scope: SCOPE.to_string(),
81            aud: audience.to_string(),
82            iat: now_secs,
83            exp: now_secs + JWT_LIFETIME_SECS,
84        }
85    }
86}
87
88#[derive(Deserialize)]
89struct TokenResponse {
90    access_token: String,
91    #[serde(default)]
92    expires_in: Option<u64>,
93}
94
95/// Mints and caches the access token until shortly before it expires.
96pub(crate) struct TokenCache {
97    cached: Mutex<Option<(String, Instant)>>,
98}
99
100impl TokenCache {
101    pub(crate) fn new() -> Self {
102        Self {
103            cached: Mutex::new(None),
104        }
105    }
106
107    pub(crate) async fn get(
108        &self,
109        http: &reqwest::Client,
110        account: &ServiceAccount,
111    ) -> Result<String> {
112        let mut cached = self.cached.lock().await;
113        if let Some((token, expires)) = cached.as_ref() {
114            if Instant::now() < *expires {
115                return Ok(token.clone());
116            }
117        }
118        let now = SystemTime::now()
119            .duration_since(UNIX_EPOCH)
120            .map(|d| d.as_secs())
121            .unwrap_or(0);
122        let assertion = account.assertion(now)?;
123        let response = http
124            .post(account.token_uri())
125            .form(&[
126                ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
127                ("assertion", assertion.as_str()),
128            ])
129            .send()
130            .await?;
131        let status = response.status();
132        if !status.is_success() {
133            return Err(Error::Token {
134                status: status.as_u16(),
135                detail: response.text().await.unwrap_or_default(),
136            });
137        }
138        let body: TokenResponse = response.json().await?;
139        let lifetime = Duration::from_secs(body.expires_in.unwrap_or(3600));
140        let expires = Instant::now() + lifetime.saturating_sub(REFRESH_MARGIN);
141        *cached = Some((body.access_token.clone(), expires));
142        Ok(body.access_token)
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn claims_carry_scope_audience_and_one_hour_lifetime() {
152        let claims = Claims::new(
153            "svc@example.iam.gserviceaccount.com",
154            DEFAULT_TOKEN_URI,
155            1000,
156        );
157        assert_eq!(claims.iss, "svc@example.iam.gserviceaccount.com");
158        assert_eq!(
159            claims.scope,
160            "https://www.googleapis.com/auth/androidpublisher"
161        );
162        assert_eq!(claims.aud, "https://oauth2.googleapis.com/token");
163        assert_eq!(claims.iat, 1000);
164        assert_eq!(claims.exp, 4600);
165    }
166
167    #[test]
168    fn token_uri_defaults_when_the_key_file_omits_it() {
169        let account =
170            ServiceAccount::from_json(r#"{"client_email":"a@b","private_key":"k"}"#).unwrap();
171        assert_eq!(account.token_uri(), "https://oauth2.googleapis.com/token");
172    }
173
174    #[test]
175    fn a_key_file_missing_the_key_is_rejected_without_echoing_it() {
176        let err = ServiceAccount::from_json(r#"{"client_email":"a@b","private_key":""}"#)
177            .unwrap_err()
178            .to_string();
179        assert!(!err.contains("a@b"));
180        assert!(ServiceAccount::from_json("not json").is_err());
181    }
182
183    #[test]
184    fn debug_output_never_contains_the_private_key() {
185        let account =
186            ServiceAccount::from_json(r#"{"client_email":"a@b","private_key":"SECRET"}"#).unwrap();
187        let shown = format!("{account:?}");
188        assert!(!shown.contains("SECRET"));
189        assert!(shown.contains("a@b"));
190    }
191
192    #[test]
193    fn a_malformed_pem_fails_to_sign_instead_of_panicking() {
194        let account =
195            ServiceAccount::from_json(r#"{"client_email":"a@b","private_key":"nope"}"#).unwrap();
196        assert!(account.assertion(0).is_err());
197    }
198}