Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 0 additions & 59 deletions .github/workflows/documentation.yml

This file was deleted.

4 changes: 4 additions & 0 deletions .spi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [Endpoints, EndpointsMocking]
329 changes: 157 additions & 172 deletions README.md

Large diffs are not rendered by default.

21 changes: 11 additions & 10 deletions Sources/Endpoints/Authentication/AuthenticationError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,28 @@ import Foundation

/// Errors that can occur during authentication operations.
public enum AuthenticationError: Error, Sendable {
/// No valid credentials are available to authenticate the request.
/// There are no credentials to authenticate the request with.
case notAuthenticated

/// No refresh token is available to perform token refresh.
/// There is no refresh token to refresh with.
case noRefreshToken

/// The token refresh operation failed.
/// The refresh failed with the underlying error.
case refreshFailed(underlying: Error)

/// The authentication method does not support refresh.
/// The authentication method can't refresh its credentials.
case refreshNotSupported

/// An implementation-specific authentication failure.
///
/// Use this from custom ``AuthenticationMethod`` implementations for failures
/// that don't fit the other cases (e.g. credential storage or signing errors).
/// A failure in a custom ``AuthenticationMethod`` that doesn't match another case,
/// such as a keychain or signing error.
case custom(underlying: Error)
}

/// Thrown when a request authenticated by a ``JWTAuth`` is made from inside that same
/// instance's refresh handler, which would otherwise deadlock.
/// The underlying error when a ``JWTAuth`` refresh handler makes a request through
/// that same `JWTAuth`.
///
/// The request would wait for the refresh, which is waiting for the request, so it
/// fails instead. Give the refresh endpoint ``NoAuth``.
public struct RefreshReentrancyError: Error, CustomStringConvertible {
let authType: Any.Type

Expand Down
50 changes: 24 additions & 26 deletions Sources/Endpoints/Authentication/AuthenticationMethod.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,58 @@ import Foundation
import FoundationNetworking
#endif

/// A protocol that defines how to authenticate requests and handle token refresh.
/// Adds credentials to requests, and optionally refreshes them after a rejected response.
public protocol AuthenticationMethod: Sendable {

/// Applies authentication credentials to a request.
/// Returns the request with credentials added.
///
/// - Parameter request: The URLRequest to authenticate.
/// - Returns: The authenticated URLRequest.
/// - Throws: ``AuthenticationError/notAuthenticated`` if no valid credentials are available.
/// Called before every attempt, including retries.
/// - Parameter request: The request to authenticate.
/// - Returns: The authenticated request.
/// - Throws: ``AuthenticationError/notAuthenticated`` if there are no credentials.
func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest

/// Determines whether a failed request should trigger reauthentication.
/// Returns whether a failed request should be retried after ``reauthenticate(after:)``.
///
/// Defaults to `false`.
/// - Parameters:
/// - error: The error that occurred.
/// - response: The HTTP response, if available.
/// - Returns: `true` if reauthentication should be attempted.
/// - error: The error the request failed with.
/// - response: The HTTP response, if the server returned one.
/// - Returns: `true` to refresh the credentials and retry.
func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool

/// Performs reauthentication (e.g., token refresh).
/// Refreshes the credentials after a failed request.
///
/// - Parameter failedRequest: The authenticated request that failed, as returned by
/// ``authenticate(request:)``. Implementations that rotate credentials should compare
/// the failed request's credentials against their current ones and skip refreshing
/// when they no longer match — the request failed with credentials that have already
/// been replaced, so refreshing again would needlessly consume a refresh token.
/// Combine concurrent calls into one refresh. If the failed request's credentials no
/// longer match the current ones, another request already refreshed them, so return
/// without refreshing again. Refreshing twice can waste a single-use refresh token.
///
/// Implementations should coalesce concurrent calls into a single refresh operation.
/// Throws ``AuthenticationError/refreshNotSupported`` by default.
/// - Parameter failedRequest: The failed request, as returned by ``authenticate(request:)``.
func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError)

/// How many times a request may be retried after reauthenticating. Defaults to 1.
/// How many times a request can be retried after reauthenticating. Defaults to 1.
///
/// Bounds the retry loop so that a server which keeps rejecting credentials cannot
/// cause an infinite request/refresh cycle.
/// This limit stops a server that keeps rejecting credentials from causing an
/// endless cycle of requests and refreshes.
var maxRetryAttempts: Int { get }
}

public extension AuthenticationMethod {

/// By default, failed requests never trigger reauthentication.
/// Override for credentials that can be refreshed.
/// Returns `false`, so failed requests are never retried.
func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool {
false
}

/// By default, refresh is unsupported.
/// Override for credentials that can be refreshed.
/// Throws ``AuthenticationError/refreshNotSupported``.
func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) {
throw AuthenticationError.refreshNotSupported
}

