Skip to content

Commit 794ec1f

Browse files
Merge pull request #7 from GoodNotes/arturo/isi-jwt
[iOS] Support for ISI JWT
2 parents 1c15c17 + ea225a3 commit 794ec1f

File tree

9 files changed

+224
-3
lines changed

9 files changed

+224
-3
lines changed

Sources/Error Handling/BackendError.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ enum BackendError: Error, Equatable {
2929
case invalidWebRedemptionToken
3030
case purchaseBelongsToOtherUser
3131
case expiredWebRedemptionToken(obfuscatedEmail: String)
32+
case unableToRefreshJWT
3233

3334
}
3435

@@ -142,6 +143,10 @@ extension BackendError: PurchasesErrorConvertible {
142143
extraUserInfo: [
143144
.obfuscatedEmail: obfuscatedEmail
144145
])
146+
case .unableToRefreshJWT:
147+
let code = BackendErrorCode.unableToRefreshJWT
148+
return ErrorUtils.backendError(withBackendCode: code,
149+
originalBackendErrorCode: code.rawValue)
145150

146151
}
147152
}
@@ -182,7 +187,8 @@ extension BackendError {
182187
.unexpectedBackendResponse,
183188
.invalidWebRedemptionToken,
184189
.purchaseBelongsToOtherUser,
185-
.expiredWebRedemptionToken:
190+
.expiredWebRedemptionToken,
191+
.unableToRefreshJWT:
186192
return nil
187193
}
188194
}
@@ -204,7 +210,8 @@ extension BackendError {
204210
.missingCachedCustomerInfo,
205211
.invalidWebRedemptionToken,
206212
.purchaseBelongsToOtherUser,
207-
.expiredWebRedemptionToken:
213+
.expiredWebRedemptionToken,
214+
.unableToRefreshJWT:
208215
return nil
209216

210217
case let .unexpectedBackendResponse(error, _, _):

Sources/Error Handling/BackendErrorCode.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ enum BackendErrorCode: Int, Error {
5050
case invalidWebRedemptionToken = 7849
5151
case purchaseBelongsToOtherUser = 7852
5252
case expiredWebRedemptionToken = 7853
53+
case unableToRefreshJWT = 6000
5354

5455
/**
5556
* - Parameter code: Generally comes from the backend in json. This may be a String, or an Int, or nothing.
@@ -137,6 +138,8 @@ extension BackendErrorCode {
137138
return .purchaseBelongsToOtherUser
138139
case .expiredWebRedemptionToken:
139140
return .expiredWebPurchaseToken
141+
case .unableToRefreshJWT:
142+
return .unableToRefreshJWT
140143
case .unknownError:
141144
return .unknownError
142145
}

Sources/Error Handling/ErrorCode.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import Foundation
6464
@objc(RCInvalidWebPurchaseToken) case invalidWebPurchaseToken = 39
6565
@objc(RCPurchaseBelongsToOtherUser) case purchaseBelongsToOtherUser = 40
6666
@objc(RCExpiredWebPurchaseToken) case expiredWebPurchaseToken = 41
67+
@objc(RCUnableToRefreshJWT) case unableToRefreshJWT = 1001
6768

6869
// swiftlint:enable missing_docs
6970

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// JWTStrings.swift
3+
// Goodnotes
4+
//
5+
// Created by Arturo Gutiérrez on 6/8/25.
6+
//
7+
8+
import Foundation
9+
10+
// swiftlint:disable identifier_name
11+
12+
enum JWTStrings {
13+
case clearing_cache
14+
case storing_jwt(String)
15+
case using_jwt(String)
16+
case refreshing_jwt
17+
case unable_to_refresh_jwt
18+
case jwt_refreshed
19+
}
20+
21+
extension JWTStrings: LogMessage {
22+
var description: String {
23+
switch self {
24+
case .clearing_cache: return "Clearing JWT cache"
25+
case let .storing_jwt(jwt): return "Storing JWT in cache: '\(jwt)'..."
26+
case let .using_jwt(jwt): return "Using JWT: '\(jwt)'"
27+
case .refreshing_jwt: return "Refreshing JWT calling to CustomerInfo..."
28+
case .unable_to_refresh_jwt: return "Unable to refresh JWT"
29+
case .jwt_refreshed: return "JWT refreshed"
30+
}
31+
}
32+
33+
var category: String { return "jwt" }
34+
}

Sources/Logging/Strings/Strings.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ enum Strings {
3232
static let receipt = ReceiptStrings.self
3333
static let signing = SigningStrings.self
3434
static let storeKit = StoreKitStrings.self
35+
static let jwt = JWTStrings.self
3536

3637
}
3738

Sources/Networking/Backend.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Backend {
2222
let internalAPI: InternalAPI
2323
let customerCenterConfig: CustomerCenterConfigAPI
2424
let redeemWebPurchaseAPI: RedeemWebPurchaseAPI
25+
let jwtManager: JWTManager
2526

2627
private let config: BackendConfiguration
2728

@@ -30,6 +31,7 @@ class Backend {
3031
systemInfo: SystemInfo,
3132
httpClientTimeout: TimeInterval = Configuration.networkTimeoutDefault,
3233
eTagManager: ETagManager,
34+
jwtManager: JWTManager,
3335
operationDispatcher: OperationDispatcher,
3436
attributionFetcher: AttributionFetcher,
3537
offlineCustomerInfoCreator: OfflineCustomerInfoCreator?,
@@ -39,6 +41,7 @@ class Backend {
3941
let httpClient = HTTPClient(apiKey: apiKey,
4042
systemInfo: systemInfo,
4143
eTagManager: eTagManager,
44+
jwtManager: jwtManager,
4245
signing: Signing(apiKey: apiKey, clock: systemInfo.clock),
4346
diagnosticsTracker: diagnosticsTracker,
4447
requestTimeout: httpClientTimeout,
@@ -89,6 +92,7 @@ class Backend {
8992
self.internalAPI = internalAPI
9093
self.customerCenterConfig = customerCenterConfig
9194
self.redeemWebPurchaseAPI = redeemWebPurchaseAPI
95+
self.jwtManager = backendConfig.httpClient.jwtManager
9296
}
9397

9498
func clearHTTPClientCaches() {
@@ -143,6 +147,32 @@ class Backend {
143147
self.customer.post(subscriberAttributes: subscriberAttributes, appUserID: appUserID, completion: completion)
144148
}
145149

150+
func getJWTToken(appUserID: String) async throws -> String {
151+
if let token = self.jwtManager.jwtToken() {
152+
return token
153+
}
154+
155+
Logger.debug(JWTStrings.refreshing_jwt)
156+
157+
// Refresh
158+
let _ = try await withUnsafeThrowingContinuation { continuation in
159+
self.config.systemInfo.isApplicationBackgrounded { isAppBackgrounded in
160+
self.getCustomerInfo(appUserID: appUserID, isAppBackgrounded: isAppBackgrounded) { result in
161+
continuation.resume(with: result)
162+
}
163+
}
164+
}
165+
166+
guard let token = self.jwtManager.jwtToken() else {
167+
Logger.debug(JWTStrings.unable_to_refresh_jwt)
168+
169+
throw BackendError.unableToRefreshJWT
170+
}
171+
172+
Logger.debug(JWTStrings.jwt_refreshed)
173+
174+
return token
175+
}
146176
}
147177

148178
extension Backend {

Sources/Networking/HTTPClient/HTTPClient.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class HTTPClient {
2525
let timeout: TimeInterval
2626
let apiKey: String
2727
let authHeaders: RequestHeaders
28+
let jwtManager: JWTManager
2829

2930
private let session: URLSession
3031
private let state: Atomic<State> = .init(.initial)
@@ -45,6 +46,7 @@ class HTTPClient {
4546
init(apiKey: String,
4647
systemInfo: SystemInfo,
4748
eTagManager: ETagManager,
49+
jwtManager: JWTManager,
4850
signing: SigningType,
4951
diagnosticsTracker: DiagnosticsTrackerType?,
5052
dnsChecker: DNSCheckerType.Type = DNSChecker.self,
@@ -57,11 +59,13 @@ class HTTPClient {
5759
config.timeoutIntervalForRequest = requestTimeout
5860
config.timeoutIntervalForResource = requestTimeout
5961
config.urlCache = nil // We implement our own caching with `ETagManager`.
62+
config.httpCookieStorage = nil // We implement our cookie caching with `JWTManager`
6063
self.session = URLSession(configuration: config,
6164
delegate: RedirectLoggerSessionDelegate(),
6265
delegateQueue: nil)
6366
self.systemInfo = systemInfo
6467
self.eTagManager = eTagManager
68+
self.jwtManager = jwtManager
6569
self.signing = signing
6670
self.diagnosticsTracker = diagnosticsTracker
6771
self.dnsChecker = dnsChecker
@@ -106,6 +110,7 @@ class HTTPClient {
106110

107111
func clearCaches() {
108112
self.eTagManager.clearCaches()
113+
self.jwtManager.clearCaches()
109114
}
110115

111116
var signatureVerificationEnabled: Bool {
@@ -432,6 +437,7 @@ private extension HTTPClient {
432437
data: Data?,
433438
error networkError: Error?,
434439
requestStartTime: Date) {
440+
435441
RCTestAssertNotMainThread()
436442

437443
let response = self.parse(
@@ -479,6 +485,9 @@ private extension HTTPClient {
479485
}
480486

481487
if !requestRetryScheduled {
488+
if let urlResponse = urlResponse {
489+
jwtManager.store(from: urlResponse)
490+
}
482491
request.completionHandler?(response)
483492
}
484493
} else {
@@ -561,9 +570,12 @@ private extension HTTPClient {
561570
withSignatureVerification: request.verificationMode.isEnabled,
562571
refreshETag: request.retried
563572
)
564-
return request.headers.merging(eTagHeader)
573+
return request.headers
574+
.merging(eTagHeader)
575+
.merging(jwtManager.jwtHeader())
565576
} else {
566577
return request.headers
578+
.merging(jwtManager.jwtHeader())
567579
}
568580
}
569581

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
//
2+
// JWTManager.swift
3+
// Goodnotes
4+
//
5+
// Created by Arturo Gutiérrez on 6/8/25.
6+
//
7+
8+
import Foundation
9+
10+
class JWTManager {
11+
private let userDefaults: SynchronizedUserDefaults
12+
13+
convenience init() {
14+
self.init(
15+
userDefaults: UserDefaults(suiteName: Self.suiteName) ?? UserDefaults.standard
16+
)
17+
}
18+
19+
init(userDefaults: UserDefaults) {
20+
self.userDefaults = .init(userDefaults: userDefaults)
21+
}
22+
23+
func store(from urlResponse: URLResponse) {
24+
guard let httpUrlResponse = urlResponse as? HTTPURLResponse else { return }
25+
guard let url = httpUrlResponse.url else { return }
26+
27+
let allHeaders = httpUrlResponse.allHeaderFields
28+
.compactMapKeys { $0 as? String }
29+
.compactMapValues { $0 as? String }
30+
31+
let cookies = HTTPCookie.cookies(withResponseHeaderFields: allHeaders, for: url)
32+
33+
guard let cookie = cookies.first(where: { $0.name == Self.jwtCookieHeaderName }) else { return }
34+
let jwt = cookie.value
35+
36+
Logger.debug(JWTStrings.storing_jwt(jwt))
37+
38+
userDefaults.write {
39+
$0.set(jwt, forKey: .jwtKey)
40+
}
41+
}
42+
43+
func jwtToken() -> String? {
44+
guard let base64 = userDefaults.read({ return $0.object(forKey: .jwtKey) as? String }) else {
45+
return nil
46+
}
47+
48+
guard let jwt = JWTToken(tokenValue: base64) else { return nil }
49+
guard isJWTValid(jwt) else {
50+
userDefaults.write { $0.removeObject(forKey: .jwtKey) }
51+
return nil
52+
}
53+
54+
Logger.debug(JWTStrings.using_jwt(base64))
55+
return base64
56+
}
57+
58+
func jwtHeader() -> [String: String] {
59+
guard let jwt = jwtToken() else { return [:] }
60+
return ["Cookie": "\(Self.jwtCookieHeaderName)=\(jwt)"]
61+
}
62+
63+
func clearCaches() {
64+
Logger.debug(Strings.jwt.clearing_cache)
65+
66+
userDefaults.write {
67+
$0.removePersistentDomain(forName: Self.suiteName)
68+
}
69+
}
70+
71+
private func isJWTValid(_ jwt: JWTToken) -> Bool {
72+
// We consider a token as as expired for less than 2 minute of validity
73+
// (using two minutes to play safe with the default timeouts of any network call).
74+
return Double(jwt.expiresAt - 120) > Date().timeIntervalSince1970
75+
}
76+
}
77+
78+
struct JWTToken: Codable {
79+
enum CodingKeys: String, CodingKey {
80+
case expiresAt = "exp"
81+
}
82+
83+
let expiresAt: Int64
84+
}
85+
86+
extension JWTToken {
87+
public init?(tokenValue: String?) {
88+
guard let tokenValue else { return nil }
89+
let jwtComponents = tokenValue.components(separatedBy: ".")
90+
guard jwtComponents.count == 3 else { return nil }
91+
92+
var jwtPayload = jwtComponents[1]
93+
// JWT uses base64url. Converting to standard base 64
94+
jwtPayload = jwtPayload
95+
.replacingOccurrences(of: "-", with: "+")
96+
.replacingOccurrences(of: "_", with: "/")
97+
// Padding payload to 4 multiple, otherwise base64 decode will fail
98+
.padding(toLength: ((jwtPayload.count + 3) / 4) * 4, withPad: "=", startingAt: 0)
99+
100+
guard let decodedData = Data(base64Encoded: jwtPayload, options: []) else {
101+
return nil
102+
}
103+
guard let jwtToken = try? JSONDecoder().decode(JWTToken.self, from: decodedData) else {
104+
return nil
105+
}
106+
self.init(expiresAt: jwtToken.expiresAt)
107+
}
108+
}
109+
110+
extension String {
111+
fileprivate static let jwtKey = "isi_jwt"
112+
}
113+
114+
extension JWTManager {
115+
fileprivate static let suiteNameBase: String = "revenuecat.jwt"
116+
fileprivate static var suiteName: String {
117+
guard let bundleID = Bundle.main.bundleIdentifier else {
118+
return suiteNameBase
119+
}
120+
return bundleID + ".\(suiteNameBase)"
121+
}
122+
123+
fileprivate static let jwtCookieHeaderName: String = "isi_token"
124+
}

Sources/Purchasing/Purchases/Purchases.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
300300

301301
let receiptFetcher = ReceiptFetcher(requestFetcher: fetcher, systemInfo: systemInfo)
302302
let eTagManager = ETagManager()
303+
let jwtManager = JWTManager()
303304
let attributionTypeFactory = AttributionTypeFactory()
304305
let attributionFetcher = AttributionFetcher(attributionFactory: attributionTypeFactory, systemInfo: systemInfo)
305306
let userDefaults = userDefaults ?? UserDefaults.computeDefault()
@@ -329,6 +330,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
329330
systemInfo: systemInfo,
330331
httpClientTimeout: networkTimeout,
331332
eTagManager: eTagManager,
333+
jwtManager: jwtManager,
332334
operationDispatcher: operationDispatcher,
333335
attributionFetcher: attributionFetcher,
334336
offlineCustomerInfoCreator: .createIfAvailable(
@@ -1256,6 +1258,13 @@ public extension Purchases {
12561258
}
12571259
}
12581260

1261+
// MARK: ISI JWT
1262+
public extension Purchases {
1263+
func getJWTToken() async throws -> String {
1264+
return try await self.backend.getJWTToken(appUserID: self.appUserID)
1265+
}
1266+
}
1267+
12591268
// swiftlint:enable missing_docs
12601269

12611270
// MARK: - Paywalls & Customer Center

0 commit comments

Comments
 (0)