From e5946eee6af859690cc1cc5e51daef3c8368981b Mon Sep 17 00:00:00 2001 From: Michal Smaga Date: Mon, 28 Oct 2024 15:51:10 +0100 Subject: [PATCH] Use cookie to share subscription access token on DDG domains (#1034) Please review the release process for BrowserServicesKit [here](https://app.asana.com/0/1200194497630846/1200837094583426). **Required**: Task/Issue URL: https://app.asana.com/0/1108686900785972/1208264562025859/f iOS PR: https://github.com/duckduckgo/iOS/pull/3488 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3458 What kind of version bump will this require?: Minor **Description**: Store and keep in sync the subscription access token for the duckduckgo.com domain cookie. For the implementation guidelines please see description of the linked task. **Steps to test this PR**: See platform PRs **OS Testing**: * [ ] iOS 14 * [ ] iOS 15 * [ ] iOS 16 * [ ] macOS 10.15 * [ ] macOS 11 * [ ] macOS 12 --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- .../SubscriptionCookie/HTTPCookieStore.swift | 28 +++ .../SubscriptionCookieManager.swift | 182 ++++++++++++++ .../SubscriptionCookieManagerEvent.swift | 29 +++ .../SubscriptionCookieManagerMock.swift | 65 +++++ .../Managers/AccountManagerTests.swift | 1 + .../SubscriptionCookieManagerTests.swift | 236 ++++++++++++++++++ 6 files changed, 541 insertions(+) create mode 100644 Sources/Subscription/SubscriptionCookie/HTTPCookieStore.swift create mode 100644 Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift create mode 100644 Sources/Subscription/SubscriptionCookie/SubscriptionCookieManagerEvent.swift create mode 100644 Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift create mode 100644 Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift diff --git a/Sources/Subscription/SubscriptionCookie/HTTPCookieStore.swift b/Sources/Subscription/SubscriptionCookie/HTTPCookieStore.swift new file mode 100644 index 000000000..74fec5568 --- /dev/null +++ b/Sources/Subscription/SubscriptionCookie/HTTPCookieStore.swift @@ -0,0 +1,28 @@ +// +// HTTPCookieStore.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import WebKit + +public protocol HTTPCookieStore { + func allCookies() async -> [HTTPCookie] + func setCookie(_ cookie: HTTPCookie) async + func deleteCookie(_ cookie: HTTPCookie) async +} + +extension WKHTTPCookieStore: HTTPCookieStore {} diff --git a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift new file mode 100644 index 000000000..7d7099d75 --- /dev/null +++ b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift @@ -0,0 +1,182 @@ +// +// SubscriptionCookieManager.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Common +import os.log + +public protocol SubscriptionCookieManaging { + init(subscriptionManager: SubscriptionManager, currentCookieStore: @MainActor @escaping () -> HTTPCookieStore?, eventMapping: EventMapping) + func refreshSubscriptionCookie() async + func resetLastRefreshDate() + + var lastRefreshDate: Date? { get } +} + +public final class SubscriptionCookieManager: SubscriptionCookieManaging { + + public static let cookieDomain = ".duckduckgo.com" + public static let cookieName = "privacy_pro_access_token" + + private static let defaultRefreshTimeInterval: TimeInterval = .hours(4) + + private let subscriptionManager: SubscriptionManager + private let currentCookieStore: @MainActor () -> HTTPCookieStore? + private let eventMapping: EventMapping + + public private(set) var lastRefreshDate: Date? + private let refreshTimeInterval: TimeInterval + + convenience nonisolated public required init(subscriptionManager: SubscriptionManager, + currentCookieStore: @MainActor @escaping () -> HTTPCookieStore?, + eventMapping: EventMapping) { + self.init(subscriptionManager: subscriptionManager, + currentCookieStore: currentCookieStore, + eventMapping: eventMapping, + refreshTimeInterval: SubscriptionCookieManager.defaultRefreshTimeInterval) + } + + nonisolated public required init(subscriptionManager: SubscriptionManager, + currentCookieStore: @MainActor @escaping () -> HTTPCookieStore?, + eventMapping: EventMapping, + refreshTimeInterval: TimeInterval) { + self.subscriptionManager = subscriptionManager + self.currentCookieStore = currentCookieStore + self.eventMapping = eventMapping + self.refreshTimeInterval = refreshTimeInterval + + registerForSubscriptionAccountManagerEvents() + } + + private func registerForSubscriptionAccountManagerEvents() { + NotificationCenter.default.addObserver(self, selector: #selector(handleAccountDidSignIn), name: .accountDidSignIn, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleAccountDidSignOut), name: .accountDidSignOut, object: nil) + } + + @objc private func handleAccountDidSignIn() { + Task { + guard let cookieStore = await currentCookieStore() else { return } + guard let accessToken = subscriptionManager.accountManager.accessToken else { + Logger.subscription.error("[SubscriptionCookieManager] Handle .accountDidSignIn - can't set the cookie, token is missing") + eventMapping.fire(.errorHandlingAccountDidSignInTokenIsMissing) + return + } + Logger.subscription.info("[SubscriptionCookieManager] Handle .accountDidSignIn - setting cookie") + + do { + try await cookieStore.setSubscriptionCookie(for: accessToken) + updateLastRefreshDateToNow() + } catch { + eventMapping.fire(.failedToSetSubscriptionCookie) + } + } + } + + @objc private func handleAccountDidSignOut() { + Task { + guard let cookieStore = await currentCookieStore() else { return } + guard let subscriptionCookie = await cookieStore.fetchCurrentSubscriptionCookie() else { + Logger.subscription.error("[SubscriptionCookieManager] Handle .accountDidSignOut - can't delete the cookie, cookie is missing") + eventMapping.fire(.errorHandlingAccountDidSignOutCookieIsMissing) + return + } + Logger.subscription.info("[SubscriptionCookieManager] Handle .accountDidSignOut - deleting cookie") + await cookieStore.deleteCookie(subscriptionCookie) + updateLastRefreshDateToNow() + } + } + + public func refreshSubscriptionCookie() async { + guard shouldRefreshSubscriptionCookie() else { return } + guard let cookieStore = await currentCookieStore() else { return } + + Logger.subscription.info("[SubscriptionCookieManager] Refresh subscription cookie") + updateLastRefreshDateToNow() + + let accessToken: String? = subscriptionManager.accountManager.accessToken + let subscriptionCookie = await cookieStore.fetchCurrentSubscriptionCookie() + + if let accessToken { + if subscriptionCookie == nil || subscriptionCookie?.value != accessToken { + Logger.subscription.info("[SubscriptionCookieManager] Refresh: No cookie or one with different value") + do { + try await cookieStore.setSubscriptionCookie(for: accessToken) + eventMapping.fire(.subscriptionCookieRefreshedWithUpdate) + } catch { + eventMapping.fire(.failedToSetSubscriptionCookie) + } + } else { + Logger.subscription.info("[SubscriptionCookieManager] Refresh: Cookie exists and is up to date") + return + } + } else { + if let subscriptionCookie { + Logger.subscription.info("[SubscriptionCookieManager] Refresh: No access token but old cookie exists, deleting it") + await cookieStore.deleteCookie(subscriptionCookie) + eventMapping.fire(.subscriptionCookieRefreshedWithDelete) + } + } + } + + private func shouldRefreshSubscriptionCookie() -> Bool { + switch lastRefreshDate { + case .none: + return true + case .some(let previousLastRefreshDate): + return previousLastRefreshDate.timeIntervalSinceNow < -refreshTimeInterval + } + } + + private func updateLastRefreshDateToNow() { + lastRefreshDate = Date() + } + + public func resetLastRefreshDate() { + lastRefreshDate = nil + } +} + +enum SubscriptionCookieManagerError: Error { + case failedToCreateSubscriptionCookie +} + +private extension HTTPCookieStore { + + func fetchCurrentSubscriptionCookie() async -> HTTPCookie? { + await allCookies().first { $0.domain == SubscriptionCookieManager.cookieDomain && $0.name == SubscriptionCookieManager.cookieName } + } + + func setSubscriptionCookie(for token: String) async throws { + guard let cookie = HTTPCookie(properties: [ + .domain: SubscriptionCookieManager.cookieDomain, + .path: "/", + .expires: Date().addingTimeInterval(.days(365)), + .name: SubscriptionCookieManager.cookieName, + .value: token, + .secure: true, + .init(rawValue: "HttpOnly"): true + ]) else { + Logger.subscription.error("[HTTPCookieStore] Subscription cookie could not be created") + assertionFailure("Subscription cookie could not be created") + throw SubscriptionCookieManagerError.failedToCreateSubscriptionCookie + } + + Logger.subscription.info("[HTTPCookieStore] Setting subscription cookie") + await setCookie(cookie) + } +} diff --git a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManagerEvent.swift b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManagerEvent.swift new file mode 100644 index 000000000..86f604d3d --- /dev/null +++ b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManagerEvent.swift @@ -0,0 +1,29 @@ +// +// SubscriptionCookieManagerEvent.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public enum SubscriptionCookieManagerEvent { + case errorHandlingAccountDidSignInTokenIsMissing + case errorHandlingAccountDidSignOutCookieIsMissing + + case subscriptionCookieRefreshedWithUpdate + case subscriptionCookieRefreshedWithDelete + + case failedToSetSubscriptionCookie +} diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift new file mode 100644 index 000000000..56e970ebb --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift @@ -0,0 +1,65 @@ +// +// SubscriptionCookieManagerMock.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Common +import Subscription + +public final class SubscriptionCookieManagerMock: SubscriptionCookieManaging { + + public var lastRefreshDate: Date? + + public convenience init() { + let accountManager = AccountManagerMock() + let subscriptionService = DefaultSubscriptionEndpointService(currentServiceEnvironment: .production) + let authService = DefaultAuthEndpointService(currentServiceEnvironment: .production) + let storePurchaseManager = StorePurchaseManagerMock() + let subscriptionManager = SubscriptionManagerMock(accountManager: accountManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService, + storePurchaseManager: storePurchaseManager, + currentEnvironment: SubscriptionEnvironment(serviceEnvironment: .production, + purchasePlatform: .appStore), + canPurchase: true) + + self.init(subscriptionManager: subscriptionManager, + currentCookieStore: { return nil }, + eventMapping: MockSubscriptionCookieManagerEventPixelMapping()) + } + + public init(subscriptionManager: SubscriptionManager, + currentCookieStore: @MainActor @escaping () -> HTTPCookieStore?, + eventMapping: EventMapping) { + + } + + public func refreshSubscriptionCookie() async { } + public func resetLastRefreshDate() { } +} + +public final class MockSubscriptionCookieManagerEventPixelMapping: EventMapping { + + public init() { + super.init { _, _, _, _ in + } + } + + override init(mapping: @escaping EventMapping.Mapping) { + fatalError("Use init()") + } +} diff --git a/Tests/SubscriptionTests/Managers/AccountManagerTests.swift b/Tests/SubscriptionTests/Managers/AccountManagerTests.swift index ce823e6a9..0a04a4cde 100644 --- a/Tests/SubscriptionTests/Managers/AccountManagerTests.swift +++ b/Tests/SubscriptionTests/Managers/AccountManagerTests.swift @@ -112,6 +112,7 @@ final class AccountManagerTests: XCTestCase { func testStoreAccount() async throws { // Given + let notificationExpectation = expectation(forNotification: .accountDidSignIn, object: accountManager, handler: nil) // When diff --git a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift new file mode 100644 index 000000000..04dfe7b8b --- /dev/null +++ b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift @@ -0,0 +1,236 @@ +// +// SubscriptionCookieManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Common +@testable import Subscription +import SubscriptionTestingUtilities + +final class SubscriptionCookieManagerTests: XCTestCase { + + private struct Constants { + static let authToken = UUID().uuidString + static let accessToken = UUID().uuidString + } + + var accountManager: AccountManagerMock! + var subscriptionService: SubscriptionEndpointServiceMock! + var authService: AuthEndpointServiceMock! + var storePurchaseManager: StorePurchaseManagerMock! + var subscriptionEnvironment: SubscriptionEnvironment! + var subscriptionManager: SubscriptionManagerMock! + + var cookieStore: HTTPCookieStore! + var subscriptionCookieManager: SubscriptionCookieManager! + + override func setUp() async throws { + accountManager = AccountManagerMock() + subscriptionService = SubscriptionEndpointServiceMock() + authService = AuthEndpointServiceMock() + storePurchaseManager = StorePurchaseManagerMock() + subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, + purchasePlatform: .appStore) + + subscriptionManager = SubscriptionManagerMock(accountManager: accountManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService, + storePurchaseManager: storePurchaseManager, + currentEnvironment: subscriptionEnvironment, + canPurchase: true) + cookieStore = MockHTTPCookieStore() + + subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: subscriptionManager, + currentCookieStore: { self.cookieStore }, + eventMapping: MockSubscriptionCookieManageEventPixelMapping(), + refreshTimeInterval: .seconds(1)) + } + + override func tearDown() async throws { + accountManager = nil + subscriptionService = nil + authService = nil + storePurchaseManager = nil + subscriptionEnvironment = nil + + subscriptionManager = nil + } + + func testSubscriptionCookieIsAddedWhenSigningInToSubscription() async throws { + // Given + await ensureNoSubscriptionCookieInTheCookieStore() + accountManager.accessToken = Constants.accessToken + + // When + NotificationCenter.default.post(name: .accountDidSignIn, object: self, userInfo: nil) + try await Task.sleep(seconds: 0.1) + + // Then + await checkSubscriptionCookieIsPresent() + } + + func testSubscriptionCookieIsDeletedWhenSigningInToSubscription() async throws { + // Given + await ensureSubscriptionCookieIsInTheCookieStore() + + // When + NotificationCenter.default.post(name: .accountDidSignOut, object: self, userInfo: nil) + try await Task.sleep(seconds: 0.1) + + // Then + await checkSubscriptionCookieIsNotPresent() + } + + func testRefreshWhenSignedInButCookieIsMissing() async throws { + // Given + accountManager.accessToken = Constants.accessToken + await ensureNoSubscriptionCookieInTheCookieStore() + + // When + await subscriptionCookieManager.refreshSubscriptionCookie() + try await Task.sleep(seconds: 0.1) + + // Then + await checkSubscriptionCookieIsPresent() + } + + func testRefreshWhenSignedOutButCookieIsPresent() async throws { + // Given + accountManager.accessToken = nil + await ensureSubscriptionCookieIsInTheCookieStore() + + // When + await subscriptionCookieManager.refreshSubscriptionCookie() + try await Task.sleep(seconds: 0.1) + + // Then + await checkSubscriptionCookieIsNotPresent() + } + + func testRefreshNotTriggeredTwiceWithinSetRefreshInterval() async throws { + // Given + let firstRefreshDate: Date? + let secondRefreshDate: Date? + + // When + await subscriptionCookieManager.refreshSubscriptionCookie() + firstRefreshDate = subscriptionCookieManager.lastRefreshDate + + try await Task.sleep(seconds: 0.5) + + await subscriptionCookieManager.refreshSubscriptionCookie() + secondRefreshDate = subscriptionCookieManager.lastRefreshDate + + // Then + XCTAssertEqual(firstRefreshDate!, secondRefreshDate!) + } + + func testRefreshNotTriggeredSecondTimeAfterSetRefreshInterval() async throws { + // Given + let firstRefreshDate: Date? + let secondRefreshDate: Date? + + // When + await subscriptionCookieManager.refreshSubscriptionCookie() + firstRefreshDate = subscriptionCookieManager.lastRefreshDate + + try await Task.sleep(seconds: 1.1) + + await subscriptionCookieManager.refreshSubscriptionCookie() + secondRefreshDate = subscriptionCookieManager.lastRefreshDate + + // Then + XCTAssertTrue(firstRefreshDate! < secondRefreshDate!) + } + + private func ensureSubscriptionCookieIsInTheCookieStore() async { + let subscriptionCookie = HTTPCookie(properties: [ + .domain: SubscriptionCookieManager.cookieDomain, + .path: "/", + .expires: Date().addingTimeInterval(.days(365)), + .name: SubscriptionCookieManager.cookieName, + .value: Constants.accessToken, + .secure: true, + .init(rawValue: "HttpOnly"): true + ])! + await cookieStore.setCookie(subscriptionCookie) + + let cookieStoreCookies = await cookieStore.allCookies() + XCTAssertEqual(cookieStoreCookies.count, 1) + } + + private func ensureNoSubscriptionCookieInTheCookieStore() async { + let cookieStoreCookies = await cookieStore.allCookies() + XCTAssertTrue(cookieStoreCookies.isEmpty) + } + + private func checkSubscriptionCookieIsPresent() async { + guard let subscriptionCookie = await cookieStore.fetchSubscriptionCookie() else { + XCTFail("No subscription cookie in the store") + return + } + XCTAssertEqual(subscriptionCookie.value, Constants.accessToken) + } + + private func checkSubscriptionCookieIsNotPresent() async { + let cookie = await cookieStore.fetchSubscriptionCookie() + XCTAssertNil(cookie) + } + +} + +private extension HTTPCookieStore { + + func fetchSubscriptionCookie() async -> HTTPCookie? { + await allCookies().first { $0.domain == SubscriptionCookieManager.cookieDomain && $0.name == SubscriptionCookieManager.cookieName } + } +} + +class MockHTTPCookieStore: HTTPCookieStore { + + var cookies: [HTTPCookie] + + init(cookies: [HTTPCookie] = []) { + self.cookies = cookies + } + + func allCookies() async -> [HTTPCookie] { + return cookies + } + + func setCookie(_ cookie: HTTPCookie) async { + cookies.append(cookie) + } + + func deleteCookie(_ cookie: HTTPCookie) async { + cookies.removeAll { $0.domain == cookie.domain } + } + +} + +class MockSubscriptionCookieManageEventPixelMapping: EventMapping { + + public init() { + super.init { event, _, _, _ in + + } + } + + override init(mapping: @escaping EventMapping.Mapping) { + fatalError("Use init()") + } +}