Skip to content

Authentication with automatic refresh, plus per-request environment and credentials - #21

Merged
zac merged 23 commits into
mainfrom
authentication
Jul 31, 2026
Merged

Authentication with automatic refresh, plus per-request environment and credentials#21
zac merged 23 commits into
mainfrom
authentication

Conversation

@zac

@zac zac commented Jul 24, 2026

Copy link
Copy Markdown
Member

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:

struct ApiServer: ServerDefinition {
    static let auth = JWTAuth(initialTokens: loadTokens(), refreshHandler: refresh)
    ...
}

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

struct LoginEndpoint: Endpoint {
    typealias Server = ApiServer
    static var auth: NoAuth { NoAuth() }          // opts out
}

Requests go through the ordinary URLSession API — there is no separate session or client type:

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

Servers that declare no auth get NoAuth, 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:

func response<T: Endpoint>(
    with endpoint: T,
    environment: T.Server.Environments = T.Server.defaultEnvironment,
    auth: any AuthenticationMethod = T.auth
) async throws(T.TaskError) -> T.Response
// Two clients, different deployments and credentials, concurrently:
try await session.response(with: ProfileEndpoint(), environment: clientA.environment, auth: clientA.auth)
try await session.response(with: ProfileEndpoint(), environment: clientB.environment, auth: clientB.auth)

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 endpointTask gate, the debug shared-instance assertion, and JWT coalescing (a client holds one JWTAuth and passes it, so sharing follows the account).

Removed: ServerDefinition.environment and its EnvironmentStorage backing — the process-global that made two simultaneous environments impossible. defaultEnvironment remains as the parameter default, and urlRequest() gains a defaulted in: parameter.

Authentication methods

HeaderKeyAuth, BasicAuth (RFC 7617), CookieAuth, JWTAuth, and NoAuth. Custom methods implement only authenticate(request:)shouldReauthenticate/reauthenticate/maxRetryAttempts are defaulted. A test in EndpointsMockingTests conforms against the public API only (no @testable) to prove external conformance works.

JWTAuth is an actor providing:

  • Reactive refresh on configurable status codes (default [401]), retries bounded by maxRetryAttempts.
  • Proactive refresh: set TokenPair.expiresAt and tokens within expiryLeeway (default 30s) of expiring refresh before the request is sent.
  • Concurrent refreshes coalesce into one refreshHandler call (join-or-start runs in a single synchronous stretch of actor isolation).
  • Rotation protection: 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/onRefreshFailed hooks for persistence and logout.

Typed throws + unified error surface

Typed throws throughout: urlRequest() throws EndpointError; endpointTask and the async response(with:) methods throw T.TaskError. Auth failures surface through a new EndpointTaskError.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. endpointPublisher wraps the async request path through a small bridge that carries Combine's non-Sendable promise across the concurrency boundary under a lock and cancels the underlying Task when the subscription is cancelled — so it reuses the async retry loop rather than reimplementing the semantics in Combine operators. That also made Mocking.handleMockPublisher dead 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 12 availability (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 endpointTask takes environment: but is the exception for authentication, staying constrained where T.Auth == NoAuth: it returns a URLSessionDataTask synchronously, so it structurally cannot await an asynchronous authenticate without 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:

try await withMock { mocks in
    mocks.register(RefreshEndpoint.self, action: .return(...))
    mocks.register(ProfileEndpoint.self, action: .return(...))
} test: { ... }

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:

  • Shared-instance requirement. JWTAuth keeps tokens and its in-flight refresh on the instance, so writing static var auth: JWTAuth { ... } instead of 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 and the check compiles out of release builds.
  • Refresh reentrancy. A refreshHandler that requests an endpoint authenticated by the same JWTAuth deadlocks. A task-local marker set around the handler lets authenticate detect the reentrant call and throw RefreshReentrancyError, whose message states the fix.

Bug fixes found along the way

  • Environment storage was keyed by the Environments type, so servers sharing TypicalEnvironments shared 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.Environments requires Sendable.
  • handlMock typo fixed (handleMock), and Header.cookie/.setCookie recategorized 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 URLSession transport paths at 96% (up from 23%). Both platforms verified locally with clean builds, including the Linux swift:6.0 container CI uses, and xcodebuild docbuild runs 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

  • The README's stated requirements now match Package.swift: the package builds against iOS 13 / macOS 10.15, while the async and Combine request APIs (and therefore authentication) require macOS 12. The macOS 12 annotations look more conservative than the SDK strictly requires, but lowering them would assert runtime support I have not verified, so they are unchanged.
  • Pre-1.0, so several intentional breaking changes: reauthenticate()reauthenticate(after:), CookieAuth lost its header knob, JWTAuth.getTokens()tokens property, AuthenticationError.maxRetriesExceeded removed (was unreachable), and the per-server environment keying changes behavior for anyone relying on shared-enum switching.
  • Biggest breaking change: ApiServer.environment = .staging is gone. Pass environment: per request, or rely on defaultEnvironment. This is what makes concurrent multi-environment clients possible.
  • Hold one JWTAuth per 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.
  • A JWTAuth refresh endpoint must not be authenticated by that same JWTAuth (task deadlock) — give it NoAuth; documented in the doc comment and README.
  • Suggested release notes for the 0.5.0 tag: authentication with automatic refresh (headline), per-request environment and credentials, mock registry, typed throws adoption.

zac added 18 commits February 2, 2026 08:07
- 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
@zac zac changed the title Authentication support with typed throws, proactive token refresh, and a mock registry Endpoint-declared authentication with typed throws, proactive token refresh, and a mock registry Jul 27, 2026
zac added 5 commits July 27, 2026 11:31
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.
@zac zac changed the title Endpoint-declared authentication with typed throws, proactive token refresh, and a mock registry Authentication with automatic refresh, plus per-request environment and credentials Jul 27, 2026
@zac

zac commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

🚀

@zac
zac merged commit da6d998 into main Jul 31, 2026
2 checks passed
@zac
zac deleted the authentication branch July 31, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant