Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1c67ea3
Started implementing authentication
zac Feb 2, 2026
6da6988
Added cookie auth
zac Feb 2, 2026
70e7a02
APIKeyAuth to HeaderKeyAuth
zac Feb 3, 2026
3d06bc3
Adopt typed throws across the public API
zac Jul 24, 2026
765adf7
Unify AuthenticatedSession error surface behind EndpointTaskError
zac Jul 24, 2026
461d8fd
Fix refresh-token race for in-flight requests
zac Jul 24, 2026
e251796
Add AuthenticationMethod defaults; polish CookieAuth and Header
zac Jul 24, 2026
d530433
Fill authentication test gaps
zac Jul 24, 2026
a5d8e79
Document authentication in the README
zac Jul 24, 2026
3941ab0
Require Sendable on ServerDefinition.Environments
zac Jul 24, 2026
51cdb79
Close auth-layer test coverage gaps
zac Jul 24, 2026
fd82680
Cover the async URLSession transport paths
zac Jul 24, 2026
10f7350
Key runtime environment storage by server type
zac Jul 24, 2026
f524bab
Add per-endpoint mock registry with batch registration
zac Jul 24, 2026
cec1f49
Add BasicAuth and AuthenticationError.custom
zac Jul 24, 2026
48b8f81
Proactively refresh expiring tokens before sending requests
zac Jul 24, 2026
f133b79
Declare authentication per endpoint instead of via a session wrapper
zac Jul 27, 2026
7a7dd8e
Document authentication in the DocC catalog
zac Jul 27, 2026
9297d9d
Apply endpoint authentication in the Combine API
zac Jul 27, 2026
929ac77
Simplify: dedup request path, mock scoping, and test fixtures
zac Jul 27, 2026
1b6ec84
Fix Linux build: move Gate out of the Apple-gated test file
zac Jul 27, 2026
90c7a34
Turn two documented auth footguns into diagnosable failures
zac Jul 27, 2026
d9a450f
Supply environment and credentials per request
zac Jul 27, 2026
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
187 changes: 180 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ The purpose of Endpoints is to, in a type-safe way, define how to create a `URLR

- **Type-safe endpoint definitions** - Define endpoints with compile-time checking of paths, parameters, and headers
- **Server definition with multiple environments** - Support for local, development, staging, and production environments with easy switching
- **Authentication with automatic token refresh** - Declare an authentication method per server or per endpoint; credentials are applied, refreshed, and retried transparently
- **Built-in mocking support** - Comprehensive testing utilities through the `EndpointsMocking` module
- **Swift 6.0 compatible** - Built with modern Swift concurrency and Sendable support
- **Swift 6.0 compatible** - Built with modern Swift concurrency, Sendable support and typed throws
- **Combine and async/await support** - Use either reactive or async patterns

## Getting Started
Expand Down Expand Up @@ -43,7 +44,7 @@ struct ApiServer: ServerDefinition {

To get started, first create a type (struct or class) conforming to `Endpoint`. There are only two required elements to conform: defining the `Response` and creating the `Definition`.

`Endpoints` and `Definitions` now include server information, eliminating the need to pass environments at call time. Servers implement a `requestProcessor` which has a final hook before `URLRequest` creation to modify the `URLRequest` to attach authentication or signatures.
`Endpoints` and `Definitions` now include server information, eliminating the need to pass environments at call time. Servers can implement a `requestProcessor`, a final synchronous hook after `URLRequest` creation for static request modification such as signing. For credentials that can expire and be refreshed, use [Authentication](#authentication) instead.

### Basic Endpoint Example

Expand Down Expand Up @@ -79,7 +80,34 @@ URLSession.shared.endpointPublisher(with: MyEndpoint())
.store(in: &cancellables)
```

Notice that the API no longer requires passing an environment - it's handled automatically by the server definition.
Notice that the common case requires no environment or credentials at the call site — both default to what the server and endpoint declare.

### Runtime context: environment and credentials

