Authentication with automatic refresh, plus per-request environment and credentials - #21
Merged
Conversation
- Endpoint.urlRequest() now throws(EndpointError), removing the as?-cast/fatalError dance at call sites - URLSession.createUrlRequest, endpointTask, and the async response methods now throw T.TaskError as a typed error - Renamed Mocking.handlMock to handleMock (typo) with typed throws; the Combine variant is now handleMockPublisher to avoid overload ambiguity - Extracted shared URLSession data-loading/error-mapping into a loadData helper
- New EndpointTaskError.authenticationError(AuthenticationError) case: AuthenticatedSession.response now throws T.TaskError exclusively (typed), instead of leaking raw AuthenticationError values - AuthenticationMethod.authenticate/reauthenticate now throw typed AuthenticationError - Restructured the retry loop so the unreachable maxRetriesExceeded throw is gone; the error case is removed entirely - Negative maxRetryAttempts values are clamped to 0
reauthenticate() is now reauthenticate(after:) and receives the failed authenticated request. JWTAuth compares the request's credentials against its current tokens and skips the refresh when they have already rotated — previously a request that was in flight during a successful refresh would 401 and burn a second (often single-use) refresh token. Also: JWTAuth.getTokens() is now a tokens property, and the stale-token comment in authenticate() now describes the actual behavior.
- Default implementations for shouldReauthenticate (false) and reauthenticate (refreshNotSupported): non-refreshing methods now only implement authenticate - CookieAuth replaces an existing same-name cookie instead of appending a duplicate, and drops the customizable header knob (cookies belong in the Cookie header) - Header.cookie/.setCookie categorized as request/response
- Retry exhaustion: repeated 401s surface the final errorResponse and refresh exactly once - Refresh failure surfaces as EndpointTaskError.authenticationError - JWT end-to-end through AuthenticatedSession: 401 -> refresh -> retry with rotated Bearer token, asserting the exact Authorization headers sent on the wire - JWTAuth without tokens fails before any request is sent - JWTAuth skips refresh when the failed request used already-rotated credentials - CookieAuth replaces an existing same-name cookie The Authenticated Session suite is now .serialized since its tests share TestURLProtocol's static handler.
- New Authentication section: AuthenticatedSession usage, built-in methods, JWTAuth refresh flow, custom methods, typed error handling - Clarify requestProcessor's role (static signing) vs AuthenticationMethod (refreshable credentials) - Note authentication is async/await-only - Fix stale install snippet version (2.0.0 -> 0.5.0)
Environment values cross threads through the shared environment storage (ApiServer.environment can be read and switched from any thread), but the associatedtype was only constrained to Hashable — the nonisolated(unsafe) storage was silencing the requirement. Constrain it to Hashable & Sendable and enforce it at the EnvironmentStorage boundary too. Also drop the now-redundant 'any (ServerDefinition & Sendable)' in EndpointError since ServerDefinition already requires Sendable.
- TestURLProtocol now routes handlers by URL path instead of a single global closure, so independent suites can run in parallel on disjoint paths; shared transport helpers moved to TestTransport.swift - New tests: Void and Data response variants through AuthenticatedSession, decode-failure surfacing, static auth (HeaderKey) not retrying a 401, JWT-native refresh failure invoking onRefreshFailed, authenticate awaiting an in-flight refresh, JWT token management (set/clear/isAuthenticated), NoAuth passthrough and the protocol default implementations, CustomNSError underlying-error bridging
New serialized suite exercising URLSession.response(with:) through TestURLProtocol rather than the mock short-circuit: decodable/Void/Data successes, response and error-response parse failures, decoded error responses, urlLoadError mapping, and offline detection. Raises URLSession+Async line coverage from 23% to 96% and package total from 62% to 68%.
EnvironmentStorage was keyed by the Environments type, so two servers sharing an environment type (e.g. the default TypicalEnvironments) shared one environment slot: switching ApiServer.environment silently switched every such server, and a server without a URL for the leaked environment failed with misconfiguredServer. Surfaced as a parallel-test flake between the environment-switching test and the mocking tests. Now keyed by the server type itself, so servers switch independently.
Mocks are now stored per endpoint type in the TaskLocal scope instead of
as a single type-erased closure. This fixes a crash where any request
for endpoint B inside withMock(A.self) force-cast B's continuation to
A's type — the shape every multi-endpoint flow (e.g. token refresh)
takes. Unmocked endpoint types now pass through to the real transport,
nested scopes merge, and an inner same-type mock shadows the outer one.
New public API for mocking several endpoints in one scope:
try await withMock { mocks in
mocks.register(RefreshEndpoint.self, action: .return(...))
mocks.register(ProfileEndpoint.self, action: .return(...))
} test: { ... }
Also adds MockContinuation.resume(with:) and documents that mocks
bypass authentication and the refresh/retry loop.
- BasicAuth sends RFC 7617 Basic credentials, UTF-8 encoded - New AuthenticationError.custom(underlying:) case so custom AuthenticationMethod implementations can surface failures that aren't token-shaped (credential storage, signing) through the typed error without misusing refreshFailed; exposed via NSUnderlyingErrorKey - New conformance test in EndpointsMockingTests compiles a custom method against the public API only (no @testable), verifying external packages can implement their own authentication methods
TokenPair gains an optional expiresAt; when set and the token is within Configuration.expiryLeeway (default 30s) of expiring, authenticate refreshes before the request goes out instead of spending a round trip on a guaranteed rejection. Without expiresAt, behavior is unchanged (reactive refresh only). The refresh internals are unified into a single join-or-start helper whose existence check and task creation share one synchronous stretch of actor isolation, so concurrent near-expiry requests coalesce into one refresh. A failed proactive refresh falls back to sending the stale token, keeping the server as the source of truth. Also documents that RefreshHandler must not run through a session authenticated by the same JWTAuth (task deadlock: the session would await the refresh that awaits the handler).
Authentication is now declared on the endpoint the same way decoders are: ServerDefinition gains an Auth associatedtype (defaulted to NoAuth) plus a shared static instance, and Endpoint.Auth defaults to Server.Auth so endpoints inherit their server's method and can override it — with NoAuth on a login or refresh endpoint, or with a different method entirely. This replaces AuthenticatedSession, which is deleted. The retry and reauthentication loop moves into URLSession.response(with:), so there is one request API again rather than a parallel session type; with NoAuth the path is behaviorally identical to the previous unauthenticated one. Declaring the method as a static let means every endpoint on a server shares one instance, which is what lets JWTAuth coalesce refreshes across endpoints. Because the Combine and closure-based APIs cannot await an asynchronous authenticate, they are now constrained where T.Auth == NoAuth: calling them with an authenticated endpoint is a compile error instead of a request that silently skips its credentials. maxRetryAttempts moves onto AuthenticationMethod (defaulted to 1), since the retry bound is a property of the strategy rather than of a session. EndpointTaskError gains a public httpResponse accessor, previously a private helper on the session type.
- Add an Authentication topic group to the landing page so the method types are discoverable in generated docs; add MockRegistry alongside the other mocking types - Rewrite the Examples requestProcessor sample, which demonstrated attaching a Bearer token — the case authentication now covers — and point readers at ServerDefinition.auth instead - Remove stale '- Parameter environment:' doc comments left from before servers carried their own environments; they were warning on every docs build
endpointPublisher now honors the endpoint's Auth, including refresh and retry, rather than being constrained to NoAuth endpoints. An earlier claim that Combine could not await an asynchronous authenticate was wrong: the publishers now wrap the async request path through a small bridge that carries the Future promise across the concurrency boundary under a lock, and cancels the underlying Task when the subscription is cancelled. This reuses the async retry loop rather than reimplementing it in Combine operators, which also makes Mocking.handleMockPublisher dead code — mocks now flow through the async path — so it is removed. The closure-based endpointTask stays constrained to NoAuth: it returns a URLSessionDataTask synchronously and structurally cannot authenticate first without changing what it returns. Behavior notes: the publishers now carry the async path's availability (macOS 12, matching what the README already documents as required) and deliver on the completing task rather than an explicit global queue. Tests cover an authenticated endpoint, JWT refresh-and-retry, an authentication error surfacing through the publisher, and cancellation propagating into the in-flight task — the last verified to fail when the cancellation hook is removed.
Source: - performRequest is typed -> T.Response (R was always T.Response), so the mock short-circuit moves inside it once instead of being pasted into all three response overloads; where mocks sit relative to auth is now one documented decision point - Hoist createUrlRequest out of the retry loop: the endpoint is immutable, so only the credentials applied to it vary per attempt - Combine's bridge takes a throwing closure, collapsing three identical do/catch Result wrappers into one - withMock(_:_:test:) delegates to withMock(registering:test:) so one function owns the merge/shadow semantics both document - Merge the twin query/form parameter loops into a single pass - Clamp retries once via AuthenticationMethod.retryAttempts rather than in the transport, so future transports inherit the bound - BasicAuth/HeaderKeyAuth compose their header value in init; the inputs are immutable, so it was re-encoded on every request - JWTAuth.refresh: join-or-start via ??, single-caller awaitRefresh inlined Tests: - Shared testURL helper, response fixtures, and the Gate latch move to TestTransport.swift; AuthTestServer/CombineTestServer are dropped in favor of the existing TestServer - CancellationProbe is rebuilt on Gate, replacing a hand-rolled continuation latch. Gate gains a bounded wait(upTo:) so an unpropagated cancellation fails in 2s instead of hanging the suite Re-verified that the cancellation test still fails when the Combine cancel hook is removed.
The simplify pass moved the Gate latch into TestTransport.swift, which is wrapped in #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS), but AuthenticationTests.swift is not platform-gated and uses Gate — so Linux failed to compile while macOS passed. Gate now lives in its own ungated file. Verified no other symbol defined in a platform-gated test file is referenced from an ungated one.
Both were previously prevented only by prose in the README, and both fail silently or by hanging — the two hardest failure modes to diagnose. Shared-instance requirement: JWTAuth keeps tokens and its in-flight refresh on the instance, so declaring auth as a computed static var instead of a static let hands out a fresh actor per request, losing tokens and turning coalescing into a refresh storm with no error to point at. Debug builds now assert once per endpoint type, naming the type and the fix. Value-type methods are skipped (a fresh copy is harmless) and the check compiles out of release builds. Refresh reentrancy: a refreshHandler that requests an endpoint authenticated by the same JWTAuth deadlocks — the request waits on the refresh that waits on the handler. A task-local marker set around the handler lets authenticate detect the reentrant call and throw RefreshReentrancyError, whose message states the fix. Also reconciles the README's stated requirements with Package.swift: the package builds against iOS 13 / macOS 10.15, while the async and Combine request APIs (and therefore authentication) require macOS 12. Verified on macOS (86 tests) and in the Linux swift:6.0 container (40).
Integration surfaced the limitation this design always had: environment and
authentication were resolved from static/global state, so two client objects
targeting different deployments with different credentials could not coexist.
The environment was process-global per server type, and the auth instance came
from the endpoint's static declaration.
Both are now per-request parameters that default to those declarations:
response(with: endpoint,
environment: T.Server.Environments = T.Server.defaultEnvironment,
auth: any AuthenticationMethod = T.auth)
Because the defaults come from what endpoints already declare, this is purely
additive — existing endpoint declarations and call sites are unchanged, and the
single-client case still passes nothing. Clients that need their own context
pass it, and two of them can issue requests concurrently without interfering.
Everything the declarative model bought is retained, since the static
declaration is now the default rather than the only source: per-endpoint opt-out
for login and refresh endpoints, the endpointTask NoAuth gate, the debug
shared-instance assertion, and JWT refresh coalescing (a client holds one
JWTAuth and passes it, so sharing follows the account).
Removed: ServerDefinition.environment and its EnvironmentStorage backing — the
mutable global that made two environments impossible. defaultEnvironment
remains as the parameter's default. urlRequest() gains an `in:` parameter,
also defaulted.
The same parameters are on endpointPublisher. endpointTask takes environment
but stays NoAuth-gated: it returns a URLSessionDataTask synchronously and
cannot await authentication.
Verified on macOS (88 tests) and in the Linux swift:6.0 container (40),
including a test that interleaves two clients with different environments and
credentials and asserts every request pairs the right host with the right key.
Member
Author
|
🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
First-class authentication for Endpoints, plus per-request runtime context, targeted at the 0.5.0 release.
Endpoints declare how they are authenticated; callers supply runtime context when they need to. A server names the method its endpoints use, and endpoints inherit or override it:
Requests go through the ordinary
URLSessionAPI — there is no separate session or client type:Servers that declare no
authgetNoAuth, so existing endpoints compile and behave exactly as before.Per-request environment and credentials
Static declarations cannot represent two clients talking to different deployments with different credentials — a limitation real integration hit. Both are now request parameters that default to the declarations:
Because the defaults come from what endpoints already declare, this is purely additive: no endpoint declaration or call site had to change, and the entire pre-existing test suite passed untouched. Single-client code still passes nothing.
It also keeps everything the declarative model bought, since the static declaration became the default rather than the only source — per-endpoint opt-out, the
endpointTaskgate, the debug shared-instance assertion, and JWT coalescing (a client holds oneJWTAuthand passes it, so sharing follows the account).Removed:
ServerDefinition.environmentand itsEnvironmentStoragebacking — the process-global that made two simultaneous environments impossible.defaultEnvironmentremains as the parameter default, andurlRequest()gains a defaultedin:parameter.Authentication methods
HeaderKeyAuth,BasicAuth(RFC 7617),CookieAuth,JWTAuth, andNoAuth. Custom methods implement onlyauthenticate(request:)—shouldReauthenticate/reauthenticate/maxRetryAttemptsare defaulted. A test inEndpointsMockingTestsconforms against the public API only (no@testable) to prove external conformance works.JWTAuthis an actor providing:[401]), retries bounded bymaxRetryAttempts.TokenPair.expiresAtand tokens withinexpiryLeeway(default 30s) of expiring refresh before the request is sent.refreshHandlercall (join-or-start runs in a single synchronous stretch of actor isolation).reauthenticate(after:)receives the failed request and skips refreshing when its credentials were already replaced — protects single-use refresh tokens from in-flight requests.onTokensUpdated/onRefreshFailedhooks for persistence and logout.Typed throws + unified error surface
Typed throws throughout:
urlRequest()throwsEndpointError;endpointTaskand the asyncresponse(with:)methods throwT.TaskError. Auth failures surface through a newEndpointTaskError.authenticationError(AuthenticationError)case, so callers handle one exhaustive error type with no casting.AuthenticationError.custom(underlying:)covers implementation-specific failures from custom methods.Which request APIs support authentication
The async/await and Combine APIs both apply authentication, including refresh and retry.
endpointPublisherwraps the async request path through a small bridge that carries Combine's non-Sendablepromise across the concurrency boundary under a lock and cancels the underlyingTaskwhen the subscription is cancelled — so it reuses the async retry loop rather than reimplementing the semantics in Combine operators. That also madeMocking.handleMockPublisherdead code (mocks now flow through the async path), so it is removed.Two behavior notes for the Combine publishers: they now carry the async path's
macOS 12availability (matching what the README already documents as required), and they deliver on the completing task rather than the explicit.global()queue the previous implementation used.The closure-based
endpointTasktakesenvironment:but is the exception for authentication, staying constrainedwhere T.Auth == NoAuth: it returns aURLSessionDataTasksynchronously, so it structurally cannot await an asynchronousauthenticatewithout changing what it returns. Calling it with an authenticated endpoint is a compile error ("requires the types 'HeaderKeyAuth' and 'NoAuth' be equivalent") rather than a request that silently skips its credentials.Mock registry
Mocks are stored per endpoint type instead of as a single type-erased closure. This fixes a crash where any request for endpoint B inside
withMock(A.self)force-cast B's continuation to A's type — exactly the shape multi-endpoint flows take. Unmocked endpoints pass through to the real transport, nested scopes merge (innermost wins per type), and a batch API mocks several endpoints in one scope:Mocks bypass authentication and the refresh/retry loop by design (documented); auth failures can be mocked directly via
.throw(.authenticationError(...)).Failure modes made diagnosable
Two constraints that were previously enforced only by documentation now fail loudly, since both had failure modes that are painful to diagnose:
JWTAuthkeeps tokens and its in-flight refresh on the instance, so writingstatic var auth: JWTAuth { ... }instead ofstatic lethands out a fresh actor per request — losing tokens and turning coalescing into a refresh storm, with no error to point at. Debug builds now assert once per endpoint type, naming the type and the fix; value-type methods are skipped and the check compiles out of release builds.refreshHandlerthat requests an endpoint authenticated by the sameJWTAuthdeadlocks. A task-local marker set around the handler letsauthenticatedetect the reentrant call and throwRefreshReentrancyError, whose message states the fix.Bug fixes found along the way
Environmentstype, so servers sharingTypicalEnvironmentsshared one environment slot — switching one server switched them all (surfaced as a parallel-test flake). Fixed to key by server type, and later removed outright when the environment became a per-request value.ServerDefinition.EnvironmentsrequiresSendable.handlMocktypo fixed (handleMock), andHeader.cookie/.setCookierecategorized as request/response.Testing
88 tests on macOS (URLProtocol-based transport/auth tests are Apple-platform-gated). Package line coverage went from 62% to ~68%, with the auth layer at 93–100% per file and the async
URLSessiontransport paths at 96% (up from 23%). Both platforms verified locally with clean builds, including the Linux swift:6.0 container CI uses, andxcodebuild docbuildruns warning-free.Beyond behavior, tests cover the declaration semantics themselves: an endpoint inheriting its server's auth, opting out with
NoAuth, overriding with a different method, and two endpoints on one server resolving to the same instance. The Combine suite covers an authenticated endpoint, JWT refresh-and-retry through the publisher, an authentication error surfacing, and cancellation propagating into the in-flight task — the last verified to fail when the cancellation hook is removed, since a naive version of that test passes either way.For the per-request context, a test interleaves eight concurrent requests from two clients on different environments with different keys and asserts every request paired the right host with the right credentials — the case that was previously unrepresentable.
Reviewer notes
Package.swift: the package builds against iOS 13 / macOS 10.15, while the async and Combine request APIs (and therefore authentication) require macOS 12. ThemacOS 12annotations look more conservative than the SDK strictly requires, but lowering them would assert runtime support I have not verified, so they are unchanged.reauthenticate()→reauthenticate(after:),CookieAuthlost itsheaderknob,JWTAuth.getTokens()→tokensproperty,AuthenticationError.maxRetriesExceededremoved (was unreachable), and the per-server environment keying changes behavior for anyone relying on shared-enum switching.ApiServer.environment = .stagingis gone. Passenvironment:per request, or rely ondefaultEnvironment. This is what makes concurrent multi-environment clients possible.JWTAuthper client for that client's lifetime. Constructing one per request breaks refresh coalescing — the same failure the debug assertion catches for statics, but instance-side it looks like ordinary code.JWTAuthrefresh endpoint must not be authenticated by that sameJWTAuth(task deadlock) — give itNoAuth; documented in the doc comment and README.0.5.0tag: authentication with automatic refresh (headline), per-request environment and credentials, mock registry, typed throws adoption.