apple_search_ads_client_secret/
lib.rs1use 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 openssl::{ec::EcKey, error::ErrorStack as OpensslErrorStack, pkey::PKey};
8use serde::{Deserialize, Serialize};
9
10pub const AUDIENCE: &str = "https://appleid.apple.com";
11pub const EXPIRATION_TIME_DURATION_SECONDS_MAX: u64 = 86400 * 180;
13
14const EC_PRIVATE_KEY_BEGIN: &[u8] = b"-----BEGIN EC PRIVATE KEY-----";
15
16#[derive(Serialize, Deserialize, Debug, Clone)]
18pub struct Claims {
19 pub iss: Box<str>,
20 #[serde(with = "ts_seconds")]
21 pub iat: DateTime<Utc>,
22 #[serde(with = "ts_seconds")]
23 pub exp: DateTime<Utc>,
24 pub aud: Box<str>,
25 pub sub: Box<str>,
26}
27
28pub fn create(
29 key_id: impl AsRef<str>,
30 ec_private_key_pem_bytes: impl AsRef<[u8]>,
31 team_id: impl AsRef<str>,
32 client_id: impl AsRef<str>,
33 issued_at: impl Into<Option<DateTime<Utc>>>,
34 expiration_time_dur: impl Into<Option<Duration>>,
35) -> Result<Box<str>, CreateError> {
36 let ec_private_key_pem_bytes = ec_private_key_pem_bytes.as_ref();
37
38 let key = if ec_private_key_pem_bytes
39 .windows(EC_PRIVATE_KEY_BEGIN.len())
40 .any(|x| x == EC_PRIVATE_KEY_BEGIN)
41 {
42 let pem_bytes = PKey::from_ec_key(
43 EcKey::private_key_from_pem(ec_private_key_pem_bytes)
44 .map_err(CreateError::MakeEcKeyFailed)?,
45 )
46 .map_err(CreateError::MakePKeyFailed)?
47 .private_key_to_pem_pkcs8()
48 .map_err(CreateError::ToPemPkcs8Failed)?;
49
50 EncodingKey::from_ec_pem(&pem_bytes).map_err(CreateError::MakeEncodingKeyFailed)?
51 } else {
52 EncodingKey::from_ec_pem(ec_private_key_pem_bytes)
53 .map_err(CreateError::MakeEncodingKeyFailed)?
54 };
55
56 let mut header = Header::new(Algorithm::ES256);
57 header.typ = None;
58 header.kid = Some(key_id.as_ref().to_owned());
59
60 let issued_at = issued_at.into().unwrap_or_else(Utc::now);
61 let mut expiration_time_dur = expiration_time_dur
62 .into()
63 .unwrap_or_else(|| Duration::from_secs(EXPIRATION_TIME_DURATION_SECONDS_MAX));
64 if expiration_time_dur.as_secs() > EXPIRATION_TIME_DURATION_SECONDS_MAX {
65 expiration_time_dur = Duration::from_secs(EXPIRATION_TIME_DURATION_SECONDS_MAX);
66 }
67 let expiration_time = issued_at + ChronoDuration::seconds(expiration_time_dur.as_secs() as i64);
68
69 let claims = Claims {
70 iss: team_id.as_ref().into(),
71 iat: issued_at,
72 exp: expiration_time,
73 aud: AUDIENCE.into(),
74 sub: client_id.as_ref().into(),
75 };
76
77 let token = encode(&header, &claims, &key).map_err(CreateError::EncodeFailed)?;
78
79 Ok(token.as_str().into())
80}
81
82#[derive(Debug)]
83pub enum CreateError {
84 MakeEcKeyFailed(OpensslErrorStack),
85 MakePKeyFailed(OpensslErrorStack),
86 ToPemPkcs8Failed(OpensslErrorStack),
87 MakeEncodingKeyFailed(JsonwebtokenError),
88 EncodeFailed(JsonwebtokenError),
89}
90impl fmt::Display for CreateError {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 write!(f, "{:?}", self)
93 }
94}
95impl std::error::Error for CreateError {}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn test_create_for_pem() {
103 const PEM_PRIVATE_KEY: &str = r#"
107-----BEGIN EC PRIVATE KEY-----
108MHcCAQEEIKtnxllRY8nbndBQwT9we4pEULtjpW605iwvzLlKcBq4oAoGCCqGSM49
109AwEHoUQDQgAEY58v74eQFyLtu5rtCpeU4NggVSUQSOcHhN744t0gWGc/xXkCSusz
110LaZriCQnnqq4Vx+IscLFcrjBj+ulZzKlUQ==
111-----END EC PRIVATE KEY-----
112 "#;
113
114 const CLIENT_ID: &str = "SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577";
115 const TEAM_ID: &str = "SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577";
116 const KEY_ID: &str = "bacaebda-e219-41ee-a907-e2c25b24d1b2";
117
118 let secret = create(
119 KEY_ID,
120 PEM_PRIVATE_KEY,
121 TEAM_ID,
122 CLIENT_ID,
123 "2022-06-06T00:00:00Z".parse::<DateTime<Utc>>().unwrap(),
124 Duration::from_secs(3600 * 24 * 180),
125 )
126 .unwrap();
127
128 println!("{}", secret);
133 let mut split = secret.split('.');
134 assert_eq!(
135 split.next().unwrap(),
136 "eyJhbGciOiJFUzI1NiIsImtpZCI6ImJhY2FlYmRhLWUyMTktNDFlZS1hOTA3LWUyYzI1YjI0ZDFiMiJ9"
137 );
138 assert_eq!(split.next().unwrap() , "eyJpc3MiOiJTRUFSQ0hBRFMuMjc0NzhlNzEtM2JiMC00NTg4LTk5OGMtMTgyZTJiNDA1NTc3IiwiaWF0IjoxNjU0NDczNjAwLCJleHAiOjE2NzAwMjU2MDAsImF1ZCI6Imh0dHBzOi8vYXBwbGVpZC5hcHBsZS5jb20iLCJzdWIiOiJTRUFSQ0hBRFMuMjc0NzhlNzEtM2JiMC00NTg4LTk5OGMtMTgyZTJiNDA1NTc3In0");
139 split.next();
140 assert!(split.next().is_none());
141 }
142
143 #[test]
144 fn test_create_for_pem_pkcs8() {
145 const PEM_PRIVATE_KEY: &str = r#"
150-----BEGIN PRIVATE KEY-----
151MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgq2fGWVFjydud0FDB
152P3B7ikRQu2OlbrTmLC/MuUpwGrihRANCAARjny/vh5AXIu27mu0Kl5Tg2CBVJRBI
1535weE3vji3SBYZz/FeQJK6zMtpmuIJCeeqrhXH4ixwsVyuMGP66VnMqVR
154-----END PRIVATE KEY-----
155 "#;
156
157 const CLIENT_ID: &str = "SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577";
158 const TEAM_ID: &str = "SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577";
159 const KEY_ID: &str = "bacaebda-e219-41ee-a907-e2c25b24d1b2";
160
161 let secret = create(
162 KEY_ID,
163 PEM_PRIVATE_KEY,
164 TEAM_ID,
165 CLIENT_ID,
166 "2022-06-06T00:00:00Z".parse::<DateTime<Utc>>().unwrap(),
167 Duration::from_secs(3600 * 24 * 180),
168 )
169 .unwrap();
170
171 println!("{}", secret);
172 let mut split = secret.split('.');
173 assert_eq!(
174 split.next().unwrap(),
175 "eyJhbGciOiJFUzI1NiIsImtpZCI6ImJhY2FlYmRhLWUyMTktNDFlZS1hOTA3LWUyYzI1YjI0ZDFiMiJ9"
176 );
177 assert_eq!(split.next().unwrap() , "eyJpc3MiOiJTRUFSQ0hBRFMuMjc0NzhlNzEtM2JiMC00NTg4LTk5OGMtMTgyZTJiNDA1NTc3IiwiaWF0IjoxNjU0NDczNjAwLCJleHAiOjE2NzAwMjU2MDAsImF1ZCI6Imh0dHBzOi8vYXBwbGVpZC5hcHBsZS5jb20iLCJzdWIiOiJTRUFSQ0hBRFMuMjc0NzhlNzEtM2JiMC00NTg4LTk5OGMtMTgyZTJiNDA1NTc3In0");
178 split.next();
179 assert!(split.next().is_none());
180 }
181}