When a request needs different context than the declarations provide — most often because your app talks to more than one deployment or account at once — pass it per request:

```swift
let response = try await URLSession.shared.response(
with: ProfileEndpoint(),
environment: client.environment,
auth: client.auth
)
```

Both parameters default to the endpoint's declarations, so this is opt-in and existing call sites are unaffected. Because they are per-request values rather than global state, two clients with different environments *and* different credentials can issue requests concurrently without interfering:

```swift
struct WavelikeClient {
let environment: ApiServer.Environments
let auth: JWTAuth // one instance per client, so its refreshes coalesce

func profile() async throws -> ProfileEndpoint.Response {
try await URLSession.shared.response(with: ProfileEndpoint(), environment: environment, auth: auth)
}
}
```

The same parameters are available on `endpointPublisher(with:)`. `endpointTask(with:)` accepts `environment:` but remains restricted to unauthenticated endpoints, since it returns a `URLSessionDataTask` synchronously and cannot await authentication.

### Async/Await

Expand All @@ -92,6 +120,134 @@ do {
}
```

## Authentication

Authentication is declared on the endpoint, the same way decoders are. A server names the `AuthenticationMethod` its endpoints use, and every endpoint inherits it:

```swift
struct ApiServer: ServerDefinition {
static let auth = HeaderKeyAuth(key: "my-api-key")

var baseUrls: [Environments: URL] { ... }
static var defaultEnvironment: Environments { .production }
}

struct ProfileEndpoint: Endpoint {
typealias Server = ApiServer // authenticated, nothing else to declare

static let definition: Definition<ProfileEndpoint> = Definition(method: .get, path: "profile")
struct Response: Decodable { let name: String }
}
```

Requests then go through the ordinary `URLSession` API — there is no separate session type, and credentials are applied automatically:

```swift
let response = try await URLSession.shared.response(with: ProfileEndpoint())
```

An individual endpoint can override its server's method — to opt out on a login or refresh endpoint, or to use a different scheme entirely:

```swift
struct LoginEndpoint: Endpoint {
typealias Server = ApiServer
static var auth: NoAuth { NoAuth() } // unauthenticated
...
}

