Overhauled the library with several new features #5

Merged
javier merged 15 commits from library/api-update into main 2026-10-25 17:52:41 +00:00
6 changed files with 355 additions and 14 deletions
Showing only changes of commit 35b3b7953d - Show all commits

No files matched your search

+4 -4
View File
@@ -18,11 +18,11 @@ import PackageDescription
let package = Package( let package = Package(
name: AppStoreConnectService.package, name: AppStoreConnectService.package,
platforms: [ platforms: [
.iOS(.v13), .iOS(.v14),
.macOS(.v10_15), .macOS(.v11),
.tvOS(.v13), .tvOS(.v14),
.visionOS(.v1), .visionOS(.v1),
.watchOS(.v6) .watchOS(.v7)
], ],
products: [ products: [
.library( .library(
+12 -6
View File
@@ -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 ```swift
let tokenProvider = try APIKeyTokenProvider(
issuerID: yourIssuerID,
keyID: yourKeyID,
privateKey: yourP8FileContents
)
BearerAuthMiddleware { BearerAuthMiddleware {
try await tokenStore.validToken() try await tokenProvider.token()
} }
``` ```
@@ -82,11 +88,11 @@ repeat {
## Supported Platforms ## Supported Platforms
- iOS 13.0+ - iOS 14.0+
- macOS 10.15+ - macOS 11.0+
- tvOS 13.0+ - tvOS 14.0+
- visionOS 1.0+ - visionOS 1.0+
- watchOS 6.0+ - watchOS 7.0+
## Development ## Development
@@ -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 ```swift
let tokenProvider = try APIKeyTokenProvider(
issuerID: yourIssuerID,
keyID: yourKeyID,
privateKey: yourP8FileContents
)
BearerAuthMiddleware { BearerAuthMiddleware {
try await tokenStore.validToken() try await tokenProvider.token()
} }
``` ```
@@ -67,6 +73,7 @@ repeat {
### Authentication ### Authentication
- ``BearerAuthMiddleware`` - ``BearerAuthMiddleware``
- ``APIKeyTokenProvider``
### Generated Types ### Generated Types
@@ -31,11 +31,12 @@ import class OpenAPIRuntime.HTTPBody
/// let middleware = BearerAuthMiddleware(token: "your-jwt-token") /// 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 /// ```swift
/// let middleware = BearerAuthMiddleware { /// let middleware = BearerAuthMiddleware {
/// try await tokenStore.validToken() /// try await tokenProvider.token()
/// } /// }
/// ``` /// ```
public struct BearerAuthMiddleware { public struct BearerAuthMiddleware {
@@ -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
@@ -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