Skip to content
Open
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
19 changes: 15 additions & 4 deletions Sources/APIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public struct APIConfiguration {
/// The token's expiration duration in seconds. Tokens that expire more than 20 minutes in the future are not valid, so set it to a max of 20 minutes.
let expirationDuration: TimeInterval

/// Optional JWT scope claim for APIs that require restricted access (e.g. `["/notary/v2"]` for the Notary API).
let scope: [String]?

/// The range of values allowed for the expiration duration of the token.
private let allowedExpirationDurationRange: ClosedRange<TimeInterval> = 0...1200

Expand All @@ -42,9 +45,11 @@ public struct APIConfiguration {
/// - privateKeyID: Your private key ID from App Store Connect (Ex: 2X9R4HXF34)
/// - privateKey: Your private key stripped out of the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines.
/// - expirationDuration: The token's expiration duration in seconds. Tokens that expire more than 20 minutes in the future are not valid, so set it to a max of 20 minutes. Defaults to 20 minutes.
public init(issuerID: String, privateKeyID: String, privateKey: String, expirationDuration: TimeInterval = 60 * 20) throws {
/// - scope: Optional JWT scope claim for APIs that require restricted access (e.g. `["/notary/v2"]` for the Notary API).
public init(issuerID: String, privateKeyID: String, privateKey: String, expirationDuration: TimeInterval = 60 * 20, scope: [String]? = nil) throws {
self.privateKeyID = privateKeyID
self.issuerID = issuerID
self.scope = scope
guard let base64Key = Data(base64Encoded: privateKey) else {
throw JWT.Error.invalidBase64EncodedPrivateKey
}
Expand All @@ -65,9 +70,11 @@ public struct APIConfiguration {
/// - individualPrivateKeyID: Your private key ID from App Store Connect (Ex: 2X9R4HXF34)
/// - individualPrivateKey: The contents of the individual private key from App Store Connect
/// - expirationDuration: The token's expiration duration in seconds. Tokens that expire more than 20 minutes in the future are not valid, so set it to a max of 20 minutes. Defaults to 20 minutes.
public init(individualPrivateKeyID: String, individualPrivateKey: String, expirationDuration: TimeInterval = 60 * 20) throws {
/// - scope: Optional JWT scope claim for APIs that require restricted access.
public init(individualPrivateKeyID: String, individualPrivateKey: String, expirationDuration: TimeInterval = 60 * 20, scope: [String]? = nil) throws {
self.privateKeyID = individualPrivateKeyID
self.issuerID = nil
self.scope = scope

guard let base64Key = Data(base64Encoded: individualPrivateKey) else {
throw JWT.Error.invalidBase64EncodedPrivateKey
Expand All @@ -90,8 +97,10 @@ public struct APIConfiguration {
/// - privateKeyID: Your private key ID from App Store Connect (Ex: 2X9R4HXF34). Will be inferred from `privateKeyURL` if nil.
/// - privateKeyURL: A file URL that references the path to your private key file.
/// - expirationDuration: The token's expiration duration in seconds. Tokens that expire more than 20 minutes in the future are not valid, so set it to a max of 20 minutes. Defaults to 20 minutes.
public init(issuerID: String, privateKeyID: String? = nil, privateKeyURL: URL, expirationDuration: TimeInterval = 60 * 20) throws {
/// - scope: Optional JWT scope claim for APIs that require restricted access.
public init(issuerID: String, privateKeyID: String? = nil, privateKeyURL: URL, expirationDuration: TimeInterval = 60 * 20, scope: [String]? = nil) throws {
self.issuerID = issuerID
self.scope = scope
if let privateKeyID = privateKeyID {
self.privateKeyID = privateKeyID
} else {
Expand All @@ -116,8 +125,10 @@ public struct APIConfiguration {
/// - individualPrivateKeyID: Your private key ID from App Store Connect (Ex: 2X9R4HXF34). Will be inferred from `privateKeyURL` if nil.
/// - individualPrivateKeyURL: A file URL that references the path to your private key file.
/// - expirationDuration: The token's expiration duration in seconds. Tokens that expire more than 20 minutes in the future are not valid, so set it to a max of 20 minutes. Defaults to 20 minutes.
public init(individualPrivateKeyID: String? = nil, privateKeyURL: URL, expirationDuration: TimeInterval = 60 * 20) throws {
/// - scope: Optional JWT scope claim for APIs that require restricted access.
public init(individualPrivateKeyID: String? = nil, privateKeyURL: URL, expirationDuration: TimeInterval = 60 * 20, scope: [String]? = nil) throws {
self.issuerID = nil
self.scope = scope
if let individualPrivateKeyID {
self.privateKeyID = individualPrivateKeyID
} else {
Expand Down
20 changes: 13 additions & 7 deletions Sources/Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,40 +25,46 @@ struct AnyEncodable: Encodable {
/// Credits to the https://github.com/kean/Get repository for this class.
/// We've copied this over since it works nicely together with the CreateAPI OpenAPI generator.
public struct Request<Response> {
/// The default App Store Connect API base URL.
public static var defaultBaseURL: URL {
guard let url = URL(string: "https://api.appstoreconnect.apple.com") else {
fatalError("Invalid App Store Connect API base URL")
}
return url
}

public var method: String
public var path: String
public var query: [(String, String?)]?
var body: AnyEncodable?
public var headers: [String: String]?
public var id: String?
public var baseURL: URL

public init(path: String, method: String, query: [(String, String?)]? = nil, headers: [String: String]? = nil, id: String) {
public init(path: String, method: String, query: [(String, String?)]? = nil, headers: [String: String]? = nil, id: String, baseURL: URL = Self.defaultBaseURL) {
self.method = method
self.path = path
self.query = query
self.headers = headers
self.id = id
self.baseURL = baseURL
}

public init<U: Encodable>(path: String, method: String, query: [(String, String?)]? = nil, body: U?, headers: [String: String]? = nil, id: String) {
public init<U: Encodable>(path: String, method: String, query: [(String, String?)]? = nil, body: U?, headers: [String: String]? = nil, id: String, baseURL: URL = Self.defaultBaseURL) {
self.method = method
self.path = path
self.query = query
self.body = body.map(AnyEncodable.init)
self.headers = headers
self.id = id
self.baseURL = baseURL
}
}

// MARK: - URLRequestConvertible

extension Request {

internal var baseURL: URL {
// swiftlint:disable:next force_unwrapping
return URL(string: "https://api.appstoreconnect.apple.com")!
}

private func makeURL(path: String, query: [(String, String?)]?) throws -> URL {
let url = baseURL.appendingPathComponent(path)
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
Expand Down
29 changes: 22 additions & 7 deletions Sources/JWT/JWT.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,23 @@ private struct TeamPayload: Codable {
case expirationTime = "exp"
case audience = "aud"
case issuedAtTime = "iat"
case scope
}

/// Your issuer identifier from the API Keys page in App Store Connect (Ex: 57246542-96fe-1a63-e053-0824d011072a)
let issuerIdentifier: String

/// The token's expiration time, in Unix epoch time; tokens that expire more than 20 minutes in the future are not valid (Ex: 1528408800)
/// The tokens expiration time, in Unix epoch time; tokens that expire more than 20 minutes in the future are not valid (Ex: 1528408800)
let expirationTime: TimeInterval

/// The token’s creation time, in UNIX epoch time (Ex: 1528407600)
let issuedAtTime: TimeInterval

/// The required audience which is set to the App Store Connect version.
let audience: String = "appstoreconnect-v1"

/// Optional scope claim for APIs that require restricted access (e.g. `["/notary/v2"]`).
let scope: [String]?
}

private struct IndividualPayload: Codable {
Expand All @@ -57,19 +61,23 @@ private struct IndividualPayload: Codable {
case expirationTime = "exp"
case audience = "aud"
case issuedAtTime = "iat"
case scope
}

/// The subject to pass to the payload when using individual keys
let subject: String = "user"

/// The token's expiration time, in Unix epoch time; tokens that expire more than 20 minutes in the future are not valid (Ex: 1528408800)
/// The tokens expiration time, in Unix epoch time; tokens that expire more than 20 minutes in the future are not valid (Ex: 1528408800)
let expirationTime: TimeInterval

/// The token’s creation time, in UNIX epoch time (Ex: 1528407600)
let issuedAtTime: TimeInterval

/// The required audience which is set to the App Store Connect version.
let audience: String = "appstoreconnect-v1"

/// Optional scope claim for APIs that require restricted access (e.g. `["/notary/v2"]`).
let scope: [String]?
}

protocol JWTCreatable {
Expand Down Expand Up @@ -116,16 +124,21 @@ public struct JWT: Codable, JWTCreatable {
/// The token's expiration duration in seconds. Tokens that expire more than 20 minutes in the future are not valid, so set it to a max of 20 minutes.
private let expireDuration: TimeInterval

/// Optional scope claim for APIs that require restricted access (e.g. the Notary API uses `["/notary/v2"]`).
private let scope: [String]?

/// Creates a new JWT Factory to create signed requests for the App Store Connect API.
///
/// - Parameters:
/// - keyIdentifier: Your private key ID from App Store Connect (Ex: 2X9R4HXF34)
/// - issuerIdentifier: Your issuer identifier from the API Keys page in App Store Connect (Ex: 57246542-96fe-1a63-e053-0824d011072a)
/// - expireDuration: The token's expiration duration in seconds. Tokens that expire more than 20 minutes in the future are not valid, so set it to a max of 20 minutes.
public init(keyIdentifier: String, issuerIdentifier: String?, expireDuration: TimeInterval) {
/// - scope: Optional scope claim for APIs that require restricted access. For example, the Notary API requires `["/notary/v2"]`.
public init(keyIdentifier: String, issuerIdentifier: String?, expireDuration: TimeInterval, scope: [String]? = nil) {
header = Header(keyIdentifier: keyIdentifier)
self.issuerIdentifier = issuerIdentifier
self.expireDuration = expireDuration
self.scope = scope
}

/// Combine the header and the payload as a digest for signing.
Expand All @@ -137,12 +150,14 @@ public struct JWT: Codable, JWTCreatable {
payload = TeamPayload(
issuerIdentifier: issuerIdentifier,
expirationTime: expirationTime,
issuedAtTime: now.timeIntervalSince1970
issuedAtTime: now.timeIntervalSince1970,
scope: scope
)
} else {
payload = IndividualPayload(
expirationTime: expirationTime,
issuedAtTime: now.timeIntervalSince1970
issuedAtTime: now.timeIntervalSince1970,
scope: scope
)
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/JWT/JWTRequestsAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class JWTRequestsAuthenticator {

init(apiConfiguration: APIConfiguration) {
self.apiConfiguration = apiConfiguration
self.jwtCreator = JWT(keyIdentifier: apiConfiguration.privateKeyID, issuerIdentifier: apiConfiguration.issuerID, expireDuration: apiConfiguration.expirationDuration)
self.jwtCreator = JWT(keyIdentifier: apiConfiguration.privateKeyID, issuerIdentifier: apiConfiguration.issuerID, expireDuration: apiConfiguration.expirationDuration, scope: apiConfiguration.scope)
}

/// Generates a new JWT Token, but only if the in memory cached one is not expired.
Expand Down