/// By default, a request is retried once after reauthenticating.
/// Retries a request once.
var maxRetryAttempts: Int { 1 }

/// ``maxRetryAttempts`` normalized to a usable count, so that every transport
/// applies the same bound without repeating the clamp.
/// ``maxRetryAttempts``, with negative values treated as 0.
var retryAttempts: Int { max(0, maxRetryAttempts) }
}
16 changes: 8 additions & 8 deletions Sources/Endpoints/Authentication/AuthenticationStability.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import Foundation
/// Debug-only check that an endpoint's ``Endpoint/auth`` yields one shared instance.
///
/// Stateful methods such as ``JWTAuth`` keep their tokens and in-flight refresh on the
/// instance, so they only work when every request sees the *same* one. Declaring
/// `static let auth = JWTAuth(...)` does that; a computed `static var auth: JWTAuth
/// { JWTAuth(...) }` silently hands out a fresh actor per access, which loses tokens
/// and turns refresh coalescing into a refresh storm — with no error to point at.
/// instance, so every request has to see the same one. `static let auth = JWTAuth(...)`
/// does that. A computed `static var auth: JWTAuth { JWTAuth(...) }` creates a new
/// actor on every access, which loses tokens and starts a separate refresh per request,
/// without any error.
///
/// This catches that during development. It runs once per endpoint type and compiles
/// out of release builds entirely.
/// This check catches that during development. It runs once per endpoint type and
/// isn't compiled into release builds.
enum AuthenticationStability {
private static let lock = NSLock()
nonisolated(unsafe) private static var verified: Set<ObjectIdentifier> = []
Expand All @@ -38,8 +38,8 @@ enum AuthenticationStability {
first === second,
"""
\(T.self).auth returns a new \(T.Auth.self) on each access. Declare it as a \
`static let` so every request shares one instance — a stateful authentication \
method cannot retain credentials or coalesce refreshes otherwise.
`static let` so every request shares one instance. A stateful authentication \
method can't keep credentials or combine refreshes otherwise.
"""
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import Foundation
import FoundationNetworking
#endif

/// Authentication using HTTP Basic credentials ([RFC 7617](https://www.rfc-editor.org/rfc/rfc7617)).
/// Sends HTTP Basic credentials ([RFC 7617](https://www.rfc-editor.org/rfc/rfc7617)).
///
/// Sends `Authorization: Basic <base64(username:password)>` with the credentials
/// Sends `Authorization: Basic <base64(username:password)>`, with the credentials
/// encoded as UTF-8.
public struct BasicAuth: AuthenticationMethod {
/// The username. Must not contain a colon (RFC 7617, section 2).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import Foundation
import FoundationNetworking
#endif

/// Authentication using a static HTTP cookie.
/// Sends a static cookie.
public struct CookieAuth: AuthenticationMethod {
/// The cookie name.
public let name: String

/// The cookie value.
public let value: String

/// Whether to merge with cookies already on the request. Defaults to true.
/// An existing cookie with the same name is replaced.
/// Whether to keep cookies already on the request. Defaults to `true`.
///
/// When `true`, an existing cookie with the same name is replaced and the others are
/// kept. When `false`, the cookie replaces all existing cookies.
public let appendToExisting: Bool

public init(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,26 @@ import Foundation
import FoundationNetworking
#endif

/// Authentication using a static header key.
/// Sends a static key in a header, such as an API key.
public struct HeaderKeyAuth: AuthenticationMethod {
/// The key value.
/// The key.
public let key: String

/// The HTTP header to use. Defaults to `.authorization`.
/// The header the key is sent in.
public let header: Header

/// Optional prefix before the key (e.g., "Bearer", "ApiKey").
/// Set to nil for no prefix.
/// The text before the key, such as `Bearer`. When `nil`, the key is sent alone.
public let prefix: String?

/// The key and prefix are immutable, so the header value is composed once.
private let headerValue: String

/// Creates a header key authentication method.
/// Creates a method that sends `key` in `header`.
///
/// - Parameters:
/// - key: The key value.
/// - header: The HTTP header to use. Defaults to `.authorization`.
/// - prefix: Optional prefix (e.g., "Bearer"). Defaults to "Bearer".
/// - key: The key.
/// - header: The header to send it in. Defaults to `Authorization`.
/// - prefix: The text before the key. Defaults to `Bearer`. Pass `nil` to send the key alone.
public init(
key: String,
header: Header = .authorization,
Expand Down
Loading
Loading