development
rs 198 lines 6.24 KB
Raw
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 }