FS
ForgeStorage

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.

AuthKeys.swift
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.

AuthTokenStorage.swift
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.

AuthService.swift
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

AppSetup.swift
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

AuthViewModel.swift
@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.

AuthTokenStorageTests.swift
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
    }
}