TokenModel.swift 9.83 KB
//
//  TokenModel.swift
//  SwiftWarplyFramework
//
//  Created by Manos Chorianopoulos on 24/6/25.
//

import Foundation

/// TokenModel represents OAuth tokens with JWT parsing capabilities
/// This model handles token lifecycle management, expiration detection, and validation
struct TokenModel {
    let accessToken: String
    let refreshToken: String
    let clientId: String?
    let clientSecret: String?
    let expirationDate: Date?
    
    // MARK: - Token Lifecycle Management
    
    /// Check if the access token is currently expired
    var isExpired: Bool {
        guard let expirationDate = expirationDate else { 
            // If we can't parse expiration, assume token is still valid
            return false 
        }
        return Date() >= expirationDate
    }
    
    /// Check if the token should be refreshed proactively (5 minutes before expiry)
    var shouldRefresh: Bool {
        guard let expirationDate = expirationDate else { 
            // If we can't parse expiration, don't refresh proactively
            return false 
        }
        // Refresh 5 minutes (300 seconds) before expiration
        return Date().addingTimeInterval(300) >= expirationDate
    }
    
    /// Validate token format and structure
    var isValid: Bool {
        return !accessToken.isEmpty && 
               !refreshToken.isEmpty && 
               isValidJWTFormat(accessToken)
    }
    
    /// Get time until token expires (in seconds)
    var timeUntilExpiration: TimeInterval? {
        guard let expirationDate = expirationDate else { return nil }
        return expirationDate.timeIntervalSinceNow
    }
}

// MARK: - JWT Parsing Extension
extension TokenModel {
    
    /// Parse JWT expiration date from access token
    /// JWT structure: header.payload.signature (Base64 URL encoded)
    static func parseJWTExpiration(from token: String) -> Date? {
        print("🔍 [TokenModel] Parsing JWT expiration from token")
        
        // JWT structure: header.payload.signature
        let components = token.components(separatedBy: ".")
        guard components.count == 3 else {
            print("⚠️ [TokenModel] Invalid JWT format - expected 3 components, got \(components.count)")
            return nil
        }
        
        // Decode payload (second component)
        let payload = components[1]
        guard let data = base64UrlDecode(payload) else {
            print("⚠️ [TokenModel] Failed to decode JWT payload")
            return nil
        }
        
        // Parse JSON payload
        do {
            if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
                if let exp = json["exp"] as? TimeInterval {
                    let expirationDate = Date(timeIntervalSince1970: exp)
                    print("✅ [TokenModel] JWT expiration parsed: \(expirationDate)")
                    return expirationDate
                } else {
                    print("⚠️ [TokenModel] No 'exp' claim found in JWT payload")
                }
            } else {
                print("⚠️ [TokenModel] JWT payload is not a valid JSON object")
            }
        } catch {
            print("❌ [TokenModel] JWT parsing error: \(error)")
        }
        
        return nil
    }
    
    /// Base64 URL decode (JWT uses URL-safe Base64 without padding)
    private static func base64UrlDecode(_ string: String) -> Data? {
        var base64 = string
            .replacingOccurrences(of: "-", with: "+")
            .replacingOccurrences(of: "_", with: "/")
        
        // Add padding if needed (Base64 requires length to be multiple of 4)
        let remainder = base64.count % 4
        if remainder > 0 {
            base64 += String(repeating: "=", count: 4 - remainder)
        }
        
        return Data(base64Encoded: base64)
    }
}

// MARK: - Validation Methods
extension TokenModel {
    
    /// Check if token follows JWT format (3 parts separated by dots)
    private func isValidJWTFormat(_ token: String) -> Bool {
        let components = token.components(separatedBy: ".")
        return components.count == 3 && 
               components.allSatisfy { !$0.isEmpty }
    }
    
    /// Get formatted expiration info for debugging
    var expirationInfo: String {
        guard let expirationDate = expirationDate else {
            return "No expiration date available"
        }
        
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .medium
        
        if isExpired {
            return "Expired at \(formatter.string(from: expirationDate))"
        } else if shouldRefresh {
            return "Should refresh - expires at \(formatter.string(from: expirationDate))"
        } else {
            return "Valid until \(formatter.string(from: expirationDate))"
        }
    }
    
