diff --git a/Package.swift b/Package.swift index 5366b5462d..387b20cc36 100644 --- a/Package.swift +++ b/Package.swift @@ -18,11 +18,11 @@ import PackageDescription let package = Package( name: AppStoreConnectService.package, platforms: [ - .iOS(.v13), - .macOS(.v10_15), - .tvOS(.v13), + .iOS(.v14), + .macOS(.v11), + .tvOS(.v14), .visionOS(.v1), - .watchOS(.v6) + .watchOS(.v7) ], products: [ .library( diff --git a/README.md b/README.md index 63e03c9582..52e48cf687 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,17 @@ let client = Client( ) ``` -Tokens expire after 20 minutes at most. For longer-lived clients, pass a token provider instead, which is called on every request: +Tokens expire after 20 minutes at most. For longer-lived clients, use `APIKeyTokenProvider`, which signs new ones with your API key as needed: ```swift +let tokenProvider = try APIKeyTokenProvider( + issuerID: yourIssuerID, + keyID: yourKeyID, + privateKey: yourP8FileContents +) + BearerAuthMiddleware { - try await tokenStore.validToken() + try await tokenProvider.token() } ``` @@ -82,11 +88,11 @@ repeat { ## Supported Platforms -- iOS 13.0+ -- macOS 10.15+ -- tvOS 13.0+ +- iOS 14.0+ +- macOS 11.0+ +- tvOS 14.0+ - visionOS 1.0+ -- watchOS 6.0+ +- watchOS 7.0+ ## Development diff --git a/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md b/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md index 6c7fc7c5dc..a75095c79f 100644 --- a/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md +++ b/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md @@ -23,11 +23,17 @@ let client = Client( ) ``` -Tokens expire after 20 minutes at most. For longer-lived clients, pass a token provider instead, which is called on every request: +Tokens expire after 20 minutes at most. For longer-lived clients, use ``APIKeyTokenProvider``, which signs new ones with your API key as needed: ```swift +let tokenProvider = try APIKeyTokenProvider( + issuerID: yourIssuerID, + keyID: yourKeyID, + privateKey: yourP8FileContents +) + BearerAuthMiddleware { - try await tokenStore.validToken() + try await tokenProvider.token() } ``` @@ -67,6 +73,7 @@ repeat { ### Authentication - ``BearerAuthMiddleware`` +- ``APIKeyTokenProvider`` ### Generated Types diff --git a/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift b/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift index c3e4f19f36..3df681e8f1 100644 --- a/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift +++ b/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift @@ -31,11 +31,12 @@ import class OpenAPIRuntime.HTTPBody /// let middleware = BearerAuthMiddleware(token: "your-jwt-token") /// ``` /// -/// Tokens expire after 20 minutes at most, so long-lived clients should provide a token provider instead: +/// Tokens expire after 20 minutes at most, so long-lived clients should provide a token provider, such as +/// ``APIKeyTokenProvider``: /// /// ```swift /// let middleware = BearerAuthMiddleware { -/// try await tokenStore.validToken() +/// try await tokenProvider.token() /// } /// ``` public struct BearerAuthMiddleware { diff --git a/Sources/ASConnectService/Sources/Public/Providers/APIKeyTokenProvider.swift b/Sources/ASConnectService/Sources/Public/Providers/APIKeyTokenProvider.swift new file mode 100644 index 0000000000..6cd6371026 --- /dev/null +++ b/Sources/ASConnectService/Sources/Public/Providers/APIKeyTokenProvider.swift @@ -0,0 +1,189 @@ +// ===----------------------------------------------------------------------=== +// +// 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 diff --git a/Tests/ASConnectService/Cases/Public/Providers/APIKeyTokenProviderTests.swift b/Tests/ASConnectService/Cases/Public/Providers/APIKeyTokenProviderTests.swift new file mode 100644 index 0000000000..6d537d7845 --- /dev/null +++ b/Tests/ASConnectService/Cases/Public/Providers/APIKeyTokenProviderTests.swift @@ -0,0 +1,138 @@ +#if canImport(CryptoKit) +import CryptoKit +import Foundation +import Testing + +// A testable import gives access to the initializer that injects the current date. +@testable import ASConnectService + +@Suite("APIKeyTokenProvider") +struct APIKeyTokenProviderTests { + // MARK: Properties + + private let key = P256.Signing.PrivateKey() + private let clock = TestClock() + + // MARK: Tests + + @Test("Signs a token for a team key") + func signsTeamKeyToken() async throws { + let token = try await makeProvider(issuerID: .Sample.issuerID).token() + let (header, payload) = try decode(token) + + #expect(header == ["alg": "ES256", "kid": String.Sample.keyID, "typ": "JWT"]) + #expect(payload["iss"] as? String == .Sample.issuerID) + #expect(payload["sub"] == nil) + #expect(payload["aud"] as? String == "appstoreconnect-v1") + #expect(payload["iat"] as? Int == Int(clock.date.timeIntervalSince1970)) + #expect(payload["exp"] as? Int == Int(clock.date.timeIntervalSince1970) + 20 * 60) + } + + @Test("Signs a token for an individual key") + func signsIndividualKeyToken() async throws { + let token = try await makeProvider(issuerID: nil).token() + let (_, payload) = try decode(token) + + #expect(payload["iss"] == nil) + #expect(payload["sub"] as? String == "user") + } + + @Test("Signs the token with the private key") + func signsWithPrivateKey() async throws { + let token = try await makeProvider(issuerID: .Sample.issuerID).token() + let parts = token.split(separator: ".").map(String.init) + let signature = try P256.Signing.ECDSASignature(rawRepresentation: try #require(Data(base64URL: parts[2]))) + + #expect(key.publicKey.isValidSignature(signature, for: Data("\(parts[0]).\(parts[1])".utf8))) + } + + @Test("Returns the current token until it is about to expire") + func returnsCurrentToken() async throws { + let provider = try makeProvider(issuerID: .Sample.issuerID) + let token = try await provider.token() + + clock.advance(by: 18 * 60) + + #expect(try await provider.token() == token) + } + + @Test("Signs a new token when the current one is about to expire") + func signsNewToken() async throws { + let provider = try makeProvider(issuerID: .Sample.issuerID) + let token = try await provider.token() + + clock.advance(by: 19 * 60 + 1) + + #expect(try await provider.token() != token) + } + + @Test("Throws when the private key is not valid") + func throwsWithInvalidPrivateKey() { + #expect(throws: (any Error).self) { + try APIKeyTokenProvider( + issuerID: .Sample.issuerID, + keyID: .Sample.keyID, + privateKey: "not-a-private-key" + ) + } + } +} + +// MARK: - Helpers + +private extension APIKeyTokenProviderTests { + func makeProvider(issuerID: String?) throws -> APIKeyTokenProvider { + try APIKeyTokenProvider( + issuerID: issuerID, + keyID: .Sample.keyID, + privateKey: key.pemRepresentation, + now: { [clock] in clock.date } + ) + } + + func decode(_ token: String) throws -> (header: [String: String], payload: [String: Any]) { + let parts = token.split(separator: ".").map(String.init) + + try #require(parts.count == 3) + + let header = try JSONDecoder().decode([String: String].self, from: try #require(Data(base64URL: parts[0]))) + let payload = try #require( + try JSONSerialization.jsonObject(with: try #require(Data(base64URL: parts[1]))) as? [String: Any] + ) + + return (header, payload) + } +} + +/// A clock whose date only changes when it is advanced. +private final class TestClock: @unchecked Sendable { + private(set) var date = Date(timeIntervalSince1970: 1_800_000_000) + + func advance(by interval: TimeInterval) { + date = date.addingTimeInterval(interval) + } +} + +// MARK: - Data+Base64URL + +private extension Data { + init?(base64URL: String) { + var base64 = base64URL + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + + self.init(base64Encoded: base64) + } +} + +// MARK: - String+Samples + +private extension String { + enum Sample { + static let issuerID: String = "57246542-96fe-1a63-e053-0824d011072a" + static let keyID: String = "2X9R4HXF34" + } +} +#endif