Overhauled the library with several new features #5
No files matched your search
+4
-4
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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