JWTManager.swift 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. // LoopFollow
  2. // JWTManager.swift
  3. import CryptoKit
  4. import Foundation
  5. class JWTManager {
  6. static let shared = JWTManager()
  7. private struct CachedToken {
  8. let jwt: String
  9. let expiresAt: Date
  10. }
  11. /// Cache keyed by "keyId:teamId", 55 min TTL
  12. private var cache: [String: CachedToken] = [:]
  13. private let ttl: TimeInterval = 55 * 60
  14. private let lock = NSLock()
  15. private init() {}
  16. func getOrGenerateJWT(keyId: String, teamId: String, apnsKey: String) -> String? {
  17. let cacheKey = "\(keyId):\(teamId)"
  18. lock.lock()
  19. defer { lock.unlock() }
  20. if let cached = cache[cacheKey], Date() < cached.expiresAt {
  21. return cached.jwt
  22. }
  23. do {
  24. let privateKey = try loadPrivateKey(from: apnsKey)
  25. let header = try encodeHeader(keyId: keyId)
  26. let payload = try encodePayload(teamId: teamId)
  27. let signingInput = "\(header).\(payload)"
  28. guard let signingData = signingInput.data(using: .utf8) else {
  29. LogManager.shared.log(category: .apns, message: "Failed to encode JWT signing input")
  30. return nil
  31. }
  32. let signature = try privateKey.signature(for: signingData)
  33. let signatureBase64 = base64URLEncode(signature.rawRepresentation)
  34. let signedJWT = "\(signingInput).\(signatureBase64)"
  35. cache[cacheKey] = CachedToken(jwt: signedJWT, expiresAt: Date().addingTimeInterval(ttl))
  36. LogManager.shared.log(category: .apns, message: "JWT generated for key \(LogRedactor.keyId(keyId)) (TTL 55 min)")
  37. return signedJWT
  38. } catch {
  39. LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)")
  40. return nil
  41. }
  42. }
  43. func invalidateCache() {
  44. lock.lock()
  45. defer { lock.unlock() }
  46. cache.removeAll()
  47. LogManager.shared.log(category: .apns, message: "JWT cache invalidated")
  48. }
  49. // MARK: - Private Helpers
  50. private func loadPrivateKey(from apnsKey: String) throws -> P256.Signing.PrivateKey {
  51. let cleaned = apnsKey
  52. .replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "")
  53. .replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "")
  54. .replacingOccurrences(of: "\n", with: "")
  55. .replacingOccurrences(of: "\r", with: "")
  56. .trimmingCharacters(in: .whitespaces)
  57. guard let keyData = Data(base64Encoded: cleaned) else {
  58. throw JWTError.keyDecodingFailed
  59. }
  60. return try P256.Signing.PrivateKey(derRepresentation: keyData)
  61. }
  62. private func encodeHeader(keyId: String) throws -> String {
  63. let header: [String: String] = [
  64. "alg": "ES256",
  65. "kid": keyId,
  66. ]
  67. let data = try JSONSerialization.data(withJSONObject: header)
  68. return base64URLEncode(data)
  69. }
  70. private func encodePayload(teamId: String) throws -> String {
  71. let now = Int(Date().timeIntervalSince1970)
  72. let payload: [String: Any] = [
  73. "iss": teamId,
  74. "iat": now,
  75. ]
  76. let data = try JSONSerialization.data(withJSONObject: payload)
  77. return base64URLEncode(data)
  78. }
  79. private func base64URLEncode(_ data: Data) -> String {
  80. data.base64EncodedString()
  81. .replacingOccurrences(of: "+", with: "-")
  82. .replacingOccurrences(of: "/", with: "_")
  83. .replacingOccurrences(of: "=", with: "")
  84. }
  85. private enum JWTError: Error, LocalizedError {
  86. case keyDecodingFailed
  87. var errorDescription: String? {
  88. switch self {
  89. case .keyDecodingFailed:
  90. return "Failed to decode APNs p8 key content. Ensure it is valid base64."
  91. }
  92. }
  93. }
  94. }