An auth middleware package for Swift OpenAPI Generator/Runtime for a common scenario dealing with long-living refresh token and short-living access token. The library is quite universal in and can cover most of such cases.
- π Clear and compact
- β‘οΈ Prevents duplication of auth queries (concurrent 401s collapse to one refresh)
- π§ Adopter-configurable
AuthPolicyfor refresh-failure, sign-out, sign-in-priority, and persistent-rejection situations - π― Proactive refresh when your
validatedAndFormattedAccessTokenrejects the cached token, with a reactive 401 retry as a backstop - πͺͺ Replays single-pass request bodies (streaming uploads) up to a configurable cap so retries don't send empty bodies
- ποΈ Pluggable
AuthenticationProvidingseam for token persistence (Keychain, file, anything) βInMemoryAuthenticationProviderships for tests and ephemeral sessions - π
signIn(credentials:)/signOut()/reset()for explicit session control (account switch, deliberate logout, lockout recovery) - π Per-operation
shouldAuthorizepredicate for specs that mix authenticated and public ops on one client
- Session layer only: attach + silent refresh. The interactive
authorization_code+ PKCE grant (browser consent) belongs outside a middleware β pass the obtained tokens into yourSignInAndRefreshconformance instead. - Tokens are cached in actor memory (write-through). Persistence is the adopter's
AuthenticationProvidingconformance βprovide()/update(...)/revoke(). The package does not ship a Keychain conformance; that may land in a 2.1.x companion module. - Single-pass bodies above
maxRetryBufferBytes(default 1 MiB) are sent once and a 401 is surfaced to the caller instead of retried. PassmaxRetryBufferBytes: 0to opt out of buffering entirely.
See Sources/RefreshTokenAuthMiddleware.docc/Design-2-0-0.md for the load-bearing design β state machine, seam shapes, error taxonomy, and migration notes.
Add the following to your Package.swift file:
dependencies: [
.package(url: "https://github.com/laconicman/RefreshTokenAuthMiddleware.git", from: "1.0.0")
]Then add the dependency to your target:
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "RefreshTokenAuthMiddleware", package: "RefreshTokenAuthMiddleware")
]
)
]For "Client" generated with Swift OpenAPI Generator, implement conformance to "SignInAndRefresh" protocol.
This will define the logic of specific authentication queries, their results, and token validation.
import Foundation
import HTTPTypes // Gonna need this to modify requests inside those funcs of `SignInAndRefresh` protocol.
import RefreshTokenAuthMiddleware
extension Client: SignInAndRefresh {
typealias Token = String
typealias RefreshToken = String
func signIn(credentials: Credentials) async throws -> (accessToken: Token, refreshToken: RefreshToken) {
// adjust to your API operation
let response = try await authSignIn(body: .json(.init(login: credentials.username, pwd: credentials.password)))
let auth = try response.ok.body.applicationJsonCharsetUtf8.auth
return (auth.token, auth.refreshToken)
}
// 2.0.0: return the (possibly rotated) refresh token as the second element.
// Return `nil` if your server does not rotate.
func refreshTokenIfNeeded(with refreshToken: RefreshToken?) async throws
-> (accessToken: Token, refreshToken: RefreshToken?) {
guard let refreshToken else { throw AuthError.missingRefreshToken }
// adjust to your API operation
let refreshTokenResponse = try await authRefreshToken(body: .json(.init(token: refreshToken)))
let auth = try refreshTokenResponse.ok.body.applicationJsonCharsetUtf8.auth
return (auth.token, auth.refreshToken) // or `nil` if no rotation
}
@Sendable func authorizeRequest(_ request: HTTPRequest, with accessToken: Token?) throws -> HTTPRequest {
// Setup request according to the doc. Usually just a header.
var authorizedRequest = request
authorizedRequest.headerFields[.authorization] = "Bearer \(try validatedAndFormattedAccessToken(accessToken))"
return authorizedRequest
}
// Throwing here is how the middleware learns the cached token is stale β it will
// proactively refresh (or sign in) before sending the request. Wire your real
// expiry check (e.g. a stored `expiresAt`) in place of the nil-only guard.
@Sendable func validatedAndFormattedAccessToken(_ token: Token?) throws -> Token {
guard let token else { throw AuthError.missingAccessToken }
// if Date() >= expiresAt { throw AuthError.missingAccessToken }
return token
}
}In main it could look like this:
import OpenAPIRuntime
import OpenAPIURLSession
import Foundation
struct OuterClient: Sendable {
let client: Client
private let refreshTokenAuthMiddleware: RefreshTokenAuthMiddleware<Client>
init?(credentials: Credentials) {
guard let serverURL = try? Servers.Server1.url() else { return nil}
let authManagementClient = Client(
serverURL: serverURL,
transport: URLSessionTransport()
)
// `InMemoryAuthenticationProvider` ships with the package β replace with a
// Keychain-backed conformance in production to persist across launches.
refreshTokenAuthMiddleware = RefreshTokenAuthMiddleware(
authManagementClient: authManagementClient,
credentials: credentials,
authProvider: InMemoryAuthenticationProvider<String, String>()
)
self.client = Client(
serverURL: serverURL,
transport: URLSessionTransport(),
middlewares: [refreshTokenAuthMiddleware]
)
}
}
let client = OuterClient(credentials: .init())
// Authorization is fully automatic by now. But if we do sign in there should be no extra re-auth request.
// let authorizationResponse = try await client?.client.signIn(credentials: .init())
// print(authorizationResponse ?? "No auth response")
let adminUsersResponse = try await client?.listGoods(body: .json(.init(limit: 10, offset: 0, page: 1, filter: "", order: .init(id: "asc"))))
print(adminUsersResponse ?? "No admin users response")Contributions and are welcome!
- swift-openapi-generator - The main Swift OpenAPI Generator project
- swift-openapi-runtime - Runtime library for Swift OpenAPI Generator
This project is licensed under the Apache License 2.0.