diff --git a/App/iOS/Delegates/SceneDelegate.swift b/App/iOS/Delegates/SceneDelegate.swift index c284636e91e..c9405bb9fee 100644 --- a/App/iOS/Delegates/SceneDelegate.swift +++ b/App/iOS/Delegates/SceneDelegate.swift @@ -203,6 +203,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { if Preferences.URP.referralLookupOutstanding.value == false { appDelegate.dau.sendPingToServer() } + + BraveSkusManager.refreshSKUCredential(isPrivate: PrivateBrowsingManager.shared.isPrivateBrowsing) } func sceneWillResignActive(_ scene: UIScene) { diff --git a/Client/BraveSkus/BraveSkusManager.swift b/Client/BraveSkus/BraveSkusManager.swift new file mode 100644 index 00000000000..34d4717951c --- /dev/null +++ b/Client/BraveSkus/BraveSkusManager.swift @@ -0,0 +1,158 @@ +// Copyright 2022 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import Shared +import BraveShared +import BraveCore +import BraveVPN +import os.log + +public class BraveSkusManager { + private let sku: SkusSkusService + + public init?(isPrivateMode: Bool) { + guard let skusService = Skus.SkusServiceFactory.get(privateMode: isPrivateMode) else { + assert(isPrivateMode, "SkusServiceFactory failed to intialize in regular mode, something is wrong.") + return nil + } + + self.sku = skusService + } + + public static func refreshSKUCredential(isPrivate: Bool) { + guard let _ = Preferences.VPN.skusCredential.value, + let domain = Preferences.VPN.skusCredentialDomain.value, + let expirationDate = Preferences.VPN.expirationDate.value else { + Logger.module.debug("No skus credentials stored in the app.") + return + } + + guard expirationDate < Date() else { + Logger.module.debug("Existing sku credential has not expired yet, no need to refresh it.") + return + } + + guard let manager = BraveSkusManager(isPrivateMode: isPrivate) else { + return + } + + Logger.module.debug("Refreshing sku credential. Clearing old credential from persistence.") + + BraveVPN.clearSkusCredentials() + + manager.credentialSummary(for: domain) { completion in + Logger.module.debug("credentialSummary response") + } + } + + // MARK: - Handling SKU methods. + + func refreshOrder(for orderId: String, domain: String, resultJSON: @escaping (Any?) -> Void) { + sku.refreshOrder(domain, orderId: orderId) { completion in + do { + guard let data = completion.data(using: .utf8) else { return } + let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + Logger.module.debug("refreshOrder json parsed successfully") + resultJSON(json) + } catch { + resultJSON(nil) + Logger.module.error("refrshOrder: Failed to decode json: \(error.localizedDescription)") + } + } + } + + func fetchOrderCredentials(for orderId: String, domain: String, resultCredential: @escaping (String) -> Void) { + sku.fetchOrderCredentials(domain, orderId: orderId) { completion in + Logger.module.debug("skus fetchOrderCredentials") + resultCredential(completion) + } + } + + func prepareCredentialsPresentation(for domain: String, path: String, + resultCredential: ((String) -> Void)?) { + Logger.module.debug("skus prepareCredentialsPresentation") + sku.prepareCredentialsPresentation(domain, path: path) { credential in + if !credential.isEmpty { + if let vpnCredential = BraveSkusWebHelper.fetchVPNCredential(credential, domain: domain) { + Preferences.VPN.skusCredential.value = credential + Preferences.VPN.skusCredentialDomain.value = domain + Preferences.VPN.expirationDate.value = vpnCredential.expirationDate + + BraveVPN.setCustomVPNCredential(vpnCredential) + } + } else { + Logger.module.debug("skus empty credential from prepareCredentialsPresentation call") + } + + resultCredential?(credential) + } + } + + func credentialSummary(for domain: String, resultJSON: @escaping (Any?) -> Void) { + sku.credentialSummary(domain) { [weak self] completion in + do { + Logger.module.debug("skus credentialSummary") + + guard let data = completion.data(using: .utf8) else { + resultJSON(nil) + return + } + let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + + let jsonDecoder = JSONDecoder() + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + let credentialSummaryJson = try jsonDecoder.decode(CredentialSummary.self, from: data) + + if credentialSummaryJson.isValid { + + if Preferences.VPN.skusCredential.value == nil { + Logger.module.debug("The credential does NOT exists, calling prepareCredentialsPresentation") + self?.prepareCredentialsPresentation(for: domain, path: "*", resultCredential: nil) + } else { + Logger.module.debug("The credential exists, NOT calling prepareCredentialsPresentation") + } + } else { + if !credentialSummaryJson.active { + Logger.module.debug("The credential summary is not active") + } + + if credentialSummaryJson.remainingCredentialCount <= 0 { + Logger.module.debug("The credential summary does not have any remaining credentials") + } + } + + resultJSON(json) + } catch { + resultJSON(nil) + Logger.module.error("refrshOrder: Failed to decode json: \(error.localizedDescription)") + } + } + } + + private struct CredentialSummary: Codable { + let expiresAt: Date + let active: Bool + let remainingCredentialCount: Int + // The json for credential summary has additional fields. They are not used in the app at the moment. + + var isValid: Bool { + active && remainingCredentialCount > 0 + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.active = try container.decode(Bool.self, forKey: .active) + self.remainingCredentialCount = try container.decode(Int.self, forKey: .remainingCredentialCount) + guard let expiresAt = + BraveSkusWebHelper.milisecondsOptionalDate(from: try container.decode(String.self, forKey: .expiresAt)) else { + throw DecodingError.typeMismatch(Data.self, .init(codingPath: [], + debugDescription: "Failed to decode Data from String")) + } + + self.expiresAt = expiresAt + } + } +} diff --git a/Client/Frontend/Browser/BraveSkusWebHelper.swift b/Client/BraveSkus/BraveSkusWebHelper.swift similarity index 91% rename from Client/Frontend/Browser/BraveSkusWebHelper.swift rename to Client/BraveSkus/BraveSkusWebHelper.swift index 27ea4a9ea3f..e6435c5a37d 100644 --- a/Client/Frontend/Browser/BraveSkusWebHelper.swift +++ b/Client/BraveSkus/BraveSkusWebHelper.swift @@ -7,6 +7,7 @@ import Foundation import Shared import BraveShared import os.log +import BraveVPN class BraveSkusWebHelper { /// On which hosts the receipt should be allowed to be exposed. @@ -102,18 +103,23 @@ class BraveSkusWebHelper { } /// Takes credential passed from the Brave SKUs and extract a proper credential to pass to the GuardianConnect framework. - static func fetchVPNCredential(_ credential: String, domain: String) -> (credential: String, environment: String)? { + static func fetchVPNCredential(_ credential: String, domain: String) -> SkusVPNCredential? { guard let unescapedCredential = credential.unescape(), let env = environment(domain: domain), let sampleUrl = URL(string: "https://brave.com") else { return nil } - guard let guardianConnectCredential = - HTTPCookie.cookies(withResponseHeaderFields: - ["Set-Cookie": unescapedCredential], for: sampleUrl).first?.value else { + guard let cookie = HTTPCookie.cookies(withResponseHeaderFields: + ["Set-Cookie": unescapedCredential], for: sampleUrl).first else { return nil } - return (guardianConnectCredential, env) + let guardianCredential = cookie.value + + guard let expirationDate = cookie.expiresDate else { + return nil + } + + return .init(guardianCredential: guardianCredential, environment: env, expirationDate: expirationDate) } static func milisecondsOptionalDate(from stringDate: String) -> Date? { diff --git a/Client/Frontend/Browser/BrowserViewController.swift b/Client/Frontend/Browser/BrowserViewController.swift index 2ec0bcae896..9ec3b35fe52 100644 --- a/Client/Frontend/Browser/BrowserViewController.swift +++ b/Client/Frontend/Browser/BrowserViewController.swift @@ -2397,7 +2397,6 @@ extension BrowserViewController: TabDelegate { BraveTalkScriptHandler(tab: tab, rewards: rewards, launchNativeBraveTalk: { [weak self] tab, room, token in self?.launchNativeBraveTalk(tab: tab, room: room, token: token) }), - BraveSkusScriptHandler(tab: tab), ResourceDownloadScriptHandler(tab: tab), DownloadContentScriptHandler(browserController: self, tab: tab), WindowRenderScriptHandler(tab: tab), @@ -2414,6 +2413,10 @@ extension BrowserViewController: TabDelegate { tab.requestBlockingContentHelper, ] + if let braveSkusHandler = BraveSkusScriptHandler(tab: tab) { + injectedScripts.append(braveSkusHandler) + } + // Only add the logins handler and wallet provider if the tab is NOT a private browsing tab if !tab.isPrivate { injectedScripts += [ diff --git a/Client/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/BraveSkusScriptHandler.swift b/Client/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/BraveSkusScriptHandler.swift index aa1aa351a82..229ebfc66ee 100644 --- a/Client/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/BraveSkusScriptHandler.swift +++ b/Client/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/BraveSkusScriptHandler.swift @@ -14,12 +14,14 @@ import os.log class BraveSkusScriptHandler: TabContentScript { typealias ReplyHandler = (Any?, String?) -> Void - private weak var tab: Tab? - private let sku: SkusSkusService? + private let braveSkusManager: BraveSkusManager - required init(tab: Tab) { - self.tab = tab - self.sku = Skus.SkusServiceFactory.get(privateMode: tab.isPrivate) + required init?(tab: Tab) { + guard let manager = BraveSkusManager(isPrivateMode: tab.isPrivate) else { + return nil + } + + self.braveSkusManager = manager } static let scriptName = "BraveSkusScript" @@ -45,7 +47,9 @@ class BraveSkusScriptHandler: TabContentScript { case credentialsSummary = 4 } - func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage, replyHandler: @escaping (Any?, String?) -> Void) { + func userContentController(_ userContentController: WKUserContentController, + didReceiveScriptMessage message: WKScriptMessage, + replyHandler: @escaping (Any?, String?) -> Void) { if !verifyMessage(message: message) { assertionFailure("Missing required security token.") return @@ -70,87 +74,27 @@ class BraveSkusScriptHandler: TabContentScript { switch methodId { case Method.refreshOrder.rawValue: if let orderId = data["orderId"] as? String { - handleRefreshOrder(for: orderId, domain: requestHost, replyHandler: replyHandler) + + braveSkusManager.refreshOrder(for: orderId, domain: requestHost) { result in + replyHandler(result, nil) + } } case Method.fetchOrderCredentials.rawValue: if let orderId = data["orderId"] as? String { - handleFetchOrderCredentials(for: orderId, domain: requestHost, replyHandler: replyHandler) + braveSkusManager.fetchOrderCredentials(for: orderId, domain: requestHost) { result in + replyHandler(result, nil) + } } case Method.prepareCredentialsPresentation.rawValue: - if let domain = data["domain"] as? String, let path = data["path"] as? String { - handlePrepareCredentialsSummary(for: domain, path: path, replyHandler: replyHandler) - } + assertionFailure("The website should never call the credentialsPresentation.") case Method.credentialsSummary.rawValue: if let domain = data["domain"] as? String { - handleCredentialsSummary(for: domain, replyHandler: replyHandler) + braveSkusManager.credentialSummary(for: domain) { result in + replyHandler(result, nil) + } } default: assertionFailure("Failure, the website called unhandled method with id: \(methodId)") } } - - private func handleRefreshOrder(for orderId: String, domain: String, replyHandler: @escaping ReplyHandler) { - sku?.refreshOrder(domain, orderId: orderId) { completion in - do { - guard let data = completion.data(using: .utf8) else { return } - let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) - Logger.module.debug("skus refreshOrder") - replyHandler(json, nil) - } catch { - replyHandler("", nil) - Logger.module.error("refrshOrder: Failed to decode json: \(error.localizedDescription)") - } - } - } - - private func handleFetchOrderCredentials(for orderId: String, domain: String, replyHandler: @escaping ReplyHandler) { - sku?.fetchOrderCredentials(domain, orderId: orderId) { completion in - Logger.module.debug("skus fetchOrderCredentials") - replyHandler(completion, nil) - } - } - - /// If no reply handler is passed, this function will not send the callback back to the website. - /// Reason is this method may be called from within another web handler, and the callback can be called only once or it crashes. - private func handlePrepareCredentialsSummary(for domain: String, path: String, replyHandler: ReplyHandler?) { - Logger.module.debug("skus prepareCredentialsPresentation") - sku?.prepareCredentialsPresentation(domain, path: path) { credential in - if !credential.isEmpty { - if let vpnCredential = BraveSkusWebHelper.fetchVPNCredential(credential, domain: domain) { - Preferences.VPN.skusCredential.value = credential - Preferences.VPN.skusCredentialDomain.value = domain - - BraveVPN.setCustomVPNCredential(vpnCredential.credential, environment: vpnCredential.environment) - } - } else { - Logger.module.debug("skus empty credential from prepareCredentialsPresentation call") - } - - replyHandler?(credential, nil) - } - } - - private func handleCredentialsSummary(for domain: String, replyHandler: @escaping ReplyHandler) { - sku?.credentialSummary(domain) { [weak self] completion in - do { - Logger.module.debug("skus credentialSummary") - - guard let data = completion.data(using: .utf8) else { return } - let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) - - replyHandler(json, nil) - - if let expiresDate = (json as? [String: Any])?["expires_at"] as? String, - let date = BraveSkusWebHelper.milisecondsOptionalDate(from: expiresDate) { - Preferences.VPN.expirationDate.value = date - } else { - assertionFailure("Failed to parse date") - } - - self?.handlePrepareCredentialsSummary(for: domain, path: "*", replyHandler: nil) - } catch { - Logger.module.error("refrshOrder: Failed to decode json: \(error.localizedDescription)") - } - } - } } diff --git a/Sources/BraveVPN/BraveVPN.swift b/Sources/BraveVPN/BraveVPN.swift index 9c691c731ef..0025718f3db 100644 --- a/Sources/BraveVPN/BraveVPN.swift +++ b/Sources/BraveVPN/BraveVPN.swift @@ -36,7 +36,7 @@ public class BraveVPN { /// Initialize the vpn service. It should be called even if the user hasn't bought the vpn yet. /// This function can have side effects if the receipt has expired(removes the vpn connection then). - public static func initialize(customCredential: (credential: String, environment: String)?) { + public static func initialize(customCredential: SkusVPNCredential?) { func clearConfiguration() { GRDVPNHelper.clearVpnConfiguration() clearCredentials() @@ -49,7 +49,13 @@ public class BraveVPN { } if let customCredential = customCredential { - setCustomVPNCredential(customCredential.credential, environment: customCredential.environment) + if hasExpired == true { + clearConfiguration() + logAndStoreError("Skus credential expired, resetting configuration") + return + } + + setCustomVPNCredential(customCredential) } helper.verifyMainCredentials { valid, error in @@ -93,13 +99,13 @@ public class BraveVPN { Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" } - public static func setCustomVPNCredential(_ credential: String, environment: String) { + public static func setCustomVPNCredential(_ credential: SkusVPNCredential) { GRDSubscriptionManager.setIsPayingUser(true) populateRegionDataIfNecessary() let dict: NSMutableDictionary = - ["brave-vpn-premium-monthly-pass": credential, - "brave-payments-env": environment, + ["brave-vpn-premium-monthly-pass": credential.guardianCredential, + "brave-payments-env": credential.environment, "validation-method": "brave-premium"] helper.customSubscriberCredentialAuthKeys = dict } @@ -300,6 +306,14 @@ public class BraveVPN { public static func clearCredentials() { GRDKeychain.removeGuardianKeychainItems() GRDKeychain.removeSubscriberCredential(withRetries: 3) + + clearSkusCredentials() + } + + public static func clearSkusCredentials() { + Preferences.VPN.skusCredential.reset() + Preferences.VPN.skusCredentialDomain.reset() + Preferences.VPN.expirationDate.reset() } // MARK: - Region selection diff --git a/Sources/BraveVPN/IAPObserver.swift b/Sources/BraveVPN/IAPObserver.swift index 02fd1149595..70f11c98bb1 100644 --- a/Sources/BraveVPN/IAPObserver.swift +++ b/Sources/BraveVPN/IAPObserver.swift @@ -60,8 +60,7 @@ public class IAPObserver: NSObject, SKPaymentTransactionObserver { // // The user will be able to retrieve the shared credential // after log in to account.brave website. - Preferences.VPN.skusCredential.reset() - Preferences.VPN.skusCredentialDomain.reset() + BraveVPN.clearSkusCredentials() } else { // Receipt either expired or receipt validation returned some error. self.delegate?.purchaseFailed(error: .receiptError) diff --git a/Sources/BraveVPN/SkusVPNCredential.swift b/Sources/BraveVPN/SkusVPNCredential.swift new file mode 100644 index 00000000000..43126cb495b --- /dev/null +++ b/Sources/BraveVPN/SkusVPNCredential.swift @@ -0,0 +1,23 @@ +// Copyright 2022 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation + +/// This structure stores all important data needed to connect to the VPN service using the cross platform credential. +public struct SkusVPNCredential { + /// The actual credential to pass to the GuardianConnect framework. + public let guardianCredential: String + /// Which environment was the `guardianCredential` registered to. + public let environment: String + /// How long is the SKU credential valid for. + /// Important: This date is different from the Guardian's credential expiration date. + public let expirationDate: Date + + public init(guardianCredential: String, environment: String, expirationDate: Date) { + self.guardianCredential = guardianCredential + self.environment = environment + self.expirationDate = expirationDate + } +} diff --git a/Tests/ClientTests/BraveSkusWebHelperTests.swift b/Tests/ClientTests/BraveSkusWebHelperTests.swift index 6cc31ed235c..91ea697dce1 100644 --- a/Tests/ClientTests/BraveSkusWebHelperTests.swift +++ b/Tests/ClientTests/BraveSkusWebHelperTests.swift @@ -69,10 +69,12 @@ final class BraveSkusWebHelperTests: XCTestCase { } func testFetchVPNCredential() throws { + let sampleCookieExpirationDate = "06 Aug 2022 00:00:00 GMT" + // Sample token we receive from the server let sampleCookie = """ - __Secure-sku#brave-firewall-vpn-premium=eyJ0eXBlIjoidGltZS1saW1pdGVkIiwidmVyc2lvbiI6MSwic2t1IjoiYnJhdmUtZmlyZXdhbGwtdnBuLXByZW1pdW0iLCJwcmVzZW50YXRpb24iOiJleUpsZUhCcGNtVnpRWFFpT2lJeU1ESXlMVEE0TFRBMklpd2lhWE56ZFdWa1FYUWlPaUl5TURJeUxUQTRMVEExSWl3aWRHOXJaVzRpT2lKV1ZUY3hNV1V5VTJoVkwzaEJNRFYzTnk5eVQyNTZVa3hvWkdsc1lqUkdSV2xvYUZkM1YzWmhkRGhIT0ZCSlIxbFpXVE42WkRZNFoxVjJiVUZrTHpKV0luMD0ifQ==;path=*;samesite=strict;expires=Sat, 06 Aug 2022 00:00:00 GMT;secure + __Secure-sku#brave-firewall-vpn-premium=eyJ0eXBlIjoidGltZS1saW1pdGVkIiwidmVyc2lvbiI6MSwic2t1IjoiYnJhdmUtZmlyZXdhbGwtdnBuLXByZW1pdW0iLCJwcmVzZW50YXRpb24iOiJleUpsZUhCcGNtVnpRWFFpT2lJeU1ESXlMVEE0TFRBMklpd2lhWE56ZFdWa1FYUWlPaUl5TURJeUxUQTRMVEExSWl3aWRHOXJaVzRpT2lKV1ZUY3hNV1V5VTJoVkwzaEJNRFYzTnk5eVQyNTZVa3hvWkdsc1lqUkdSV2xvYUZkM1YzWmhkRGhIT0ZCSlIxbFpXVE42WkRZNFoxVjJiVUZrTHpKV0luMD0ifQ==;path=*;samesite=strict;expires=Sat, \(sampleCookieExpirationDate);secure """ let manuallyExtractedCredential = @@ -81,15 +83,15 @@ final class BraveSkusWebHelperTests: XCTestCase { """ let developmentCred = BraveSkusWebHelper.fetchVPNCredential(sampleCookie, domain: "account.brave.software") - XCTAssertEqual(try XCTUnwrap(developmentCred?.credential), manuallyExtractedCredential) + XCTAssertEqual(try XCTUnwrap(developmentCred?.guardianCredential), manuallyExtractedCredential) XCTAssertEqual("development", try XCTUnwrap(developmentCred?.environment)) let stagingCred = BraveSkusWebHelper.fetchVPNCredential(sampleCookie, domain: "account.bravesoftware.com") - XCTAssertEqual(try XCTUnwrap(stagingCred?.credential), manuallyExtractedCredential) + XCTAssertEqual(try XCTUnwrap(stagingCred?.guardianCredential), manuallyExtractedCredential) XCTAssertEqual("staging", try XCTUnwrap(stagingCred?.environment)) let prodCred = BraveSkusWebHelper.fetchVPNCredential(sampleCookie, domain: "account.brave.com") - XCTAssertEqual(try XCTUnwrap(prodCred?.credential), manuallyExtractedCredential) + XCTAssertEqual(try XCTUnwrap(prodCred?.guardianCredential), manuallyExtractedCredential) XCTAssertEqual("production", try XCTUnwrap(prodCred?.environment)) let wrongDomainCred = BraveSkusWebHelper.fetchVPNCredential(sampleCookie, domain: "example.com") @@ -97,5 +99,13 @@ final class BraveSkusWebHelperTests: XCTestCase { let wrongCookie = BraveSkusWebHelper.fetchVPNCredential("wrong cookie", domain: "account.brave.com") XCTAssertNil(wrongCookie) + + let expirationDate = try XCTUnwrap(prodCred?.expirationDate) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd MMM yyyy HH:mm:ss zzz" + + let dateFromTestCookie = try XCTUnwrap(dateFormatter.date(from: sampleCookieExpirationDate)) + + XCTAssertEqual(expirationDate, dateFromTestCookie) } }