docs/iOS_TOKEN_LIFECYCLE.md
iOS Token Lifecycle Implementation Guide
Overview
This guide documents the current JWT token lifecycle management in the iOS app, with different behaviors for guest vs authenticated users.
Token Behavior by User Type
Guest Users (Current Implementation)
- Storage: iOS Keychain (persisted) - stored with key
"guest_token" - Token Lifetime: 15 minutes from creation
- On App Close: Token preserved in Keychain
- On App Launch: Auto-restore session if token not expired
- On Token Expiry: Token cleared, must tap "Continue as Guest" again
- Rate Limiting: 5 edits per day per device (tracked by IP + Device ID)
Authenticated Users (Partial Implementation)
- Storage: iOS Keychain (persisted) - stored with key
"access_token" - Lifetime: Depends on OAuth provider
- On App Close: Token preserved
- On App Launch: Auto-restore session if token not expired
- On Token Expiry: Token cleared, must re-authenticate
- Refresh Token: Stored with key
"refresh_token"(not yet used for auto-refresh)
Required Headers for All Requests
Current Implementation (APIService.swift)
// Headers are set in processHairstyle and other API methods:
if let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
// Add device ID header for guest users (required by backend for rate limiting)
if isGuestUser {
request.setValue(deviceID, forHTTPHeaderField: "X-Device-ID")
}
}
Note: X-Device-ID is only required for guest users. Device ID is persisted in UserDefaults.
Core Implementation
1. AuthenticationManager (Current Implementation)
class AuthenticationManager: NSObject, ObservableObject {
@Published var isAuthenticated = false
@Published var currentUser: User?
@Published var authToken: String?
private let apiService = APIService.shared
// Guest sign-in stores token in Keychain
private func signInAsGuest() {
Task {
do {
let response = try await apiService.createGuestSession()
await MainActor.run {
self.authToken = response.guestToken
self.apiService.setAuthToken(response.guestToken, isGuest: true)
self.currentUser = User(
id: UUID().uuidString,
email: nil,
name: "Guest",
isGuest: true
)
self.isAuthenticated = true
// Store guest token in Keychain (PERSISTED)
KeychainHelper.shared.save(response.guestToken, forKey: "guest_token")
}
} catch {
// Handle error
}
}
}
// Check existing auth on app launch
func checkExistingAuth() {
if let token = KeychainHelper.shared.load(forKey: "access_token") {
// Check if token is expired using JWTDecoder
if JWTDecoder.isTokenExpired(token) {
KeychainHelper.shared.delete(forKey: "access_token")
isAuthenticated = false
return
}
// Set up authenticated session
authToken = token
apiService.setAuthToken(token, isGuest: false)
isAuthenticated = true
} else if let guestToken = KeychainHelper.shared.load(forKey: "guest_token") {
// Check if guest token is expired
if JWTDecoder.isTokenExpired(guestToken) {
KeychainHelper.shared.delete(forKey: "guest_token")
isAuthenticated = false
return
}
// Restore guest session
authToken = guestToken
apiService.setAuthToken(guestToken, isGuest: true)
let guestId = JWTDecoder.getSubject(guestToken) ?? UUID().uuidString
currentUser = User(id: guestId, email: nil, name: "Guest", isGuest: true)
isAuthenticated = true
}
}
}
2. JWT Token Decoder (JWTDecoder.swift)
struct JWTDecoder {
/// Decodes a JWT token and returns its claims
static func decode(_ token: String) -> [String: Any]? {
let segments = token.split(separator: ".")
guard segments.count == 3 else { return nil }
let payloadSegment = String(segments[1])
let paddedPayload = padBase64String(payloadSegment)
guard let payloadData = Data(base64Encoded: paddedPayload) else { return nil }
return try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
}
/// Checks if a JWT token is expired
static func isTokenExpired(_ token: String) -> Bool {
guard let claims = decode(token),
let exp = claims["exp"] as? TimeInterval else {
return true
}
let expirationDate = Date(timeIntervalSince1970: exp)
let now = Date()
// Add 5-minute buffer for clock skew
let bufferTime: TimeInterval = 5 * 60
let adjustedExpirationDate = expirationDate.addingTimeInterval(-bufferTime)
return now > adjustedExpirationDate
}
}
3. API Service (APIService.swift)
class APIService {
static let shared = APIService()
private var authToken: String?
private var isGuestUser: Bool = false
private let deviceID: String
private init() {
// Generate or retrieve persistent device ID
if let savedDeviceID = UserDefaults.standard.string(forKey: "deviceID") {
self.deviceID = savedDeviceID
} else {
let newDeviceID = UUID().uuidString
UserDefaults.standard.set(newDeviceID, forKey: "deviceID")
self.deviceID = newDeviceID
}
}
func setAuthToken(_ token: String?, isGuest: Bool = false) {
self.authToken = token
self.isGuestUser = isGuest
}
func makeAuthenticatedRequest(
endpoint: String,
method: String = "POST",
body: Data? = nil,
completion: @escaping (Result<Data, Error>) -> Void
) {
// Check for valid token
guard let token = AuthManager.shared.getCurrentToken() else {
// Token expired or missing - notify UI to show auth screen
NotificationCenter.default.post(
name: .authenticationRequired,
object: nil
)
completion(.failure(APIError.authenticationRequired))
return
}
// Build request with required headers
guard let url = URL(string: "\(GuestAuthService.baseURL)\(endpoint)") else {
completion(.failure(APIError.invalidURL))
return
}
var request = URLRequest(url: url)
request.httpMethod = method
request.httpBody = body
// Set required headers
let headers = APIHeaders.getHeaders(
token: token,
deviceID: AuthManager.shared.deviceID
)
headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
// Make request
URLSession.shared.dataTask(with: request) { data, response, error in
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 401:
// Token invalid/expired
AuthManager.shared.clearSession()
NotificationCenter.default.post(
name: .authenticationRequired,
object: nil
)
completion(.failure(APIError.unauthorized))
case 400:
// Might be missing X-Device-ID
if let data = data,
let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data),
errorResponse.error.message.contains("X-Device-ID") {
completion(.failure(APIError.missingDeviceID))
} else {
completion(.failure(APIError.badRequest))
}
case 429:
// Rate limited
self.handleRateLimit(response: httpResponse, data: data)
completion(.failure(APIError.rateLimited))
case 200...299:
// Success
if let data = data {
completion(.success(data))
} else {
completion(.failure(APIError.noData))
}
default:
completion(.failure(APIError.serverError(statusCode: httpResponse.statusCode)))
}
} else if let error = error {
completion(.failure(error))
} else {
completion(.failure(APIError.unknown))
}
}.resume()
}
private func handleRateLimit(response: HTTPURLResponse, data: Data?) {
// Extract rate limit info
let remaining = response.allHeaderFields["X-RateLimit-Remaining"] as? String
let resetTime = response.allHeaderFields["X-RateLimit-Reset"] as? String
let retryAfter = response.allHeaderFields["Retry-After"] as? String
// Notify UI about rate limit
NotificationCenter.default.post(
name: .rateLimitExceeded,
object: nil,
userInfo: [
"remaining": remaining ?? "0",
"resetTime": resetTime ?? "",
"retryAfter": retryAfter ?? "3600"
]
)
}
}
4. App Lifecycle Integration
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Check for existing session
if AuthManager.shared.isTokenValid() {
// Token still valid (app was in background, not closed)
navigateToMainApp()
} else {
// No valid token - show auth screen
navigateToAuthScreen()
}
return true
}
func applicationWillTerminate(_ application: UIApplication) {
// Guest tokens are automatically cleared (memory only)
// No action needed
}
}
// SceneDelegate for iOS 13+
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneDidBecomeActive(_ scene: UIScene) {
// Check token validity when app becomes active
if !AuthManager.shared.isTokenValid() {
navigateToAuthScreen()
}
}
}
5. View Controller Integration
class MainViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Listen for auth events
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAuthRequired),
name: .authenticationRequired,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRateLimit),
name: .rateLimitExceeded,
object: nil
)
}
@objc private func handleAuthRequired() {
DispatchQueue.main.async {
// Show auth screen
let authVC = AuthViewController()
authVC.modalPresentationStyle = .fullScreen
self.present(authVC, animated: true)
}
}
@objc private func handleRateLimit(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let retryAfter = userInfo["retryAfter"] as? String,
let seconds = Int(retryAfter) else { return }
DispatchQueue.main.async {
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
var message = "You've reached your daily limit of 5 hairstyle edits."
if hours > 0 {
message += " Try again in \(hours) hour\(hours > 1 ? "s" : "")."
} else if minutes > 0 {
message += " Try again in \(minutes) minute\(minutes > 1 ? "s" : "")."
}
let alert = UIAlertController(
title: "Daily Limit Reached",
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}
}
6. Error Types
enum APIError: Error {
case authenticationRequired
case unauthorized
case rateLimited
case missingDeviceID
case badRequest
case serverError(statusCode: Int)
case noData
case invalidURL
case unknown
var localizedDescription: String {
switch self {
case .authenticationRequired:
return "Please sign in to continue"
case .unauthorized:
return "Your session has expired. Please sign in again."
case .rateLimited:
return "You've reached your daily limit. Try again tomorrow."
case .missingDeviceID:
return "Device identification required"
case .badRequest:
return "Invalid request"
case .serverError(let code):
return "Server error (\(code))"
case .noData:
return "No data received"
case .invalidURL:
return "Invalid URL"
case .unknown:
return "An unknown error occurred"
}
}
}
struct ErrorResponse: Codable {
let error: ErrorDetail
}
struct ErrorDetail: Codable {
let code: String
let message: String
}
7. Notification Names
extension Notification.Name {
static let authenticationRequired = Notification.Name("authenticationRequired")
static let rateLimitExceeded = Notification.Name("rateLimitExceeded")
static let sessionExpired = Notification.Name("sessionExpired")
}
Testing Checklist
- [x] Guest sign in generates new token each time
- [x] Token IS persisted in Keychain after app close
- [x] App auto-restores guest session if token not expired (15 min window)
- [x] X-Device-ID header is sent with guest requests only
- [x] 401 errors are returned for expired/invalid tokens
- [x] 400 errors returned for missing Device ID on guest requests
- [x] 429 rate limit errors with proper headers
- [x] Token expiry (15 minutes for guests) handled locally
- [x] Rate limit persists across multiple guest sessions (same device)
- [x] Local JWT validation without API call on app launch
Security Notes
- Guest tokens ARE stored: Currently persisted in Keychain (may change)
- Device ID for guests only: Required for guest rate limiting
- Handle 401 gracefully: Clear token and show auth screen
- Rate limits are enforced: 5 edits/day per device, tracked by IP + Device ID hash
- Token expiration: 15 minutes for guests, checked locally before API calls
- Clock skew buffer: 5-minute buffer when checking expiration locally
Rate Limiting Behavior
- Tracked by: SHA256(IP Address + Device ID) for guests, User ID for authenticated
- Limit: 5 successful API calls per day for guests
- Reset: Every 24 hours rolling window
- Cannot bypass: Getting new token doesn't reset count (tied to device)
- Required header: X-Device-ID must be present for guest requests only
- Backend validation: Returns 400 if X-Device-ID missing for guest users
Current Backend Responses
Guest Token Creation (/api/auth/guest)
{
"guestToken": "eyJ...",
"expiresIn": 900, // 15 minutes
"limitations": {
"maxEditsPerDay": 5,
"featuresDisabled": ["save", "history", "premium_templates"]
}
}
Error Responses
- 401 Unauthorized: "Invalid or expired token" or "Authentication required"
- 400 Bad Request: "X-Device-ID header is required for guest users"
- 429 Too Many Requests: Includes rate limit headers
