Modern Networking in iOS with URLSession and async/await – Part 2
Source: Dev.to
Part 2 – Secure Token Handling
In Part 1 we built a clean networking layer using Swift’s modern concurrency and URLSession. The example endpoints were public and didn’t require authentication. Real‑world apps, however, usually need to authenticate users and attach short‑lived access tokens to every request. These tokens eventually expire, and we need a way to obtain a new access token without forcing the user to log in again.
In this part we’ll walk through how to implement a secure and robust token‑handling mechanism on top of the networking client from Part 1.
Understanding access and refresh tokens
Access tokens grant access to protected APIs. They are typically short‑lived (minutes to hours) so that an attacker only has a limited window if the token is compromised.
Refresh tokens are credentials that allow the client to request a new access token. Because they can be used to mint new access tokens, they need to be protected as if they were user passwords.
Token model
import Foundation
/// A container for access/refresh tokens and their expiration date.
/// Conforms to `Codable` so it can be encoded to and decoded from JSON.
struct TokenBundle: Codable {
let accessToken: String
let refreshToken: String
let expiresAt: Date
/// Returns `true` if the access token is expired.
var isExpired: Bool {
return expiresAt // original logic placeholder
}
/// Loads a token bundle from the Keychain.
static func load() throws -> TokenBundle? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound {
return nil
}
guard status == errSecSuccess, let data = item as? Data else {
throw KeychainError.unhandled(status: status)
}
return try JSONDecoder().decode(TokenBundle.self, from: data)
}
/// Saves a token bundle to the Keychain.
static func save(_ bundle: TokenBundle) throws {
let data = try JSONEncoder().encode(bundle)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data
]
let status = SecItemAdd(query as CFDictionary, nil)
if status != errSecSuccess && status != errSecDuplicateItem {
throw KeychainError.unhandled(status: status)
}
}
/// Removes the token bundle from the Keychain.
static func delete() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unhandled(status: status)
}
}
}
This helper encodes a TokenBundle to Data, stores it under a single key in the Keychain, and decodes it back when needed. It also includes a delete() method to clear the stored tokens when the user logs out.
Concurrency‑safe token management
When multiple network requests need a valid token simultaneously, we must avoid refreshing the token more than once at the same time. Using a Swift actor ensures that only one refresh call happens concurrently.
import Foundation
/// Manages access and refresh tokens.
/// Uses an actor to serialize token access and refresh operations safely.
actor AuthManager {
/// The currently running refresh task, if any.
private var refreshTask: Task<TokenBundle, Error>?
/// Cached token bundle loaded from the Keychain.
private var currentTokens: TokenBundle?
init() {
// Load any persisted tokens at initialization.
currentTokens = try? KeychainService.load()
}
/// Returns a valid token bundle, refreshing if necessary.
/// Throws if no tokens are available or if refresh fails.
func validTokenBundle() async throws -> TokenBundle {
// If a refresh is already in progress, await its result.
if let task = refreshTask {
return try await task.value
}
// If we have a cached token that isn’t expired, return it.
if let tokens = currentTokens, !tokens.isExpired {
return tokens
}
// No valid token – start a refresh.
let task = Task { try await refreshTokens() }
refreshTask = task
defer { refreshTask = nil }
let newTokens = try await task.value
currentTokens = newTokens
try KeychainService.save(newTokens)
return newTokens
}
/// Performs the actual network call to exchange the refresh token for a new access token.
private func refreshTokens() async throws -> TokenBundle {
guard let refreshToken = currentTokens?.refreshToken else {
throw AuthError.missingRefreshToken
}
// Build the request (replace URL & parameters with your own API).
var request = URLRequest(url: URL(string: "https://api.example.com/auth/refresh")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["refresh_token": refreshToken]
request.httpBody = try JSONEncoder().encode(body)
// Use the networking client from Part 1.
let (data, response) = try await URLSession.shared.data(for: request)
// Basic response validation.
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw AuthError.refreshFailed
}
// Decode the new token bundle.
let newBundle = try JSONDecoder().decode(TokenBundle.self, from: data)
return newBundle
}
/// Clears stored tokens (e.g., on logout).
func clearTokens() throws {
currentTokens = nil
try KeychainService.delete()
}
}
// MARK: - Supporting Types
enum AuthError: Error {
case missingRefreshToken
case refreshFailed
}
- The
AuthManageractor guarantees that only one refresh operation runs at a time. validTokenBundle()first checks for an in‑flight refresh, then for a cached, non‑expired token, and finally triggers a refresh if needed.- The refreshed tokens are persisted to the Keychain and cached in memory for subsequent calls.
You can now inject AuthManager into your networking layer (e.g., via dependency injection) and have every request automatically receive a fresh, valid access token.
AuthManager Actor (alternative implementation)
/// An actor that lazily loads tokens from the keychain at initialization.
actor AuthManager {
private var currentTokens: TokenBundle?
private var refreshTask: Task<TokenBundle, Error>?
init() {
// Load tokens from the keychain if they exist.
currentTokens = try? KeychainService.load()
}
/// Returns a valid token bundle, refreshing it if necessary.
func validTokenBundle() async throws -> TokenBundle {
// No stored tokens means the user must log in.
guard let tokens = currentTokens else {
throw AuthError.noCredentials
}
// If not expired, return immediately.
if !tokens.isExpired {
return tokens
}
// Otherwise refresh.
return try await refreshTokens()
}
/// Forces a refresh of the tokens regardless of expiration status.
func refreshTokens() async throws -> TokenBundle {
// If a refresh is already happening, await it.
if let task = refreshTask {
return try await task.value
}
// Ensure we have a refresh token.
guard let tokens = currentTokens else {
throw AuthError.noCredentials
}
// Create a new task to perform the refresh.
let task = Task { () throws -> TokenBundle in
defer { refreshTask = nil }
// Build a request to your auth server’s token endpoint.
// Replace `api.example.com` and path with your actual auth server and endpoint.
var components = URLComponents()
components.scheme = "https"
components.host = "api.example.com" // change to your auth server
components.path = "/oauth/token"
var request = URLRequest(url: components.url!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = ["refresh_token": tokens.refreshToken]
request.httpBody = try JSONEncoder().encode(body)
// Perform the network call.
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw AuthError.refreshFailed
}
// Decode the new token bundle.
return try JSONDecoder().decode(TokenBundle.self, from: data)
}
refreshTask = task
return try await task.value
}
}
Supporting Types
enum AuthError: Error {
case noCredentials
case refreshFailed
}
Integrating with the Network Client
/// A generic network client that can automatically attach authentication headers.
struct NetworkClient {
let authManager: AuthManager
/// Sends a request, automatically adding the bearer token when required.
func send<T: Request>(_ request: T,
allowRetry: Bool = true) async throws -> T.Response {
var urlRequest = try request.makeURLRequest()
// Add the Authorization header when needed.
if request.endpoint.requiresAuthentication {
let tokens = try await authManager.validTokenBundle()
urlRequest.setValue("Bearer \(tokens.accessToken)",
forHTTPHeaderField: "Authorization")
}
// Perform the request.
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
// Handle 401 Unauthorized.
if httpResponse.statusCode == 401 {
guard allowRetry else {
throw NetworkError.unauthorized
}
do {
// Attempt to refresh the token and retry once.
_ = try await authManager.refreshTokens()
return try await send(request, allowRetry: false)
} catch {
// Refresh failed → propagate the error.
throw error
}
}
// Decode the successful response.
return try JSONDecoder().decode(T.Response.self, from: data)
}
}
Supporting Types
enum NetworkError: Error {
case invalidResponse
case unauthorized
// … other cases …
}
Summary
AuthManagerhandles token storage, validation, and refresh, ensuring only one refresh request runs at a time.Endpoint.requiresAuthenticationtells the client whether a request needs an auth header.NetworkClient.sendinjects the bearer token when required, retries once after a 401 by refreshing the token, and propagates errors otherwise.
You can view the full project on GitHub:
https://github.com/Pro-Mobile-Dev/ios-modern-network-layer