struct MetricsEndpoint: Endpoint {
typealias Server = ApiServer
static let auth = HeaderKeyAuth(key: clientKey, header: "X-Client-Key", prefix: nil)
...
}
```

Declare the method as a `static let` so all endpoints on a server share one instance. That shared instance is what allows a stateful method like `JWTAuth` to coalesce concurrent token refreshes across every endpoint — a computed `static var` would hand out a fresh instance per request, losing tokens. Debug builds assert if that happens, so the mistake surfaces in development rather than as mysterious re-authentication in production.

Servers that declare no `auth` use `NoAuth`, so existing endpoints keep working unchanged.

The async/await and Combine APIs both apply authentication, including refresh and retry. Cancelling a Combine subscription cancels the underlying request.

The closure-based `endpointTask` is the exception: it hands back a `URLSessionDataTask` synchronously, so it cannot await an asynchronous `authenticate` before returning. It is constrained to unauthenticated endpoints — calling it with an authenticated endpoint is a compile error rather than a request that silently skips its credentials. Use `response(with:)` or `endpointPublisher(with:)` for authenticated endpoints.

Built-in authentication methods:

- `HeaderKeyAuth` - A static key in a header, with an optional prefix. Defaults to `Authorization: Bearer <key>`; use `HeaderKeyAuth(key: "secret", header: "X-API-Key", prefix: nil)` for custom API-key headers.
- `BasicAuth` - HTTP Basic credentials (RFC 7617), UTF-8 encoded.
- `CookieAuth` - A static cookie, merged with any cookies already on the request.
- `JWTAuth` - Access/refresh token pairs with automatic refresh (see below).
- `NoAuth` - Passes requests through unchanged. Useful as a generic placeholder.

### Token refresh with JWTAuth

`JWTAuth` holds an access/refresh token pair. When a request fails with a status code in `refreshTriggerStatusCodes` (401 by default), your `refreshHandler` is called and the request is retried with the new tokens. Concurrent refreshes are coalesced into a single operation, and a request that fails with already-replaced tokens will not trigger a redundant refresh — important when your backend rotates single-use refresh tokens.

If you know when the access token expires, set `TokenPair.expiresAt`: tokens within `expiryLeeway` (30 seconds by default) of expiring are then refreshed *before* the request is sent, skipping the round trip that would have been rejected. Without `expiresAt`, refresh is purely reactive.

```swift
struct ApiServer: ServerDefinition {
static let auth = JWTAuth(
initialTokens: loadTokensFromKeychain(),
refreshHandler: { refreshToken in
// Exchange the refresh token for new tokens against your backend.
let response = try await URLSession.shared.response(with: RefreshEndpoint(token: refreshToken))
return JWTAuth.TokenPair(accessToken: response.access, refreshToken: response.refresh)
},
onTokensUpdated: { tokens in
saveTokensToKeychain(tokens)
},
onRefreshFailed: { error in
await logOut()
}
)

var baseUrls: [Environments: URL] { ... }
static var defaultEnvironment: Environments { .production }
}
```

> **Important:** the refresh endpoint must not be authenticated by the same `JWTAuth` — the request would wait on the very refresh that is waiting on it. Give it `static var auth: NoAuth { NoAuth() }`; it authenticates with the refresh token, not the access token. If you do hit this, the request fails with a `RefreshReentrancyError` explaining the fix rather than hanging.

After a login or logout, update the tokens with `await ApiServer.auth.setTokens(_:)` or `await ApiServer.auth.clearTokens()`.

### Custom authentication methods

Conform to `AuthenticationMethod` to implement your own scheme. Only `authenticate(request:)` is required; refreshable credentials also implement `shouldReauthenticate(for:response:)` and `reauthenticate(after:)`:

```swift
struct SignatureAuth: AuthenticationMethod {
let secret: String

func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest {
var request = request
request.setValue(sign(request, with: secret), forHTTPHeaderField: "X-Signature")
return request
}
}
```

For failures that don't fit the built-in `AuthenticationError` cases (credential storage errors, signing failures), wrap them in `AuthenticationError.custom(underlying:)`.

### Error handling

All failures — including authentication failures — surface as the endpoint's typed `EndpointTaskError`, so a single `catch` covers everything:

```swift
do {
let response = try await URLSession.shared.response(with: MyEndpoint())
} catch {
// error is MyEndpoint.TaskError — no casting needed
switch error {
case .authenticationError(.refreshFailed(let underlying)):
// token refresh failed; underlying holds the refresh error
case .errorResponse(_, let errorResponse):
// typed server error response
default:
break
}
}
```

## Testing with EndpointsMocking

Endpoints includes a comprehensive mocking system through the `EndpointsMocking` module:
Expand All @@ -109,12 +265,28 @@ import EndpointsMocking
}
```

When a flow touches several endpoints, register them together with a `MockRegistry` instead of nesting `withMock` calls:

```swift
try await withMock { mocks in
mocks.register(RefreshEndpoint.self, action: .return(.init(access: "new", refresh: "next")))
mocks.register(ProfileEndpoint.self, action: .return(.init(name: "Zac")))
} test: {
let profile = try await session.response(with: ProfileEndpoint())
}
```

Mocks are scoped per endpoint type: endpoints without a registered mock pass through to the real transport, nested `withMock` scopes merge, and an inner mock for the same endpoint type shadows the outer one for the duration of its scope.

The mocking system supports:
- Returning successful responses
- Returning error responses
- Throwing network errors
- Dynamic response generation
- Combine publisher mocking
- Authenticated and unauthenticated endpoints alike

Note that mocks bypass authentication entirely: a mocked request never invokes the endpoint's `AuthenticationMethod`, and mock errors do not trigger the refresh/retry loop. To simulate an authentication failure, throw one directly with `.throw(.authenticationError(.notAuthenticated))`; to test the refresh flow itself, use a `URLProtocol`-based fake transport.

