Files
asconnect-service/Sources/ASConnectService/Sources/Public/Providers/APIKeyTokenProvider.swift
T

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