| 1 | use crate::error::{Error, Result}; |
| 2 | use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; |
| 3 | use serde::Serialize; |
| 4 | use std::path::Path; |
| 5 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 6 | |
| 7 | const AUDIENCE: &str = "appstoreconnect-v1"; |
| 8 | // Apple rejects tokens whose `exp` is more than 20 minutes past `iat`. See |
| 9 | // https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests |
| 10 | const MAX_TOKEN_LIFETIME_SECS: u64 = 20 * 60; |
| 11 | |
| 12 | #[derive(Debug, Clone, Serialize)] |
| 13 | struct Claims { |
| 14 | iss: String, |
| 15 | iat: u64, |
| 16 | exp: u64, |
| 17 | aud: String, |
| 18 | } |
| 19 | |
| 20 | /// An App Store Connect API key: the (key ID, issuer ID, private key) |
| 21 | /// triple from App Store Connect → Users and Access → Integrations → App |
| 22 | /// Store Connect API. Signs the short-lived ES256 JWTs the API requires as |
| 23 | /// a bearer token on every request. |
| 24 | #[derive(Clone)] |
| 25 | pub struct ApiKey { |
| 26 | key_id: String, |
| 27 | issuer_id: String, |
| 28 | encoding_key: EncodingKey, |
| 29 | lifetime_secs: u64, |
| 30 | } |
| 31 | |
| 32 | impl ApiKey { |
| 33 | /// Load a key from a PEM-encoded `.p8` private key file — the file App |
| 34 | /// Store Connect lets you download once, at key-creation time. |
| 35 | pub fn from_p8_file( |
| 36 | key_id: impl Into<String>, |
| 37 | issuer_id: impl Into<String>, |
| 38 | p8_path: impl AsRef<Path>, |
| 39 | ) -> Result<Self> { |
| 40 | let path = p8_path.as_ref(); |
| 41 | let pem = std::fs::read_to_string(path).map_err(|source| Error::PrivateKeyRead { |
| 42 | path: path.display().to_string(), |
| 43 | source, |
| 44 | })?; |
| 45 | Self::from_p8_pem(key_id, issuer_id, &pem) |
| 46 | } |
| 47 | |
| 48 | /// Load a key from an already-in-memory PEM string. |
| 49 | pub fn from_p8_pem( |
| 50 | key_id: impl Into<String>, |
| 51 | issuer_id: impl Into<String>, |
| 52 | pem: &str, |
| 53 | ) -> Result<Self> { |
| 54 | let encoding_key = EncodingKey::from_ec_pem(pem.as_bytes())?; |
| 55 | Ok(Self { |
| 56 | key_id: key_id.into(), |
| 57 | issuer_id: issuer_id.into(), |
| 58 | encoding_key, |
| 59 | lifetime_secs: MAX_TOKEN_LIFETIME_SECS, |
| 60 | }) |
| 61 | } |
| 62 | |
| 63 | /// Override the token lifetime in seconds. Values above Apple's 20 |
| 64 | /// minute ceiling are clamped down to it. |
| 65 | pub fn with_lifetime_secs(mut self, secs: u64) -> Self { |
| 66 | self.lifetime_secs = secs.min(MAX_TOKEN_LIFETIME_SECS); |
| 67 | self |
| 68 | } |
| 69 | |
| 70 | pub fn key_id(&self) -> &str { |
| 71 | &self.key_id |
| 72 | } |
| 73 | |
| 74 | pub fn lifetime_secs(&self) -> u64 { |
| 75 | self.lifetime_secs |
| 76 | } |
| 77 | |
| 78 | /// Mint a fresh bearer token. `Client` caches and reuses the result of |
| 79 | /// this until shortly before it expires rather than calling it per |
| 80 | /// request. |
| 81 | pub fn token(&self) -> Result<String> { |
| 82 | let now = SystemTime::now() |
| 83 | .duration_since(UNIX_EPOCH) |
| 84 | .unwrap_or_default() // a clock before 1970 isn't a real failure mode worth propagating |
| 85 | .as_secs(); |
| 86 | |
| 87 | let claims = Claims { |
| 88 | iss: self.issuer_id.clone(), |
| 89 | iat: now, |
| 90 | exp: now + self.lifetime_secs, |
| 91 | aud: AUDIENCE.to_string(), |
| 92 | }; |
| 93 | |
| 94 | let mut header = Header::new(Algorithm::ES256); |
| 95 | header.kid = Some(self.key_id.clone()); |
| 96 | |
| 97 | Ok(encode(&header, &claims, &self.encoding_key)?) |
| 98 | } |
| 99 | } |