To find out more about the pieces of the `Endpoint`, check out [Defining a ResponseType](https://github.com/velos/Endpoints/wiki/DefiningResponseType) on the wiki.

Expand All @@ -125,7 +297,8 @@ To browse more complex examples, make sure to check out the [Examples](https://g
## Requirements

- Swift 6.0+
- iOS 15.0+ / macOS 12.0+ / tvOS 15.0+ / watchOS 8.0+
- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 6.0+ to build endpoints and create `URLRequest`s
- macOS 12.0+ for the async/await and Combine request APIs (`response(with:)`, `endpointPublisher(with:)`), and therefore for authentication

## Installation

Expand All @@ -135,7 +308,7 @@ Add the following to your `Package.swift`:

```swift
dependencies: [
.package(url: "https://github.com/velos/Endpoints.git", from: "2.0.0")
.package(url: "https://github.com/velos/Endpoints.git", from: "0.5.0")
]
```

Expand All @@ -161,8 +334,8 @@ Full documentation is available in Xcode (Product > Build Documentation) and inc
If you're upgrading from version 0.4.0 or earlier, the main changes are:

1. **ServerDefinition replaces EnvironmentType** - Define your environments in a `ServerDefinition` conforming type
2. **No more environment parameter** - Remove `in: .production` from all API calls
3. **Add Server typealias** - Add `typealias Server = YourServer` to your endpoints
2. **Add Server typealias** - Add `typealias Server = YourServer` to your endpoints
3. **Environment is per request, not global** - `ApiServer.environment = .staging` is gone. Pass `environment:` to the request methods, or rely on the server's `defaultEnvironment`. This is what allows two clients to use different environments at the same time.
4. **Swift 6.0 required** - Update your Swift toolchain

See the [Migration Guide](https://github.com/velos/Endpoints/wiki/Migration) for detailed instructions.
Expand Down
48 changes: 48 additions & 0 deletions Sources/Endpoints/Authentication/AuthenticationError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation

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

/// No refresh token is available to perform token refresh.
case noRefreshToken

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

/// The authentication method does not support refresh.
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).
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.
public struct RefreshReentrancyError: Error, CustomStringConvertible {
let authType: Any.Type

public var description: String {
"""
A request authenticated by this \(authType) was made from inside its own \
refreshHandler, which would deadlock: the request waits for the refresh that is \
waiting for the handler. Give the refresh endpoint `static var auth: NoAuth \
{ NoAuth() }`, or perform the refresh with a plain URLSession.
"""
}
}

extension AuthenticationError: CustomNSError {
public var errorUserInfo: [String: Any] {
switch self {
case .refreshFailed(let underlying), .custom(let underlying):
return [NSUnderlyingErrorKey: underlying]
default:
return [:]
}
}
}
63 changes: 63 additions & 0 deletions Sources/Endpoints/Authentication/AuthenticationMethod.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// A protocol that defines how to authenticate requests and handle token refresh.
public protocol AuthenticationMethod: Sendable {

/// Applies authentication credentials to a request.
///
/// - Parameter request: The URLRequest to authenticate.
/// - Returns: The authenticated URLRequest.
/// - Throws: ``AuthenticationError/notAuthenticated`` if no valid credentials are available.
func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest

/// Determines whether a failed request should trigger reauthentication.
///
/// - Parameters:
/// - error: The error that occurred.
/// - response: The HTTP response, if available.
/// - Returns: `true` if reauthentication should be attempted.
func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool

/// Performs reauthentication (e.g., token refresh).
///
/// - 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.
///
/// Implementations should coalesce concurrent calls into a single refresh operation.
func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError)

/// How many times a request may 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.
var maxRetryAttempts: Int { get }
}

public extension AuthenticationMethod {

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

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

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

/// ``maxRetryAttempts`` normalized to a usable count, so that every transport
/// applies the same bound without repeating the clamp.
var retryAttempts: Int { max(0, maxRetryAttempts) }
}
Loading
Loading