    /// Get time remaining until expiration in a human-readable format
    var timeRemainingDescription: String {
        guard let timeRemaining = timeUntilExpiration else {
            return "Unknown"
        }
        
        if timeRemaining <= 0 {
            return "Expired"
        }
        
        let hours = Int(timeRemaining) / 3600
        let minutes = Int(timeRemaining.truncatingRemainder(dividingBy: 3600)) / 60
        let seconds = Int(timeRemaining.truncatingRemainder(dividingBy: 60))
        
        if hours > 0 {
            return "\(hours)h \(minutes)m \(seconds)s"
        } else if minutes > 0 {
            return "\(minutes)m \(seconds)s"
        } else {
            return "\(seconds)s"
        }
    }
}

// MARK: - Convenience Initializers
extension TokenModel {
    
    /// Initialize with automatic JWT expiration parsing
    init(accessToken: String, refreshToken: String, clientId: String? = nil, clientSecret: String? = nil) {
        self.accessToken = accessToken
        self.refreshToken = refreshToken
        self.clientId = clientId ?? ""  // Legacy credential - always use empty string
        self.clientSecret = clientSecret ?? ""  // Legacy credential - always use empty string
        self.expirationDate = Self.parseJWTExpiration(from: accessToken)
        
        print("🔐 [TokenModel] Created token model - \(expirationInfo)")
    }
    
    /// Initialize from database values (returns nil if required tokens are missing)
    init?(accessToken: String?, refreshToken: String?, clientId: String?, clientSecret: String?) {
        guard let accessToken = accessToken, !accessToken.isEmpty,
              let refreshToken = refreshToken, !refreshToken.isEmpty else {
            print("⚠️ [TokenModel] Cannot create token model - missing required tokens")
            return nil
        }
        
        self.init(
            accessToken: accessToken,
            refreshToken: refreshToken,
            clientId: clientId,
            clientSecret: clientSecret
        )
    }
    
    /// Create a new TokenModel with updated tokens (preserves client credentials)
    func withUpdatedTokens(accessToken: String, refreshToken: String) -> TokenModel {
        return TokenModel(
            accessToken: accessToken,
            refreshToken: refreshToken,
            clientId: self.clientId,
            clientSecret: self.clientSecret
        )
    }
}

// MARK: - Debug and Logging Support
extension TokenModel {
    
    /// Safe description for logging (doesn't expose sensitive data)
    var debugDescription: String {
        let accessTokenPreview = String(accessToken.prefix(10)) + "..."
        let refreshTokenPreview = String(refreshToken.prefix(10)) + "..."
        
        return """
        TokenModel {
            accessToken: \(accessTokenPreview)
            refreshToken: \(refreshTokenPreview)
            clientId: \(clientId ?? "nil")
            hasClientSecret: \(clientSecret != nil)
            expirationDate: \(expirationDate?.description ?? "nil")
            isExpired: \(isExpired)
            shouldRefresh: \(shouldRefresh)
            isValid: \(isValid)
            timeRemaining: \(timeRemainingDescription)
        }
        """
    }
    
    /// Detailed token status for debugging
    var statusDescription: String {
        if !isValid {
            return "❌ Invalid token format"
        } else if isExpired {
            return "🔴 Token expired"
        } else if shouldRefresh {
            return "🟡 Token should be refreshed"
        } else {
            return "🟢 Token is valid"
        }
    }
}

// MARK: - Database Integration Helpers
extension TokenModel {
    
    /// Convert to tuple format for database storage
    var databaseValues: (accessToken: String, refreshToken: String, clientId: String?, clientSecret: String?) {
        return (accessToken, refreshToken, clientId, clientSecret)
    }
    
    /// Create from database tuple
    static func fromDatabaseValues(_ values: (accessToken: String?, refreshToken: String?, clientId: String?, clientSecret: String?)) -> TokenModel? {
        return TokenModel(
            accessToken: values.accessToken,
            refreshToken: values.refreshToken,
            clientId: values.clientId,
            clientSecret: values.clientSecret
        )
    }
}

// MARK: - Token Refresh Support
extension TokenModel {
    
    /// Check if this token can be used for refresh (has refresh token and client credentials)
    var canRefresh: Bool {
        return !refreshToken.isEmpty && 
               clientId != nil && 
               clientSecret != nil
    }
    
    /// Get refresh request parameters
    var refreshParameters: [String: String]? {
        guard let clientId = clientId,
              let clientSecret = clientSecret else {
            print("⚠️ [TokenModel] Cannot create refresh parameters - missing client credentials")
            return nil
        }
        
        return [
            "client_id": clientId,
            "client_secret": clientSecret,
            "refresh_token": refreshToken,
            "grant_type": "refresh_token"
        ]
    }
}