Skip to main content

smbcloud_ascapi_core/
auth.rs

1use crate::error::{Error, Result};
2use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
3use serde::Serialize;
4use std::path::Path;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7const 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
10const MAX_TOKEN_LIFETIME_SECS: u64 = 20 * 60;
11
12#[derive(Debug, Clone, Serialize)]
13struct 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)]
25pub struct ApiKey {
26    key_id: String,
27    issuer_id: String,
28    encoding_key: EncodingKey,
29    lifetime_secs: u64,
30}
31
32impl 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}