| 1 | use std::path::Path; |
| 2 | use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; |
| 3 | |
| 4 | use jsonwebtoken::{Algorithm, EncodingKey, Header}; |
| 5 | use serde::{Deserialize, Serialize}; |
| 6 | use tokio::sync::Mutex; |
| 7 | |
| 8 | use crate::error::{Error, Result}; |
| 9 | |
| 10 | pub const SCOPE: &str = "https://www.googleapis.com/auth/androidpublisher"; |
| 11 | const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token"; |
| 12 | const JWT_LIFETIME_SECS: u64 = 3600; |
| 13 | /// Refresh this long before the token expires. |
| 14 | const 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)] |
| 18 | pub 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. |
| 26 | impl 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 | |
| 35 | impl 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)] |
| 68 | pub(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 | |
| 76 | impl 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)] |
| 89 | struct 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. |
| 96 | pub(crate) struct TokenCache { |
| 97 | cached: Mutex<Option<(String, Instant)>>, |
| 98 | } |
| 99 | |
| 100 | impl 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)] |
| 147 | mod 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 | } |