Skip to content

Authentication and Token Refresh

Husnain Ali edited this page Sep 27, 2026 · 3 revisions

Authentication and Token Refresh

Authentication strategies

The strategy on the configuration decides how credentials attach; the requirement on the endpoint decides whether they must.

var config = NetworkConfiguration(baseURL: "https://api.example.com")

config.authorization = BearerAuth()                       // Authorization: Bearer <token from storage>
config.authorization = APIKeyAuth(key: "...", field: .header("X-API-Key"))
config.authorization = BasicAuth(username: "u", password: "p")
config.authorization = CustomAuth { request, _ in
    request.setValue(sign(request), forHTTPHeaderField: "X-Signature")
}
struct GetPublicFeed: Endpoint {
    typealias Response = Feed
    var path: String { "/feed" }
    var authentication: AuthRequirement { .none }          // default
}

struct GetInbox: Endpoint {
    typealias Response = [Message]
    var path: String { "/me/inbox" }
    var authentication: AuthRequirement { .required }      // must have a token; 401 triggers refresh
}

Token management

Tokens live in a TokenStorage:

config.tokenStorage = InMemoryTokenStorage()                        // tests, or short-lived processes
config.tokenStorage = KeychainTokenStorage(service: "com.acme.app") // production; device-only by default

KeychainTokenStorage defaults to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (not synced to iCloud, survives backgrounding). Override with the accessibility: parameter. A custom store is any TokenStorage conformer (SQLite, an in-house secrets manager, and so on).

A TokenPair holds accessToken, optional refreshToken, and an optional expiryDate used for proactive refresh.

Automatic token refresh

Pass a refresh handler to NetworkClient and refresh becomes automatic:

request  ->  401  ->  single-flight refresh  ->  store new TokenPair  ->  retry original request once
                                    │
       concurrent requests during refresh queue behind it, then resume with the new token
let client = NetworkClient(
    configuration: config,
    refresh: { storage in
        let old = try await storage.tokenPair()
        let new = try await myAuthAPI.exchange(refreshToken: old.refreshToken)
        return TokenPair(accessToken: new.access, refreshToken: new.refresh, expiryDate: new.expiry)
    },
    onSessionExpired: { await AppRouter.logout() }
)

Guarantees:

  • Single-flight. Ten simultaneous 401s cause exactly one refresh call; the other nine await it.
  • Queued requests. Requests started mid-refresh wait and then use the new token.
  • Loop guard. A second 401 for the same request after a fresh token surfaces NetworkError.sessionExpired and calls onSessionExpired once.
  • Proactive refresh. If the stored TokenPair has an expiryDate, the client refreshes proactiveRefreshLeeway seconds early (default 60) instead of waiting for a 401.
  • Storage failures are not "no token". A locked keychain or an undecodable pair fails the request instead of sending it unauthenticated, and a refreshed pair that cannot be saved fails the refresh with tokenRefreshFailed rather than leaving the stale token in place.
  • No deadlock. The refresh request should set skipRequestQueue = true so a full concurrency queue plus an expired token cannot wedge.

Treat NetworkError.sessionExpired as your single "log the user out" signal. See Error Handling and Best Practices and a Complete Example.

OAuth 2.0

Authorization Code flow with PKCE. The package builds the authorization URL and exchanges the code; the app presents the URL (typically in ASWebAuthenticationSession) and captures the redirect.

let flow = AuthorizationCodeFlow(configuration: OAuthConfiguration(
    authorizationEndpoint: URL(string: "https://accounts.google.com/o/oauth2/v2/auth")!,
    tokenEndpoint: URL(string: "https://oauth2.googleapis.com/token")!,
    clientID: "...",
    redirectURI: "myapp://callback",
    scopes: ["openid", "profile", "email"]
))

let state = AuthorizationCodeFlow.makeState()
let pkce  = PKCE()

let authURL = flow.authorizationURL(state: state, pkce: pkce)
// present authURL, receive redirectURL

let code   = try flow.authorizationCode(fromRedirect: redirectURL, expectedState: state)
let tokens = try await flow.exchange(code: code, pkce: pkce)

// hand automatic refresh to the client:
let client = NetworkClient(configuration: config, refresh: flow.tokenManagerRefreshHandler())
  • code_verifier is 43 to 128 characters from a CSPRNG; code_challenge is base64url(SHA256(verifier)) (RFC 7636).
  • state mismatch on the redirect throws before any token exchange.
  • OAuth is entirely optional; nothing else in the package depends on it.
  • AuthorizationCodeFlow accepts an optional logger: so a malformed token response's decoding cause is logged even though OAuthError.malformedTokenResponse can't carry it.

Clone this wiki locally