apple_app_store_connect_api_token/
lib.rs

1//! [Doc](https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests)
2
3use core::{fmt, time::Duration};
4
5use chrono::{serde::ts_seconds, DateTime, Duration as ChronoDuration, Utc};
6use jsonwebtoken::{encode, errors::Error as JsonwebtokenError, Algorithm, EncodingKey, Header};
7use serde::{Deserialize, Serialize};
8
9pub const AUDIENCE: &str = "appstoreconnect-v1";
10// six months
11pub const EXPIRATION_TIME_DURATION_SECONDS_MAX: u64 = 60 * 60 * 24 * 6;
12// 20 minutes
13pub const EXPIRATION_TIME_DURATION_SECONDS_MAX_FOR_MOST_REQUESTS: u64 = 60 * 20;
14
15//
16#[derive(Serialize, Deserialize, Debug, Clone)]
17pub struct Claims {
18    pub iss: Box<str>,
19    #[serde(with = "ts_seconds")]
20    pub iat: DateTime<Utc>,
21    #[serde(with = "ts_seconds")]
22    pub exp: DateTime<Utc>,
23    pub aud: Box<str>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub scope: Option<Vec<Box<str>>>,
26}
27
28pub fn create(
29    key_id: impl AsRef<str>,
30    auth_key_p8_bytes: impl AsRef<[u8]>,
31    issuer_id: impl AsRef<str>,
32    scope: impl Into<Option<Vec<Box<str>>>>,
33    issued_at: impl Into<Option<DateTime<Utc>>>,
34    expiration_time_dur: impl Into<Option<Duration>>,
35) -> Result<Box<str>, CreateError> {
36    let key = EncodingKey::from_ec_pem(auth_key_p8_bytes.as_ref())
37        .map_err(CreateError::MakeEncodingKeyFailed)?;
38
39    let mut header = Header::new(Algorithm::ES256);
40    header.typ = Some("JWT".to_owned());
41    header.kid = Some(key_id.as_ref().to_owned());
42
43    let issued_at = issued_at.into().unwrap_or_else(Utc::now);
44    let mut expiration_time_dur = expiration_time_dur.into().unwrap_or_else(|| {
45        Duration::from_secs(EXPIRATION_TIME_DURATION_SECONDS_MAX_FOR_MOST_REQUESTS)
46    });
47    if expiration_time_dur.as_secs() > EXPIRATION_TIME_DURATION_SECONDS_MAX {
48        expiration_time_dur = Duration::from_secs(EXPIRATION_TIME_DURATION_SECONDS_MAX);
49    }
50    let expiration_time = issued_at + ChronoDuration::seconds(expiration_time_dur.as_secs() as i64);
51
52    let claims = Claims {
53        iss: issuer_id.as_ref().into(),
54        iat: issued_at,
55        exp: expiration_time,
56        aud: AUDIENCE.into(),
57        scope: scope.into(),
58    };
59
60    let token = encode(&header, &claims, &key).map_err(CreateError::EncodeFailed)?;
61
62    Ok(token.as_str().into())
63}
64
65#[derive(Debug)]
66pub enum CreateError {
67    MakeEncodingKeyFailed(JsonwebtokenError),
68    EncodeFailed(JsonwebtokenError),
69}
70impl fmt::Display for CreateError {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(f, "{:?}", self)
73    }
74}
75impl std::error::Error for CreateError {}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn test_create() {
83        /*
84        openssl ecparam -genkey -noout -name prime256v1 \
85            | openssl pkcs8 -topk8 -nocrypt -out ec-private.pem
86        */
87        const P8_PRIVATE_KEY: &str = r#"
88-----BEGIN PRIVATE KEY-----
89MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgsXxkulZ+qopu4M9j
90++u5MRRIYJQG3oobmJsvhKQx4+uhRANCAARIXRezNaTknAu66ifGy0vCkxTWD7oX
91LjUOxL+a+60LCYQsO4O9fzi1klyzxSa3n2ZUvjNkiqlbxufipiYejOZk
92-----END PRIVATE KEY-----
93        "#;
94
95        const ISSUER_ID: &str = "57246542-96fe-1a63-e053-0824d011072a";
96        const KEY_ID: &str = "2X9R4HXF34";
97
98        let secret = create(
99            KEY_ID,
100            P8_PRIVATE_KEY,
101            ISSUER_ID,
102            vec!["GET /v1/apps?filter[platform]=IOS".into()],
103            "2022-06-06T00:00:00Z".parse::<DateTime<Utc>>().unwrap(),
104            Duration::from_secs(60 * 60 * 10),
105        )
106        .unwrap();
107
108        println!("{}", secret);
109        let mut split = secret.split('.');
110        assert_eq!(
111            split.next().unwrap(),
112            "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IjJYOVI0SFhGMzQifQ"
113        );
114        assert_eq!(split.next().unwrap() , "eyJpc3MiOiI1NzI0NjU0Mi05NmZlLTFhNjMtZTA1My0wODI0ZDAxMTA3MmEiLCJpYXQiOjE2NTQ0NzM2MDAsImV4cCI6MTY1NDUwOTYwMCwiYXVkIjoiYXBwc3RvcmVjb25uZWN0LXYxIiwic2NvcGUiOlsiR0VUIC92MS9hcHBzP2ZpbHRlcltwbGF0Zm9ybV09SU9TIl19");
115        split.next();
116        assert!(split.next().is_none());
117    }
118}