190 lines
5.5 KiB
Swift
190 lines
5.5 KiB
Swift
// ===----------------------------------------------------------------------===
|
|
//
|
|
// This source file is part of the ASConnectService open source project
|
|
//
|
|
// Copyright (c) 2026 Röck+Cöde VoF. and the ASConnectService project authors
|
|
// Licensed under Apache license v2.0
|
|
//
|
|
// See LICENSE for license information
|
|
// See CONTRIBUTORS for the list of ASConnectService project authors
|
|
//
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
//
|
|
// ===----------------------------------------------------------------------===
|
|
|
|
#if canImport(CryptoKit)
|
|
import enum CryptoKit.P256
|
|
import struct Foundation.Data
|
|
import struct Foundation.Date
|
|
import class Foundation.JSONEncoder
|
|
import typealias Foundation.TimeInterval
|
|
|
|
/// A token provider that signs App Store Connect API tokens with an API key.
|
|
///
|
|
/// Each JSON Web Token (JWT) is reused until a minute before it expires:
|
|
///
|
|
/// ```swift
|
|
/// let tokenProvider = try APIKeyTokenProvider(
|
|
/// issuerID: "57246542-96fe-1a63-e053-0824d011072a",
|
|
/// keyID: "2X9R4HXF34",
|
|
/// privateKey: privateKey
|
|
/// )
|
|
///
|
|
/// let middleware = BearerAuthMiddleware {
|
|
/// try await tokenProvider.token()
|
|
/// }
|
|
/// ```
|
|
public actor APIKeyTokenProvider {
|
|
// MARK: Properties
|
|
|
|
/// The issuer ID of a team key, or `nil` for an individual key.
|
|
private let issuerID: String?
|
|
|
|
/// The ID of the API key.
|
|
private let keyID: String
|
|
|
|
/// The private key that signs the tokens.
|
|
private let privateKey: P256.Signing.PrivateKey
|
|
|
|
/// The closure that provides the current date.
|
|
private let now: @Sendable () -> Date
|
|
|
|
/// The last signed token, and its expiration date.
|
|
private var current: (token: String, expiresAt: Date)?
|
|
|
|
// MARK: Initializers
|
|
|
|
/// Creates a token provider with an App Store Connect API key.
|
|
/// - Parameters:
|
|
/// - issuerID: The issuer ID of a team key, or `nil` for an individual key.
|
|
/// - keyID: The ID of the key.
|
|
/// - privateKey: The contents of the key's `.p8` file.
|
|
/// - Throws: An error if `privateKey` is not a P-256 private key in PEM format.
|
|
public init(
|
|
issuerID: String?,
|
|
keyID: String,
|
|
privateKey: String
|
|
) throws {
|
|
try self.init(
|
|
issuerID: issuerID,
|
|
keyID: keyID,
|
|
privateKey: privateKey,
|
|
now: { Date() }
|
|
)
|
|
}
|
|
|
|
/// Creates a token provider that reads the current date from `now`, for testing.
|
|
init(
|
|
issuerID: String?,
|
|
keyID: String,
|
|
privateKey: String,
|
|
now: @escaping @Sendable () -> Date
|
|
) throws {
|
|
self.issuerID = issuerID
|
|
self.keyID = keyID
|
|
self.privateKey = try P256.Signing.PrivateKey(pemRepresentation: privateKey)
|
|
self.now = now
|
|
}
|
|
|
|
// MARK: Methods
|
|
|
|
/// Returns the current token, or signs a new one if it expires within a minute.
|
|
/// - Returns: A JSON Web Token (JWT) valid for 20 minutes at most.
|
|
/// - Throws: An error if the token cannot be signed.
|
|
public func token() throws -> String {
|
|
let date = now()
|
|
|
|
if let current, current.expiresAt.timeIntervalSince(date) > .refreshMargin {
|
|
return current.token
|
|
}
|
|
|
|
let expiresAt = date.addingTimeInterval(.lifetime)
|
|
let token = try sign(
|
|
issuedAt: date,
|
|
expiresAt: expiresAt
|
|
)
|
|
|
|
current = (token, expiresAt)
|
|
|
|
return token
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
extension APIKeyTokenProvider {
|
|
// MARK: Functions
|
|
|
|
/// Signs a new token.
|
|
/// - Parameters:
|
|
/// - issuedAt: The date the token is issued at.
|
|
/// - expiresAt: The date the token expires at.
|
|
/// - Returns: A JSON Web Token (JWT) signed with the ES256 algorithm.
|
|
/// - Throws: An error if the token cannot be encoded or signed.
|
|
private func sign(
|
|
issuedAt: Date,
|
|
expiresAt: Date
|
|
) throws -> String {
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = .sortedKeys
|
|
|
|
let header = try encoder.encode(Header(kid: keyID))
|
|
let payload = try encoder.encode(
|
|
Payload(
|
|
iss: issuerID,
|
|
sub: issuerID == nil ? "user" : nil,
|
|
iat: Int(issuedAt.timeIntervalSince1970),
|
|
exp: Int(expiresAt.timeIntervalSince1970)
|
|
)
|
|
)
|
|
|
|
let message = "\(header.base64URLEncoded).\(payload.base64URLEncoded)"
|
|
let signature = try privateKey.signature(for: Data(message.utf8))
|
|
|
|
return "\(message).\(signature.rawRepresentation.base64URLEncoded)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
/// The header of a token.
|
|
private struct Header: Encodable {
|
|
let alg = "ES256"
|
|
let kid: String
|
|
let typ = "JWT"
|
|
}
|
|
|
|
// MARK: - Payload
|
|
|
|
/// The payload of a token.
|
|
private struct Payload: Encodable {
|
|
let iss: String?
|
|
let sub: String?
|
|
let iat: Int
|
|
let exp: Int
|
|
let aud = "appstoreconnect-v1"
|
|
}
|
|
|
|
// MARK: - Data+Base64URL
|
|
|
|
private extension Data {
|
|
/// The Base64URL encoding of the data, without padding.
|
|
var base64URLEncoded: String {
|
|
base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
}
|
|
}
|
|
|
|
// MARK: - TimeInterval+Constants
|
|
|
|
private extension TimeInterval {
|
|
/// The lifetime of a token, which App Store Connect limits to 20 minutes.
|
|
static let lifetime: TimeInterval = 20 * 60
|
|
|
|
/// The time before the expiration of a token when a new one is signed.
|
|
static let refreshMargin: TimeInterval = 60
|
|
}
|
|
#endif
|