Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,14 @@ let encodedKey = try! String(contentsOfFile: "/path/to/key/SubscriptionKey_ABCDE

let productId = "<product_id>"
let subscriptionOfferId = "<subscription_offer_id>"
let applicationUsername = "<application_username>"
let appAccountToken = "<app_account_token>"

// try! used for example purposes only
let signatureCreator = try! PromotionalOfferSignatureCreator(privateKey: encodedKey, keyId: keyId, bundleId: bundleId)

let nonce = UUID()
let timestamp = Int64(Date().timeIntervalSince1970) * 1000
let signature = signatureCreator.createSignature(productIdentifier: productIdentifier, subscriptionOfferID: subscriptionOfferID, applicationUsername: applicationUsername, nonce: nonce, timestamp: timestamp)
let signature = signatureCreator.createSignature(productIdentifier: productIdentifier, subscriptionOfferID: subscriptionOfferID, appAccountToken: appAccountToken, nonce: nonce, timestamp: timestamp)
print(signature)
```

Expand Down
6 changes: 5 additions & 1 deletion Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,6 @@ public class AppStoreServerAPIClient {
var bid: String
var aud: AudienceClaim
var iat: IssuedAtClaim

func verify(using algorithm: some JWTAlgorithm) async throws {
fatalError("Do not attempt to locally verify a JWT")
}
Expand Down Expand Up @@ -548,6 +547,11 @@ public enum APIError: Int64 {
///[InvalidTransactionTypeNotSupportedError](https://developer.apple.com/documentation/appstoreserverapi/invalidtransactiontypenotsupportederror)
case invalidTransactionTypeNotSupported = 4000047

///An error that indicates the endpoint doesn't support an app transaction ID.
///
///[AppTransactionIdNotSupportedError](https://developer.apple.com/documentation/appstoreserverapi/apptransactionidnotsupportederror)
case appTransactionIdNotSupported = 4000048

///An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state.
///
///[SubscriptionExtensionIneligibleError](https://developer.apple.com/documentation/appstoreserverapi/subscriptionextensionineligibleerror)
Expand Down
229 changes: 229 additions & 0 deletions Sources/AppStoreServerLibrary/JWSSignatureCreator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2025 Apple Inc. Licensed under MIT License.

import Foundation
import JWTKit
import Crypto

fileprivate protocol BasePayload: Codable {
var nonce: String { get }
var iss: IssuerClaim { get }
var bid: String { get }
var aud: AudienceClaim { get }
var iat: IssuedAtClaim { get }
}

fileprivate class BasePayloadObject: BasePayload {
let nonce: String
let iss: IssuerClaim
let bid: String
let aud: AudienceClaim
let iat: IssuedAtClaim
init(nonce: String, iss: IssuerClaim, bid: String, aud: AudienceClaim, iat: IssuedAtClaim) {
self.nonce = nonce
self.iss = iss
self.bid = bid
self.aud = aud
self.iat = iat
}
}

fileprivate final class PromotionalOfferV2Payload: BasePayload, JWTPayload {

let nonce: String
let iss: IssuerClaim
let bid: String
let aud: AudienceClaim
let iat: IssuedAtClaim
let productId: String
let offerIdentifier: String
let transactionId: String?

init(basePayload: BasePayload, productId: String, offerIdentifier: String, transactionId: String? = nil) {
self.productId = productId
self.offerIdentifier = offerIdentifier
self.transactionId = transactionId
self.nonce = basePayload.nonce
self.iss = basePayload.iss
self.bid = basePayload.bid
self.aud = basePayload.aud
self.iat = basePayload.iat
}

required init(from decoder: any Decoder) throws {
fatalError("Do not attempt to decode a JWS locally")
}

func verify(using algorithm: some JWTKit.JWTAlgorithm) async throws {
fatalError("Do not attempt to locally verify a JWS")
}
}

fileprivate final class IntroductoryOfferEligibilityPayload: BasePayload, JWTPayload {
let nonce: String
let iss: IssuerClaim
let bid: String
let aud: AudienceClaim
let iat: IssuedAtClaim
let productId: String
let allowIntroductoryOffer: Bool
let transactionId: String

init(basePayload: BasePayload, productId: String, allowIntroductoryOffer: Bool, transactionId: String) {
self.productId = productId
self.allowIntroductoryOffer = allowIntroductoryOffer
self.transactionId = transactionId
self.nonce = basePayload.nonce
self.iss = basePayload.iss
self.bid = basePayload.bid
self.aud = basePayload.aud
self.iat = basePayload.iat
}

required init(from decoder: any Decoder) throws {
fatalError("Do not attempt to decode a JWS locally")
}

func verify(using algorithm: some JWTKit.JWTAlgorithm) async throws {
fatalError("Do not attempt to locally verify a JWS")
}
}

fileprivate final class AdvancedCommerceInAppPayload: BasePayload, JWTPayload {
let nonce: String
let iss: IssuerClaim
let bid: String
let aud: AudienceClaim
let iat: IssuedAtClaim
let request: String

init(basePayload: BasePayload, request: String) {
self.request = request
self.nonce = basePayload.nonce
self.iss = basePayload.iss
self.bid = basePayload.bid
self.aud = basePayload.aud
self.iat = basePayload.iat
}

required init(from decoder: any Decoder) throws {
fatalError("Do not attempt to decode a JWS locally")
}

func verify(using algorithm: some JWTKit.JWTAlgorithm) async throws {
fatalError("Do not attempt to locally verify a JWS")
}
}

public class JWSSignatureCreator {

private let audience: String
private let signingKey: P256.Signing.PrivateKey
private let keyId: String
private let issuerId: String
private let bundleId: String

init(audience: String, signingKey: String, keyId: String, issuerId: String, bundleId: String) throws {
self.audience = audience
self.signingKey = try P256.Signing.PrivateKey(pemRepresentation: signingKey)
self.keyId = keyId
self.issuerId = issuerId
self.bundleId = bundleId
}

fileprivate func getBasePayload() -> BasePayload {
return BasePayloadObject(
nonce: UUID().uuidString,
iss: .init(value: self.issuerId),
bid: self.bundleId,
aud: .init(value: self.audience),
iat: .init(value: Date())
)
}

fileprivate func createSignature(payload: JWTPayload) async throws -> String {
let keys = JWTKeyCollection()
try await keys.add(ecdsa: ECDSA.PrivateKey<P256>(backing: signingKey))
return try await keys.sign(payload, header: ["typ": "JWT", "kid": .string(self.keyId)])
}
}

public class PromotionalOfferV2SignatureCreator: JWSSignatureCreator {
///Create a PromotionalOfferV2SignatureCreator
///
///- Parameter signingKey: Your private key downloaded from App Store Connect
///- Parameter issuerId: Your issuer ID from the Keys page in App Store Connect
///- Parameter bundleId: Your app’s bundle ID
///- Parameter environment: The environment to target
public init(signingKey: String, keyId: String, issuerId: String, bundleId: String) throws {
try super.init(audience: "promotional-offer", signingKey: signingKey, keyId: keyId, issuerId: issuerId, bundleId: bundleId)
}

///Create a promotional offer V2 signature.
///
///- Parameter productId: The unique identifier of the product
///- Parameter offerIdentifier: The promotional offer identifier that you set up in App Store Connect
///- Parameter transactionId: The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app. This field is optional, but recommended.
///- Returns: The signed JWS.
///[Generating JWS to sign App Store requests](https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests)
public func createSignature(productId: String, offerIdentifier: String, transactionId: String? = nil) async throws -> String {
let baseClaims = super.getBasePayload()
let claims = PromotionalOfferV2Payload(basePayload: baseClaims, productId: productId, offerIdentifier: offerIdentifier, transactionId: transactionId)
return try await super.createSignature(payload: claims)
}
}

public class IntroductoryOfferEligibilitySignatureCreator: JWSSignatureCreator {
///Create a IntroductoryOfferEligibilitySignatureCreator
///
///- Parameter signingKey: Your private key downloaded from App Store Connect
///- Parameter issuerId: Your issuer ID from the Keys page in App Store Connect
///- Parameter bundleId: Your app’s bundle ID
///- Parameter environment: The environment to target
public init(signingKey: String, keyId: String, issuerId: String, bundleId: String) throws {
try super.init(audience: "introductory-offer-eligibility", signingKey: signingKey, keyId: keyId, issuerId: issuerId, bundleId: bundleId)
}

///Create an introductory offer eligibility signature.
///
///- Parameter productId: The unique identifier of the product
///- Parameter allowIntroductoryOffer: A boolean value that determines whether the customer is eligible for an introductory offer
///- Parameter transactionId: The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app.
///- Returns: The signed JWS.
///[Generating JWS to sign App Store requests](https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests)
public func createSignature(productId: String, allowIntroductoryOffer: Bool, transactionId: String) async throws -> String {
let baseClaims = super.getBasePayload()
let claims = IntroductoryOfferEligibilityPayload(basePayload: baseClaims, productId: productId, allowIntroductoryOffer: allowIntroductoryOffer, transactionId: transactionId)
return try await super.createSignature(payload: claims)
}
}

public protocol AdvancedCommerceInAppRequest: Encodable {

}

public class AdvancedCommerceInAppSignatureCreator: JWSSignatureCreator {
///Create a AdvancedCommerceInAppSignatureCreator
///
///- Parameter signingKey: Your private key downloaded from App Store Connect
///- Parameter issuerId: Your issuer ID from the Keys page in App Store Connect
///- Parameter bundleId: Your app’s bundle ID
///- Parameter environment: The environment to target
public init(signingKey: String, keyId: String, issuerId: String, bundleId: String) throws {
try super.init(audience: "advanced-commerce-api", signingKey: signingKey, keyId: keyId, issuerId: issuerId, bundleId: bundleId)
}

///Create an Advanced Commerce in-app signed request.
///
///- Parameter advancedCommerceInAppRequest: The request to be signed.
///- Returns: The signed JWS.
///[Generating JWS to sign App Store requests](https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests)
public func createSignature(advancedCommerceInAppRequest: AdvancedCommerceInAppRequest) async throws -> String {
let jsonEncoder = getJsonEncoder()
let body = try jsonEncoder.encode(advancedCommerceInAppRequest)

let base64EncodedBody = body.base64EncodedString()
let baseClaims = super.getBasePayload()
let claims = AdvancedCommerceInAppPayload(basePayload: baseClaims, request: base64EncodedBody)
return try await super.createSignature(payload: claims)
}
}
34 changes: 32 additions & 2 deletions Sources/AppStoreServerLibrary/Models/AppTransaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Foundation
///[AppTransaction](https://developer.apple.com/documentation/storekit/apptransaction)
public struct AppTransaction: DecodedSignedData, Decodable, Encodable, Hashable, Sendable {

public init(receiptType: AppStoreEnvironment? = nil, appAppleId: Int64? = nil, bundleId: String? = nil, applicationVersion: String? = nil, versionExternalIdentifier: Int64? = nil, receiptCreationDate: Date? = nil, originalPurchaseDate: Date? = nil, originalApplicationVersion: String? = nil, deviceVerification: String? = nil, deviceVerificationNonce: UUID? = nil, preorderDate: Date? = nil) {
public init(receiptType: AppStoreEnvironment? = nil, appAppleId: Int64? = nil, bundleId: String? = nil, applicationVersion: String? = nil, versionExternalIdentifier: Int64? = nil, receiptCreationDate: Date? = nil, originalPurchaseDate: Date? = nil, originalApplicationVersion: String? = nil, deviceVerification: String? = nil, deviceVerificationNonce: UUID? = nil, preorderDate: Date? = nil, appTransactionId: String? = nil, originalPlatform: PurchasePlatform? = nil) {
self.receiptType = receiptType
self.appAppleId = appAppleId
self.bundleId = bundleId
Expand All @@ -20,9 +20,11 @@ public struct AppTransaction: DecodedSignedData, Decodable, Encodable, Hashable,
self.deviceVerification = deviceVerification
self.deviceVerificationNonce = deviceVerificationNonce
self.preorderDate = preorderDate
self.appTransactionId = appTransactionId
self.originalPlatform = originalPlatform
}

public init(rawReceiptType: String? = nil, appAppleId: Int64? = nil, bundleId: String? = nil, applicationVersion: String? = nil, versionExternalIdentifier: Int64? = nil, receiptCreationDate: Date? = nil, originalPurchaseDate: Date? = nil, originalApplicationVersion: String? = nil, deviceVerification: String? = nil, deviceVerificationNonce: UUID? = nil, preorderDate: Date? = nil) {
public init(rawReceiptType: String? = nil, appAppleId: Int64? = nil, bundleId: String? = nil, applicationVersion: String? = nil, versionExternalIdentifier: Int64? = nil, receiptCreationDate: Date? = nil, originalPurchaseDate: Date? = nil, originalApplicationVersion: String? = nil, deviceVerification: String? = nil, deviceVerificationNonce: UUID? = nil, preorderDate: Date? = nil, appTransactionId: String? = nil, rawOriginalPlatform: String? = nil) {
self.rawReceiptType = rawReceiptType
self.appAppleId = appAppleId
self.bundleId = bundleId
Expand All @@ -34,6 +36,8 @@ public struct AppTransaction: DecodedSignedData, Decodable, Encodable, Hashable,
self.deviceVerification = deviceVerification
self.deviceVerificationNonce = deviceVerificationNonce
self.preorderDate = preorderDate
self.appTransactionId = appTransactionId
self.rawOriginalPlatform = rawOriginalPlatform
}

///The server environment that signs the app transaction.
Expand Down Expand Up @@ -108,6 +112,26 @@ public struct AppTransaction: DecodedSignedData, Decodable, Encodable, Hashable,
public var signedDate: Date? {
receiptCreationDate
}

///The unique identifier of the app download transaction.
///
///[appTransactionId](https://developer.apple.com/documentation/storekit/apptransaction/apptransactionid)
public var appTransactionId: String?

///The platform on which the customer originally purchased the app.
///
///[originalPlatform](https://developer.apple.com/documentation/storekit/apptransaction/originalplatform-4mogz)
public var originalPlatform: PurchasePlatform? {
get {
return rawOriginalPlatform.flatMap { PurchasePlatform(rawValue: $0) }
}
set {
self.rawOriginalPlatform = newValue.map { $0.rawValue }
}
}

///See ``originalPlatform``
public var rawOriginalPlatform: String?


public enum CodingKeys: CodingKey {
Expand All @@ -122,6 +146,8 @@ public struct AppTransaction: DecodedSignedData, Decodable, Encodable, Hashable,
case deviceVerification
case deviceVerificationNonce
case preorderDate
case appTransactionId
case originalPlatform
}

public init(from decoder: any Decoder) throws {
Expand All @@ -137,6 +163,8 @@ public struct AppTransaction: DecodedSignedData, Decodable, Encodable, Hashable,
self.deviceVerification = try container.decodeIfPresent(String.self, forKey: .deviceVerification)
self.deviceVerificationNonce = try container.decodeIfPresent(UUID.self, forKey: .deviceVerificationNonce)
self.preorderDate = try container.decodeIfPresent(Date.self, forKey: .preorderDate)
self.appTransactionId = try container.decodeIfPresent(String.self, forKey: .appTransactionId)
self.rawOriginalPlatform = try container.decodeIfPresent(String.self, forKey: .originalPlatform)
}

public func encode(to encoder: any Encoder) throws {
Expand All @@ -152,5 +180,7 @@ public struct AppTransaction: DecodedSignedData, Decodable, Encodable, Hashable,
try container.encodeIfPresent(self.deviceVerification, forKey: .deviceVerification)
try container.encodeIfPresent(self.deviceVerificationNonce, forKey: .deviceVerificationNonce)
try container.encodeIfPresent(self.preorderDate, forKey: .preorderDate)
try container.encodeIfPresent(self.appTransactionId, forKey: .appTransactionId)
try container.encodeIfPresent(self.rawOriginalPlatform, forKey: .originalPlatform)
}
}
Loading