Overhauled the library with several new features #5
No files matched your search
+4
-4
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user