104 lines
3.5 KiB
Swift
104 lines
3.5 KiB
Swift
import Foundation
|
|
|
|
public enum TokenSecurity {
|
|
public static let defaultRotationWindow: TimeInterval = 60 * 60
|
|
private static let authPrefixes = ["token", "bearer"]
|
|
|
|
// Basic high-entropy hex token detector for accidental commits.
|
|
private static let tokenRegex = try? NSRegularExpression(pattern: "\\b[a-fA-F0-9]{32,}\\b")
|
|
|
|
public static func containsPotentialHardcodedToken(in text: String) -> Bool {
|
|
guard let tokenRegex else {
|
|
return false
|
|
}
|
|
let range = NSRange(location: 0, length: text.utf16.count)
|
|
return tokenRegex.firstMatch(in: text, options: [], range: range) != nil
|
|
}
|
|
|
|
public static func isRedactedToken(_ token: String?) -> Bool {
|
|
guard let token else { return false }
|
|
let upper = token.uppercased()
|
|
return upper.contains("REDACTED") || upper.contains("YOUR_TOKEN") || upper.contains("PLACEHOLDER")
|
|
}
|
|
|
|
public static func sanitizeToken(_ token: String?) -> String? {
|
|
guard let rawToken = token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
rawToken.isEmpty == false else {
|
|
return nil
|
|
}
|
|
|
|
let normalized = normalizeAuthPrefix(rawToken)
|
|
guard normalized.isEmpty == false,
|
|
isRedactedToken(normalized) == false else {
|
|
return nil
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
public static func jwtExpiration(_ token: String) -> Date? {
|
|
let segments = token.split(separator: ".")
|
|
guard segments.count == 3 else {
|
|
return nil
|
|
}
|
|
|
|
let payloadSegment = String(segments[1])
|
|
guard let payloadData = base64URLDecode(payloadSegment),
|
|
let object = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
|
|
let exp = object["exp"] as? TimeInterval else {
|
|
return nil
|
|
}
|
|
|
|
return Date(timeIntervalSince1970: exp)
|
|
}
|
|
|
|
public static func isExpired(_ token: String?, now: Date = Date()) -> Bool {
|
|
guard let token = sanitizeToken(token) else {
|
|
return true
|
|
}
|
|
|
|
guard let expiration = jwtExpiration(token) else {
|
|
// Non-JWT tokens cannot be locally validated for expiry.
|
|
return false
|
|
}
|
|
|
|
return expiration <= now
|
|
}
|
|
|
|
public static func shouldRotate(_ token: String?, now: Date = Date(), rotationWindow: TimeInterval = defaultRotationWindow) -> Bool {
|
|
guard let token = sanitizeToken(token),
|
|
let expiration = jwtExpiration(token) else {
|
|
return false
|
|
}
|
|
|
|
return expiration.timeIntervalSince(now) <= rotationWindow
|
|
}
|
|
|
|
private static func base64URLDecode(_ input: String) -> Data? {
|
|
var value = input
|
|
.replacingOccurrences(of: "-", with: "+")
|
|
.replacingOccurrences(of: "_", with: "/")
|
|
|
|
let remainder = value.count % 4
|
|
if remainder > 0 {
|
|
value.append(String(repeating: "=", count: 4 - remainder))
|
|
}
|
|
|
|
return Data(base64Encoded: value)
|
|
}
|
|
|
|
private static func normalizeAuthPrefix(_ token: String) -> String {
|
|
let lowercased = token.lowercased()
|
|
for prefix in authPrefixes {
|
|
if lowercased == prefix {
|
|
return ""
|
|
}
|
|
|
|
let prefixed = "\(prefix) "
|
|
if lowercased.hasPrefix(prefixed) {
|
|
return String(token.dropFirst(prefixed.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|
|
return token
|
|
}
|
|
}
|