app_store_connect/
api_token.rs

1// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
2// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
3// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
4// option. This file may not be copied, modified, or distributed
5// except according to those terms.
6
7//! App Store Connect API tokens.
8
9use {
10    crate::Result,
11    jsonwebtoken::{Algorithm, EncodingKey, Header},
12    serde::{Deserialize, Serialize},
13    std::{path::Path, time::SystemTime},
14    thiserror::Error,
15};
16
17#[derive(Clone, Debug, Deserialize, Serialize)]
18struct ConnectTokenRequest {
19    iss: String,
20    iat: u64,
21    exp: u64,
22    aud: String,
23}
24
25/// A JWT Token for use with App Store Connect API.
26pub type AppStoreConnectToken = String;
27
28/// Represents a private key used to create JWT tokens for use with App Store Connect.
29///
30/// See https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api
31/// and https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests
32/// for more details.
33///
34/// This entity holds the necessary metadata to issue new JWT tokens.
35///
36/// App Store Connect API tokens/JWTs are derived from:
37///
38/// * A key identifier. This is a short alphanumeric string like `DEADBEEF42`.
39/// * An issuer ID. This is likely a UUID.
40/// * A private key. Likely ECDSA.
41///
42/// All these are issued by Apple. You can log in to App Store Connect and see/manage your keys
43/// at https://appstoreconnect.apple.com/access/api.
44#[derive(Clone)]
45pub struct ConnectTokenEncoder {
46    key_id: String,
47    issuer_id: String,
48    encoding_key: EncodingKey,
49}
50
51impl ConnectTokenEncoder {
52    /// Construct an instance from an [EncodingKey] instance.
53    ///
54    /// This is the lowest level API and ultimately what all constructors use.
55    pub fn from_jwt_encoding_key(
56        key_id: String,
57        issuer_id: String,
58        encoding_key: EncodingKey,
59    ) -> Self {
60        Self {
61            key_id,
62            issuer_id,
63            encoding_key,
64        }
65    }
66
67    /// Construct an instance from a DER encoded ECDSA private key.
68    pub fn from_ecdsa_der(key_id: String, issuer_id: String, der_data: &[u8]) -> Result<Self> {
69        let encoding_key = EncodingKey::from_ec_der(der_data);
70
71        Ok(Self::from_jwt_encoding_key(key_id, issuer_id, encoding_key))
72    }
73
74    /// Create a token from a PEM encoded ECDSA private key.
75    pub fn from_ecdsa_pem(key_id: String, issuer_id: String, pem_data: &[u8]) -> Result<Self> {
76        let encoding_key = EncodingKey::from_ec_pem(pem_data)?;
77
78        Ok(Self::from_jwt_encoding_key(key_id, issuer_id, encoding_key))
79    }
80
81    /// Create a token from a PEM encoded ECDSA private key in a filesystem path.
82    pub fn from_ecdsa_pem_path(
83        key_id: String,
84        issuer_id: String,
85        path: impl AsRef<Path>,
86    ) -> Result<Self> {
87        let data = std::fs::read(path.as_ref())?;
88
89        Self::from_ecdsa_pem(key_id, issuer_id, &data)
90    }
91
92    /// Attempt to construct in instance from an API Key ID.
93    ///
94    /// e.g. `DEADBEEF42`. This looks for an `AuthKey_<id>.p8` file in default search
95    /// locations like `~/.appstoreconnect/private_keys`.
96    pub fn from_api_key_id(key_id: String, issuer_id: String) -> Result<Self> {
97        let mut search_paths = vec![std::env::current_dir()?.join("private_keys")];
98
99        if let Some(home) = dirs::home_dir() {
100            search_paths.extend([
101                home.join("private_keys"),
102                home.join(".private_keys"),
103                home.join(".appstoreconnect").join("private_keys"),
104            ]);
105        }
106
107        // AuthKey_<apiKey>.p8
108        let filename = format!("AuthKey_{key_id}.p8");
109
110        for path in search_paths {
111            let candidate = path.join(filename.as_str());
112
113            if candidate.exists() {
114                return Self::from_ecdsa_pem_path(key_id, issuer_id, candidate);
115            }
116        }
117
118        Err(MissingApiKey.into())
119    }
120
121    /// Mint a new JWT token.
122    ///
123    /// Using the private key and key metadata bound to this instance, we issue a new JWT
124    /// for the requested duration.
125    pub fn new_token(&self, duration: u64) -> Result<AppStoreConnectToken> {
126        let header = Header {
127            kid: Some(self.key_id.clone()),
128            alg: Algorithm::ES256,
129            ..Default::default()
130        };
131
132        let now = SystemTime::now()
133            .duration_since(SystemTime::UNIX_EPOCH)
134            .expect("calculating UNIX time should never fail")
135            .as_secs();
136
137        let claims = ConnectTokenRequest {
138            iss: self.issuer_id.clone(),
139            iat: now,
140            exp: now + duration,
141            aud: "appstoreconnect-v1".to_string(),
142        };
143
144        let token = jsonwebtoken::encode(&header, &claims, &self.encoding_key)?;
145
146        Ok(token)
147    }
148}
149
150#[derive(Clone, Copy, Debug, Error)]
151#[error("no app store connect api key found")]
152pub struct MissingApiKey;