Auth Tokens
A complete example of secure authentication token management for a TaskFlow productivity app using CryptStore. Covers storing access and refresh tokens, a token refresh flow, logout, and testing with InMemoryCrypt.
Define Keychain Keys
Keychain keys have no default values. A missing token is nil, not a fallback string.
import ForgeCrypt
enum AuthKeys {
static let accessToken = CryptKey<String>(
key: "taskflow.auth.accessToken",
accessibility: .afterFirstUnlock
)
static let refreshToken = CryptKey<String>(
key: "taskflow.auth.refreshToken",
accessibility: .afterFirstUnlock
)
static let tokenExpiry = CryptKey<Date>(
key: "taskflow.auth.tokenExpiry",
accessibility: .afterFirstUnlock
)
static let userId = CryptKey<String>(
key: "taskflow.auth.userId",
accessibility: .afterFirstUnlock
)
} AuthTokenStorage
A service that wraps CryptStoring with domain-specific methods for authentication.
import Foundation
import ForgeCrypt
protocol AuthTokenStorageProtocol: Sendable {
var accessToken: String? { get throws }
var refreshToken: String? { get throws }
var isLoggedIn: Bool { get throws }
var isTokenExpired: Bool { get throws }
func storeTokens(
access: String,
refresh: String,
expiresIn: TimeInterval,
userId: String
) throws
func updateAccessToken(_ token: String, expiresIn: TimeInterval) throws
func clearAll() throws
}
final class AuthTokenStorage: AuthTokenStorageProtocol {
private let keychain: CryptStoring
init(keychain: CryptStoring) {
self.keychain = keychain
}
// MARK: - Read
var accessToken: String? {
get throws { try keychain.get(AuthKeys.accessToken) }
}
var refreshToken: String? {
get throws { try keychain.get(AuthKeys.refreshToken) }
}
var isLoggedIn: Bool {
get throws { try keychain.contains(AuthKeys.accessToken) }
}
var isTokenExpired: Bool {
get throws {
guard let expiry: Date = try keychain.get(AuthKeys.tokenExpiry) else {
return true
}
return Date() >= expiry
}
}
// MARK: - Write
func storeTokens(
access: String,
refresh: String,
expiresIn: TimeInterval,
userId: String
) throws {
try keychain.set(access, for: AuthKeys.accessToken)
try keychain.set(refresh, for: AuthKeys.refreshToken)
try keychain.set(
Date().addingTimeInterval(expiresIn),
for: AuthKeys.tokenExpiry
)
try keychain.set(userId, for: AuthKeys.userId)
}
func updateAccessToken(_ token: String, expiresIn: TimeInterval) throws {
try keychain.set(token, for: AuthKeys.accessToken)
try keychain.set(
Date().addingTimeInterval(expiresIn),
for: AuthKeys.tokenExpiry
)
}
// MARK: - Delete
func clearAll() throws {
try keychain.deleteAll()
}
} AuthService with Token Refresh
An authentication service that uses AuthTokenStorage for persistence and handles token refresh when the access token expires.
import Foundation
enum AuthError: Error {
case notLoggedIn
case refreshFailed
case invalidResponse
}
final class AuthService {
private let tokenStorage: AuthTokenStorageProtocol
private let session: URLSession
private let baseURL: URL
init(
tokenStorage: AuthTokenStorageProtocol,
session: URLSession = .shared,
baseURL: URL
) {
self.tokenStorage = tokenStorage
self.session = session
self.baseURL = baseURL
}
// MARK: - Login
func login(email: String, password: String) async throws {
let url = baseURL.appendingPathComponent("auth/login")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"email": email,
"password": password,
])
let (data, _) = try await session.data(for: request)
let response = try JSONDecoder().decode(TokenResponse.self, from: data)
try tokenStorage.storeTokens(
access: response.accessToken,
refresh: response.refreshToken,
expiresIn: response.expiresIn,
userId: response.userId
)
}
// MARK: - Authenticated request
func authenticatedRequest(for url: URL) async throws -> URLRequest {
// Refresh if expired
if try tokenStorage.isTokenExpired {
try await refreshTokens()
}
guard let token = try tokenStorage.accessToken else {
throw AuthError.notLoggedIn
}
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return request
}
// MARK: - Token refresh
private func refreshTokens() async throws {
guard let refresh = try tokenStorage.refreshToken else {
throw AuthError.notLoggedIn
}
let url = baseURL.appendingPathComponent("auth/refresh")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"refreshToken": refresh,
])
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
// Refresh failed — force logout
try tokenStorage.clearAll()
throw AuthError.refreshFailed
}
let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data)
try tokenStorage.updateAccessToken(
tokenResponse.accessToken,
expiresIn: tokenResponse.expiresIn
)
}
// MARK: - Logout
func logout() throws {
try tokenStorage.clearAll()
}
}
// MARK: - Response model
struct TokenResponse: Codable {
let accessToken: String
let refreshToken: String
let expiresIn: TimeInterval
let userId: String
} Production Setup
let keychain = CryptStore(service: "com.taskflow")
let tokenStorage = AuthTokenStorage(keychain: keychain)
let authService = AuthService(
tokenStorage: tokenStorage,
baseURL: URL(string: "https://api.taskflow.app")!
)
// Shared with widget via access group
let sharedKeychain = CryptStore(
service: "com.taskflow",
accessGroup: "group.com.taskflow.shared"
) SwiftUI Usage
@Observable
final class AuthViewModel {
private let authService: AuthService
private let tokenStorage: AuthTokenStorageProtocol
var isLoggedIn = false
var isLoading = false
var errorMessage: String?
init(authService: AuthService, tokenStorage: AuthTokenStorageProtocol) {
self.authService = authService
self.tokenStorage = tokenStorage
self.isLoggedIn = (try? tokenStorage.isLoggedIn) ?? false
}
func login(email: String, password: String) async {
isLoading = true
errorMessage = nil
do {
try await authService.login(email: email, password: password)
isLoggedIn = true
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
func logout() {
try? authService.logout()
isLoggedIn = false
}
} Testing
Use InMemoryCrypt for tests. No entitlements, no Keychain access, works on CI.
import Testing
@testable import TaskFlow
struct AuthTokenStorageTests {
let keychain = InMemoryCrypt()
let storage: AuthTokenStorage
init() {
storage = AuthTokenStorage(keychain: keychain)
}
@Test func initiallyNotLoggedIn() throws {
#expect(try !storage.isLoggedIn)
#expect(try storage.accessToken == nil)
#expect(try storage.refreshToken == nil)
}
@Test func storeAndRetrieveTokens() throws {
try storage.storeTokens(
access: "access_123",
refresh: "refresh_456",
expiresIn: 3600,
userId: "user_1"
)
#expect(try storage.isLoggedIn)
#expect(try storage.accessToken == "access_123")
#expect(try storage.refreshToken == "refresh_456")
#expect(try !storage.isTokenExpired) // Just set, not expired
}
@Test func updateAccessToken() throws {
try storage.storeTokens(
access: "old_token",
refresh: "refresh",
expiresIn: 3600,
userId: "user_1"
)
try storage.updateAccessToken("new_token", expiresIn: 7200)
#expect(try storage.accessToken == "new_token")
#expect(try storage.refreshToken == "refresh") // Unchanged
}
@Test func clearAllRemovesEverything() throws {
try storage.storeTokens(
access: "token",
refresh: "refresh",
expiresIn: 3600,
userId: "user_1"
)
try storage.clearAll()
#expect(try !storage.isLoggedIn)
#expect(try storage.accessToken == nil)
#expect(try storage.refreshToken == nil)
}
@Test func expiredTokenIsDetected() throws {
// Store with 0 seconds expiry (already expired)
try storage.storeTokens(
access: "token",
refresh: "refresh",
expiresIn: 0,
userId: "user_1"
)
#expect(try storage.isTokenExpired)
}
@Test func missingExpiryTreatedAsExpired() throws {
#expect(try storage.isTokenExpired) // No expiry stored
}
}