Added an API key token provider.
This commit is contained in:
1 parent
a303150963
commit
35b3b7953d
6 files changed
+355
-14
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user