app_store_server_library/
jws_signature_creator.rs

1use base64::engine::general_purpose::STANDARD as BASE64;
2use base64::Engine;
3use chrono::Utc;
4use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7use uuid::Uuid;
8
9#[derive(Error, Debug)]
10pub enum JWSSignatureCreatorError {
11    #[error("InvalidPrivateKey")]
12    InvalidPrivateKey,
13    
14    #[error("JWTEncodingError: [{0}]")]
15    JWTEncodingError(#[from] jsonwebtoken::errors::Error),
16    
17    #[error("SerializationError: [{0}]")]
18    SerializationError(#[from] serde_json::Error),
19}
20
21#[derive(Debug, Serialize, Deserialize)]
22struct BasePayload {
23    nonce: String,
24    iss: String,
25    bid: String,
26    aud: String,
27    iat: i64,
28}
29
30#[derive(Debug, Serialize, Deserialize)]
31struct PromotionalOfferV2Payload {
32    #[serde(flatten)]
33    base: BasePayload,
34    #[serde(rename = "productId")]
35    product_id: String,
36    #[serde(rename = "offerIdentifier")]
37    offer_identifier: String,
38    #[serde(rename = "transactionId", skip_serializing_if = "Option::is_none")]
39    transaction_id: Option<String>,
40}
41
42#[derive(Debug, Serialize, Deserialize)]
43struct IntroductoryOfferEligibilityPayload {
44    #[serde(flatten)]
45    base: BasePayload,
46    #[serde(rename = "productId")]
47    product_id: String,
48    #[serde(rename = "allowIntroductoryOffer")]
49    allow_introductory_offer: bool,
50    #[serde(rename = "transactionId")]
51    transaction_id: String,
52}
53
54#[derive(Debug, Serialize, Deserialize)]
55struct AdvancedCommerceInAppPayload {
56    #[serde(flatten)]
57    base: BasePayload,
58    request: String,
59}
60
61/// Trait for Advanced Commerce in-app requests
62pub trait AdvancedCommerceInAppRequest: Serialize {}
63
64/// Base struct for creating JWS signatures for App Store requests
65struct JWSSignatureCreator {
66    audience: String,
67    signing_key: EncodingKey,
68    key_id: String,
69    issuer_id: String,
70    bundle_id: String,
71}
72
73impl JWSSignatureCreator {
74    fn new(
75        audience: String,
76        signing_key: &str,
77        key_id: String,
78        issuer_id: String,
79        bundle_id: String,
80    ) -> Result<Self, JWSSignatureCreatorError> {
81        let key = EncodingKey::from_ec_pem(signing_key.as_bytes())
82            .map_err(|_| JWSSignatureCreatorError::InvalidPrivateKey)?;
83        
84        Ok(Self {
85            audience,
86            signing_key: key,
87            key_id,
88            issuer_id,
89            bundle_id,
90        })
91    }
92
93    fn get_base_payload(&self) -> BasePayload {
94        BasePayload {
95            nonce: Uuid::new_v4().to_string(),
96            iss: self.issuer_id.clone(),
97            bid: self.bundle_id.clone(),
98            aud: self.audience.clone(),
99            iat: Utc::now().timestamp(),
100        }
101    }
102
103    fn create_signature<T: Serialize>(&self, payload: &T) -> Result<String, JWSSignatureCreatorError> {
104        let mut header = Header::new(Algorithm::ES256);
105        header.kid = Some(self.key_id.clone());
106        header.typ = Some("JWT".to_string());
107        
108        let token = encode(&header, payload, &self.signing_key)?;
109        Ok(token)
110    }
111}
112
113/// Creator for Promotional Offer V2 signatures
114pub struct PromotionalOfferV2SignatureCreator {
115    base: JWSSignatureCreator,
116}
117
118impl PromotionalOfferV2SignatureCreator {
119    /// Creates a new `PromotionalOfferV2SignatureCreator` instance.
120    ///
121    /// # Arguments
122    ///
123    /// * `signing_key` - Your private key downloaded from App Store Connect (in PEM format)
124    /// * `key_id` - Your key ID from the Keys page in App Store Connect
125    /// * `issuer_id` - Your issuer ID from the Keys page in App Store Connect
126    /// * `bundle_id` - Your app's bundle ID
127    ///
128    /// # Returns
129    ///
130    /// A `Result` containing the `PromotionalOfferV2SignatureCreator` instance or an error.
131    pub fn new(
132        signing_key: &str,
133        key_id: String,
134        issuer_id: String,
135        bundle_id: String,
136    ) -> Result<Self, JWSSignatureCreatorError> {
137        let base = JWSSignatureCreator::new(
138            "promotional-offer".to_string(),
139            signing_key,
140            key_id,
141            issuer_id,
142            bundle_id,
143        )?;
144        
145        Ok(Self { base })
146    }
147
148    /// Creates a promotional offer V2 signature.
149    ///
150    /// # Arguments
151    ///
152    /// * `product_id` - The unique identifier of the product
153    /// * `offer_identifier` - The promotional offer identifier that you set up in App Store Connect
154    /// * `transaction_id` - The unique identifier of any transaction that belongs to the customer.
155    ///   You can use the customer's appTransactionId, even for customers who haven't made any 
156    ///   In-App Purchases in your app. This field is optional, but recommended.
157    ///
158    /// # Returns
159    ///
160    /// A `Result` containing the signed JWS string or an error.
161    ///
162    /// # References
163    ///
164    /// [Generating JWS to sign App Store requests](https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests)
165    pub fn create_signature(
166        &self,
167        product_id: &str,
168        offer_identifier: &str,
169        transaction_id: Option<String>,
170    ) -> Result<String, JWSSignatureCreatorError> {
171        let base_payload = self.base.get_base_payload();
172        let payload = PromotionalOfferV2Payload {
173            base: base_payload,
174            product_id: product_id.to_string(),
175            offer_identifier: offer_identifier.to_string(),
176            transaction_id,
177        };
178        
179        self.base.create_signature(&payload)
180    }
181}
182
183/// Creator for Introductory Offer Eligibility signatures
184pub struct IntroductoryOfferEligibilitySignatureCreator {
185    base: JWSSignatureCreator,
186}
187
188impl IntroductoryOfferEligibilitySignatureCreator {
189    /// Creates a new `IntroductoryOfferEligibilitySignatureCreator` instance.
190    ///
191    /// # Arguments
192    ///
193    /// * `signing_key` - Your private key downloaded from App Store Connect (in PEM format)
194    /// * `key_id` - Your key ID from the Keys page in App Store Connect
195    /// * `issuer_id` - Your issuer ID from the Keys page in App Store Connect
196    /// * `bundle_id` - Your app's bundle ID
197    ///
198    /// # Returns
199    ///
200    /// A `Result` containing the `IntroductoryOfferEligibilitySignatureCreator` instance or an error.
201    pub fn new(
202        signing_key: &str,
203        key_id: String,
204        issuer_id: String,
205        bundle_id: String,
206    ) -> Result<Self, JWSSignatureCreatorError> {
207        let base = JWSSignatureCreator::new(
208            "introductory-offer-eligibility".to_string(),
209            signing_key,
210            key_id,
211            issuer_id,
212            bundle_id,
213        )?;
214        
215        Ok(Self { base })
216    }
217
218    /// Creates an introductory offer eligibility signature.
219    ///
220    /// # Arguments
221    ///
222    /// * `product_id` - The unique identifier of the product
223    /// * `allow_introductory_offer` - A boolean value that determines whether the customer 
224    ///   is eligible for an introductory offer
225    /// * `transaction_id` - The unique identifier of any transaction that belongs to the customer.
226    ///   You can use the customer's appTransactionId, even for customers who haven't made any 
227    ///   In-App Purchases in your app.
228    ///
229    /// # Returns
230    ///
231    /// A `Result` containing the signed JWS string or an error.
232    ///
233    /// # References
234    ///
235    /// [Generating JWS to sign App Store requests](https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests)
236    pub fn create_signature(
237        &self,
238        product_id: &str,
239        allow_introductory_offer: bool,
240        transaction_id: &str,
241    ) -> Result<String, JWSSignatureCreatorError> {
242        let base_payload = self.base.get_base_payload();
243        let payload = IntroductoryOfferEligibilityPayload {
244            base: base_payload,
245            product_id: product_id.to_string(),
246            allow_introductory_offer,
247            transaction_id: transaction_id.to_string(),
248        };
249        
250        self.base.create_signature(&payload)
251    }
252}
253
254/// Creator for Advanced Commerce In-App signatures
255pub struct AdvancedCommerceInAppSignatureCreator {
256    base: JWSSignatureCreator,
257}
258
259impl AdvancedCommerceInAppSignatureCreator {
260    /// Creates a new `AdvancedCommerceInAppSignatureCreator` instance.
261    ///
262    /// # Arguments
263    ///
264    /// * `signing_key` - Your private key downloaded from App Store Connect (in PEM format)
265    /// * `key_id` - Your key ID from the Keys page in App Store Connect
266    /// * `issuer_id` - Your issuer ID from the Keys page in App Store Connect
267    /// * `bundle_id` - Your app's bundle ID
268    ///
269    /// # Returns
270    ///
271    /// A `Result` containing the `AdvancedCommerceInAppSignatureCreator` instance or an error.
272    pub fn new(
273        signing_key: &str,
274        key_id: String,
275        issuer_id: String,
276        bundle_id: String,
277    ) -> Result<Self, JWSSignatureCreatorError> {
278        let base = JWSSignatureCreator::new(
279            "advanced-commerce-api".to_string(),
280            signing_key,
281            key_id,
282            issuer_id,
283            bundle_id,
284        )?;
285        
286        Ok(Self { base })
287    }
288
289    /// Creates an Advanced Commerce in-app signed request.
290    ///
291    /// # Arguments
292    ///
293    /// * `advanced_commerce_in_app_request` - The request to be signed.
294    ///
295    /// # Returns
296    ///
297    /// A `Result` containing the signed JWS string or an error.
298    ///
299    /// # References
300    ///
301    /// [Generating JWS to sign App Store requests](https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests)
302    pub fn create_signature<T: AdvancedCommerceInAppRequest>(
303        &self,
304        advanced_commerce_in_app_request: &T,
305    ) -> Result<String, JWSSignatureCreatorError> {
306        let json_data = serde_json::to_vec(advanced_commerce_in_app_request)?;
307        let base64_encoded_body = BASE64.encode(&json_data);
308        
309        let base_payload = self.base.get_base_payload();
310        let payload = AdvancedCommerceInAppPayload {
311            base: base_payload,
312            request: base64_encoded_body,
313        };
314        
315        self.base.create_signature(&payload)
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use serde_json::Value;
323
324    #[test]
325    fn test_promotional_offer_v2_signature_creator() {
326        let test_signing_key = include_str!("../resources/certs/testSigningKey.p8");
327        let creator = PromotionalOfferV2SignatureCreator::new(
328            test_signing_key,
329            "keyId".to_string(),
330            "issuerId".to_string(),
331            "bundleId".to_string(),
332        ).unwrap();
333
334        let signature = creator.create_signature(
335            "productId",
336            "offerIdentifier",
337            Some("transactionId".to_string()),
338        ).unwrap();
339
340        let parts: Vec<&str> = signature.split('.').collect();
341        assert_eq!(parts.len(), 3);
342
343        // Decode and verify header
344        let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
345            .decode(parts[0])
346            .unwrap();
347        let header: Value = serde_json::from_slice(&header_bytes).unwrap();
348        
349        assert_eq!(header["typ"], "JWT");
350        assert_eq!(header["alg"], "ES256");
351        assert_eq!(header["kid"], "keyId");
352
353        // Decode and verify payload
354        let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
355            .decode(parts[1])
356            .unwrap();
357        let payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
358
359        assert_eq!(payload["iss"], "issuerId");
360        assert!(payload["iat"].is_number());
361        assert!(payload["exp"].is_null());
362        assert_eq!(payload["aud"], "promotional-offer");
363        assert_eq!(payload["bid"], "bundleId");
364        assert!(payload["nonce"].is_string());
365        assert_eq!(payload["productId"], "productId");
366        assert_eq!(payload["offerIdentifier"], "offerIdentifier");
367        assert_eq!(payload["transactionId"], "transactionId");
368    }
369
370    #[test]
371    fn test_promotional_offer_v2_signature_creator_without_transaction_id() {
372        let test_signing_key = include_str!("../resources/certs/testSigningKey.p8");
373        let creator = PromotionalOfferV2SignatureCreator::new(
374            test_signing_key,
375            "keyId".to_string(),
376            "issuerId".to_string(),
377            "bundleId".to_string(),
378        ).unwrap();
379
380        let signature = creator.create_signature(
381            "productId",
382            "offerIdentifier",
383            None,
384        ).unwrap();
385
386        let parts: Vec<&str> = signature.split('.').collect();
387        let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
388            .decode(parts[1])
389            .unwrap();
390        let payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
391
392        assert!(payload["transactionId"].is_null());
393    }
394
395    #[test]
396    fn test_introductory_offer_eligibility_signature_creator() {
397        let test_signing_key = include_str!("../resources/certs/testSigningKey.p8");
398        let creator = IntroductoryOfferEligibilitySignatureCreator::new(
399            test_signing_key,
400            "keyId".to_string(),
401            "issuerId".to_string(),
402            "bundleId".to_string(),
403        ).unwrap();
404
405        let signature = creator.create_signature(
406            "productId",
407            true,
408            "transactionId",
409        ).unwrap();
410
411        let parts: Vec<&str> = signature.split('.').collect();
412        assert_eq!(parts.len(), 3);
413
414        // Decode and verify header
415        let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
416            .decode(parts[0])
417            .unwrap();
418        let header: Value = serde_json::from_slice(&header_bytes).unwrap();
419        
420        assert_eq!(header["typ"], "JWT");
421        assert_eq!(header["alg"], "ES256");
422        assert_eq!(header["kid"], "keyId");
423
424        // Decode and verify payload
425        let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
426            .decode(parts[1])
427            .unwrap();
428        let payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
429
430        assert_eq!(payload["iss"], "issuerId");
431        assert!(payload["iat"].is_number());
432        assert!(payload["exp"].is_null());
433        assert_eq!(payload["aud"], "introductory-offer-eligibility");
434        assert_eq!(payload["bid"], "bundleId");
435        assert!(payload["nonce"].is_string());
436        assert_eq!(payload["productId"], "productId");
437        assert_eq!(payload["allowIntroductoryOffer"], true);
438        assert_eq!(payload["transactionId"], "transactionId");
439    }
440
441    #[derive(Debug, Serialize)]
442    struct TestInAppRequest {
443        #[serde(rename = "testData")]
444        test_data: String,
445    }
446
447    impl AdvancedCommerceInAppRequest for TestInAppRequest {}
448
449    #[test]
450    fn test_advanced_commerce_in_app_signature_creator() {
451        let test_signing_key = include_str!("../resources/certs/testSigningKey.p8");
452        let creator = AdvancedCommerceInAppSignatureCreator::new(
453            test_signing_key,
454            "keyId".to_string(),
455            "issuerId".to_string(),
456            "bundleId".to_string(),
457        ).unwrap();
458
459        let in_app_request = TestInAppRequest {
460            test_data: "testData".to_string(),
461        };
462
463        let signature = creator.create_signature(&in_app_request).unwrap();
464
465        let parts: Vec<&str> = signature.split('.').collect();
466        assert_eq!(parts.len(), 3);
467
468        // Decode and verify header
469        let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
470            .decode(parts[0])
471            .unwrap();
472        let header: Value = serde_json::from_slice(&header_bytes).unwrap();
473        
474        assert_eq!(header["typ"], "JWT");
475        assert_eq!(header["alg"], "ES256");
476        assert_eq!(header["kid"], "keyId");
477
478        // Decode and verify payload
479        let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
480            .decode(parts[1])
481            .unwrap();
482        let payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
483
484        assert_eq!(payload["iss"], "issuerId");
485        assert!(payload["iat"].is_number());
486        assert!(payload["exp"].is_null());
487        assert_eq!(payload["aud"], "advanced-commerce-api");
488        assert_eq!(payload["bid"], "bundleId");
489        assert!(payload["nonce"].is_string());
490        
491        // Verify the request field
492        let base64_encoded_request = payload["request"].as_str().unwrap();
493        let request_data = BASE64.decode(base64_encoded_request).unwrap();
494        let decoded_request: Value = serde_json::from_slice(&request_data).unwrap();
495        assert_eq!(decoded_request["testData"], "testData");
496    }
497
498}