From d5035f9a3aa2970a31547983a67986bae7bae95b Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 25 Oct 2026 17:04:01 +0100 Subject: [PATCH] Support token providers in Bearer Auth middleware. --- README.md | 8 +++ .../Catalogs/ASConnectService.docc/Library.md | 8 +++ .../Middlewares/BearerAuthMiddleware.swift | 40 +++++++++--- .../BearerAuthMiddlewareTests.swift | 64 ++++++++++++++++++- 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7366bcfe2d..c5e0a5a736 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ 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: + +```swift +BearerAuthMiddleware { + try await tokenStore.validToken() +} +``` + ### Making API Calls The `Client` conforms to `APIProtocol`, which defines a method for every endpoint in the App Store Connect API. Each method accepts an `Input` value and returns an `Output` value with the response. diff --git a/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md b/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md index 98c30375dc..94d244283d 100644 --- a/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md +++ b/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md @@ -25,6 +25,14 @@ 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: + +```swift +BearerAuthMiddleware { + try await tokenStore.validToken() +} +``` + ### Making API Calls The ``Client`` conforms to ``APIProtocol``, which defines a method for every endpoint in the App Store Connect API. Each method accepts an `Input` value and returns an `Output` value with the response. diff --git a/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift b/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift index 2446ab7365..c142c9de52 100644 --- a/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift +++ b/Sources/ASConnectService/Sources/Public/Middlewares/BearerAuthMiddleware.swift @@ -13,10 +13,6 @@ // ===----------------------------------------------------------------------=== import struct Foundation.URL -import struct Foundation.URLComponents -import struct Foundation.URLQueryItem -import struct HTTPTypes.HTTPField -import struct HTTPTypes.HTTPFields import struct HTTPTypes.HTTPRequest import struct HTTPTypes.HTTPResponse import protocol OpenAPIRuntime.ClientMiddleware @@ -30,21 +26,43 @@ import class OpenAPIRuntime.HTTPBody /// /// ## Usage /// +/// Provide a fixed token: +/// /// ```swift /// let middleware = BearerAuthMiddleware(token: "your-jwt-token") /// ``` +/// +/// Tokens expire after 20 minutes at most, so long-lived clients should provide a token provider instead: +/// +/// ```swift +/// let middleware = BearerAuthMiddleware { +/// try await tokenStore.validToken() +/// } +/// ``` public struct BearerAuthMiddleware { // MARK: Properties - /// The Bearer token to include in the `Authorization` header of each request. - private let token: String + /// The closure that provides the Bearer token for each request. + private let tokenProvider: @Sendable () async throws -> String // MARK: Initializers - /// Creates a new middleware instance with the given Bearer token. - /// - Parameter token: A JSON Web Token (JWT) string used to authenticate requests to the App Store Connect API. - init(token: String) { - self.token = token + /// Creates a new middleware instance with a fixed Bearer token. + /// - Parameter token: A JSON Web Token (JWT) used to authenticate requests to the App Store Connect API. + public init( + token: String + ) { + self.init { token } + } + + /// Creates a new middleware instance that asks for a Bearer token on every request. + /// + /// If the provider throws, the error is rethrown and the request is not sent. + /// - Parameter tokenProvider: A closure that returns a valid JSON Web Token (JWT). + public init( + tokenProvider: @escaping @Sendable () async throws -> String + ) { + self.tokenProvider = tokenProvider } } @@ -68,6 +86,8 @@ extension BearerAuthMiddleware: ClientMiddleware { operationID: String, next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { + let token = try await tokenProvider() + var request = request request.headerFields[.authorization] = "Bearer \(token)" diff --git a/Tests/ASConnectService/Cases/Public/Middlewares/BearerAuthMiddlewareTests.swift b/Tests/ASConnectService/Cases/Public/Middlewares/BearerAuthMiddlewareTests.swift index 222aa98897..71e94bfd06 100644 --- a/Tests/ASConnectService/Cases/Public/Middlewares/BearerAuthMiddlewareTests.swift +++ b/Tests/ASConnectService/Cases/Public/Middlewares/BearerAuthMiddlewareTests.swift @@ -3,7 +3,8 @@ import HTTPTypes import OpenAPIRuntime import Testing -@testable import ASConnectService +// A non-testable import ensures that the middleware can be created from outside the module. +import ASConnectService @Suite("BearerAuthMiddleware") struct BearerAuthMiddlewareTests { @@ -112,6 +113,67 @@ struct BearerAuthMiddlewareTests { } ) } + + @Test("Asks the token provider for a token on every request") + func asksTokenProviderOnEveryRequest() async throws { + let tokens = TokenSequence() + let middleware = BearerAuthMiddleware { + await tokens.next() + } + + for expectedToken in ["token-1", "token-2"] { + _ = try await middleware.intercept( + .get, + body: nil, + baseURL: .base, + operationID: "listApps", + next: { interceptedRequest, _, _ in + #expect(interceptedRequest.headerFields[.authorization] == "Bearer \(expectedToken)") + + return (HTTPResponse(status: .ok), nil) + } + ) + } + } + + @Test("Rethrows the token provider error without calling the next handler") + func rethrowsTokenProviderError() async throws { + let middleware = BearerAuthMiddleware { + throw TokenError.expired + } + + await #expect(throws: TokenError.expired) { + _ = try await middleware.intercept( + .get, + body: nil, + baseURL: .base, + operationID: "listApps", + next: { _, _, _ in + Issue.record("The next handler should not be called when the token provider throws") + + return (HTTPResponse(status: .ok), nil) + } + ) + } + } +} + +// MARK: - Helpers + +/// An actor that returns a new token every time it is asked for one. +private actor TokenSequence { + private var count = 0 + + func next() -> String { + count += 1 + + return "token-\(count)" + } +} + +/// An error thrown by a token provider. +private enum TokenError: Error { + case expired } // MARK: - HTTPRequest+Samples