diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1a0732..a48e5fd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed + +- Keep the refreshed access token in memory when storing it to the keychain fails (such as `errSecNotAvailable` when the keychain service is temporarily unavailable). The current session continues with the new token and the consumed refresh token is not sent again. The SDK retries the keychain writing when the app becomes active or protected data becomes available. + +### Changed + +- Behavior change: `API.Auth.refreshAccessToken` and the login flow no longer report a failure when the token itself is obtained but cannot be stored to the keychain. Observe the new `.LineSDKAccessTokenDidFailToPersist` notification if you need to detect or report such storing failures. The underlying error is available in the `userInfo` dictionary under the `LineSDKNotificationKey.persistingError` key. + ## [5.17.0] - 2026-07-23 ### Changed diff --git a/LineSDK/LineSDK.xcodeproj/project.pbxproj b/LineSDK/LineSDK.xcodeproj/project.pbxproj index 92537e7c..25e911a6 100644 --- a/LineSDK/LineSDK.xcodeproj/project.pbxproj +++ b/LineSDK/LineSDK.xcodeproj/project.pbxproj @@ -113,6 +113,7 @@ 4B6CACC9239F4A2B00BD6C35 /* AssertionHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B6CACC8239F4A2B00BD6C35 /* AssertionHelpers.swift */; }; 4B792FB121102D9200EDDD1E /* LoginProcessURLResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B792FB021102D9200EDDD1E /* LoginProcessURLResponseTests.swift */; }; 4B792FB321103A0200EDDD1E /* LoginManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B792FB221103A0200EDDD1E /* LoginManagerTests.swift */; }; + A7C0FFEE2607250001ACCE55 /* AccessTokenStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C0FFED2607250001ACCE55 /* AccessTokenStoreTests.swift */; }; 4B792FB821103D4B00EDDD1E /* ResponseDataStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B792FB721103D4B00EDDD1E /* ResponseDataStub.swift */; }; 4B7D8C252137B9FA00C6855A /* public_base_64_newline in Resources */ = {isa = PBXBuildFile; fileRef = 4B7D8C1D2137B9FA00C6855A /* public_base_64_newline */; }; 4B7D8C262137B9FA00C6855A /* public_base_64_header in Resources */ = {isa = PBXBuildFile; fileRef = 4B7D8C1E2137B9FA00C6855A /* public_base_64_header */; }; @@ -681,6 +682,7 @@ 4B6CACC8239F4A2B00BD6C35 /* AssertionHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertionHelpers.swift; sourceTree = ""; }; 4B792FB021102D9200EDDD1E /* LoginProcessURLResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginProcessURLResponseTests.swift; sourceTree = ""; }; 4B792FB221103A0200EDDD1E /* LoginManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginManagerTests.swift; sourceTree = ""; }; + A7C0FFED2607250001ACCE55 /* AccessTokenStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessTokenStoreTests.swift; sourceTree = ""; }; 4B792FB721103D4B00EDDD1E /* ResponseDataStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseDataStub.swift; sourceTree = ""; }; 4B7D8C1D2137B9FA00C6855A /* public_base_64_newline */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = public_base_64_newline; sourceTree = ""; }; 4B7D8C1E2137B9FA00C6855A /* public_base_64_header */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = public_base_64_header; sourceTree = ""; }; @@ -1197,6 +1199,7 @@ 4B8A965621100A5800760219 /* LoginConfigurationTests.swift */, 4B792FB021102D9200EDDD1E /* LoginProcessURLResponseTests.swift */, 4B792FB221103A0200EDDD1E /* LoginManagerTests.swift */, + A7C0FFED2607250001ACCE55 /* AccessTokenStoreTests.swift */, DB09851423D5AF9D0001A3B8 /* PKCETests.swift */, ); path = Login; @@ -2422,6 +2425,7 @@ 4B39D1E421104DBA00A45510 /* APITests.swift in Sources */, 4B9A305A2121639600174C6F /* TemplateCarouselPayloadTests.swift in Sources */, 4B792FB321103A0200EDDD1E /* LoginManagerTests.swift in Sources */, + A7C0FFEE2607250001ACCE55 /* AccessTokenStoreTests.swift in Sources */, 4BFC09EF213CCE7700F4594D /* JWKTests.swift in Sources */, 4B3CCB9E2152449400F51D76 /* ECDSAKeyTests.swift in Sources */, 2B8D25362E39A2B50029FB34 /* LoginProcessMocks.swift in Sources */, diff --git a/LineSDK/LineSDK/Login/LoginManager.swift b/LineSDK/LineSDK/Login/LoginManager.swift index 3d0d9f3c..4319c263 100644 --- a/LineSDK/LineSDK/Login/LoginManager.swift +++ b/LineSDK/LineSDK/Login/LoginManager.swift @@ -282,7 +282,7 @@ public final class LoginManager: @unchecked Sendable /* Sendable is ensured by t } // Everything goes fine now. Store token. - try AccessTokenStore.shared.setCurrentToken(token) + AccessTokenStore.shared.setCurrentToken(token) return LoginResult.init( accessToken: token, permissions: Set(token.permissions), diff --git a/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift b/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift index 8b8baba3..ea8b536c 100644 --- a/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift +++ b/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift @@ -19,11 +19,13 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +import UIKit extension Notification.Name { - /// Sent when the LINE SDK detects that the current token has been updated and stored in the keychain. - /// This means that the user has authorized your app and your app has obtained an access token. The + /// Sent when the LINE SDK detects that the current token has been updated. This means that the user has + /// authorized your app and your app has obtained an access token. Normally, the new token is also stored + /// in the keychain. If the keychain is temporarily unavailable, the token is kept in memory and the SDK + /// stores it to the keychain later when the app becomes active or protected data becomes available. The /// `object` property of the posted `Notification` object contains the new access token. The `userInfo` /// dictionary of the posted `Notification` object contains the new access token under the /// `LineSDKNotificationKey.newAccessToken` key. If an access token has previously existed, it will be under @@ -35,15 +37,29 @@ extension Notification.Name { /// automatically removed since the access token is refreshed when it is used to make an API call. /// The `object` property of the posted `Notification` object contains the removed access token. public static let LineSDKAccessTokenDidRemove = Notification.Name("com.linecorp.linesdk.AccessTokenDidRemove") + + /// Sent when the LINE SDK fails to store the current access token to the keychain. This can happen when + /// the keychain is temporarily unavailable (for example, `errSecNotAvailable`). The token is kept in + /// memory and keeps working for the current session, and the SDK retries the storing when the app + /// becomes active or protected data becomes available; this notification is sent again if a retry fails. + /// The `object` property of the posted `Notification` object contains the access token which failed to + /// be stored. The `userInfo` dictionary contains the underlying error under the + /// `LineSDKNotificationKey.persistingError` key. Observe this notification if you want to log or report + /// such failures. + public static let LineSDKAccessTokenDidFailToPersist = + Notification.Name("com.linecorp.linesdk.AccessTokenDidFailToPersist") } extension LineSDKNotificationKey { /// A user information key for an old access token value. public static let oldAccessToken = "oldAccessToken" - + /// A user information key for a new access token value. public static let newAccessToken = "newAccessToken" + + /// A user information key for the error which caused an access token persisting failure. + public static let persistingError = "persistingError" } /// Represents the storage of an `AccessToken` object. @@ -123,7 +139,7 @@ final public class AccessTokenStore: @unchecked Sendable { init(configuration: LoginConfiguration) { self.configuration = configuration - + let keychainStore = KeychainStore(service: storeVersion.keychainService) self.keychainStore = keychainStore do { @@ -132,6 +148,11 @@ final public class AccessTokenStore: @unchecked Sendable { Log.print("Error happened during loading token from token store: \(error)") Log.print("LineSDK recovered from it but your user might need another authorization to Line SDK.") } + setupTokenPersistenceRetryObservers() + } + + deinit { + lifecycleObservers.forEach(NotificationCenter.default.removeObserver) } private let lock = NSLock() @@ -151,31 +172,138 @@ final public class AccessTokenStore: @unchecked Sendable { } } - func setCurrentToken(_ token: AccessToken) throws { + // Whether the `current` token failed to be stored to the keychain and is waiting for another + // persistence attempt. Guarded by `lock`. + private var _tokenPersistencePending = false + var isTokenPersistencePending: Bool { + lock.lock() + defer { lock.unlock() } + return _tokenPersistencePending + } + + private var lifecycleObservers: [NSObjectProtocol] = [] + + // Executes a pending-persistence retry, off the main thread by default since keychain calls can + // block when securityd is unresponsive. Tests replace this to run retries synchronously. + var persistenceRetryExecutor: (@escaping @Sendable () -> Void) -> Void = { work in + DispatchQueue.global(qos: .utility).async(execute: work) + } + + func setCurrentToken(_ token: AccessToken) { guard current != token else { return } - - try keychainStore.set(token, configuration: configuration, version: storeVersion) - + + lock.lock() + var persistenceError: Error? + do { + try keychainStore.set(token, configuration: configuration, version: storeVersion) + _tokenPersistencePending = false + } catch { + // The keychain can be temporarily unavailable (typically `errSecNotAvailable` when securityd is + // unreachable). Keep the token in memory so the current session continues with the new token and + // the consumed refresh token is not sent again. The keychain writing will be retried later. + _tokenPersistencePending = true + persistenceError = error + } + var userInfo = [LineSDKNotificationKey.newAccessToken: token] - if let old = current { + if let old = _current { userInfo[LineSDKNotificationKey.oldAccessToken] = old } - current = token - + _current = token + lock.unlock() + NotificationCenter.default.post(name: .LineSDKAccessTokenDidUpdate, object: token, userInfo: userInfo) + if let error = persistenceError { + logTokenPersistenceFailure(error) + NotificationCenter.default.post( + name: .LineSDKAccessTokenDidFailToPersist, + object: token, + userInfo: [LineSDKNotificationKey.persistingError: error] + ) + } } - + + // Retries to store the in-memory token when a previous keychain writing failed. + func retryPendingTokenPersistence() { + lock.lock() + guard _tokenPersistencePending, let token = _current else { + lock.unlock() + return + } + var persistenceError: Error? + do { + try keychainStore.set(token, configuration: configuration, version: storeVersion) + _tokenPersistencePending = false + } catch { + // Keychain is still unavailable. Keep the pending state and wait for the next chance. + persistenceError = error + } + lock.unlock() + + if let error = persistenceError { + Log.print("Retrying token persistence failed again: \(error)") + NotificationCenter.default.post( + name: .LineSDKAccessTokenDidFailToPersist, + object: token, + userInfo: [LineSDKNotificationKey.persistingError: error] + ) + } else { + Log.print("The pending access token is now stored to keychain successfully.") + } + } + + private func setupTokenPersistenceRetryObservers() { + let names: [Notification.Name] = [ + UIApplication.didBecomeActiveNotification, + UIApplication.protectedDataDidBecomeAvailableNotification + ] + lifecycleObservers = names.map { name in + NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { [weak self] _ in + guard let self = self, self.isTokenPersistencePending else { return } + self.persistenceRetryExecutor { + self.retryPendingTokenPersistence() + } + } + } + } + + private func logTokenPersistenceFailure(_ error: Error) { + Task { @MainActor in + let application = UIApplication.shared + let state: String + switch application.applicationState { + case .active: state = "active" + case .inactive: state = "inactive" + case .background: state = "background" + @unknown default: state = "unknown" + } + Log.print( + "Writing token to keychain failed: \(error). The token is kept in memory and LineSDK will " + + "retry the writing when the app becomes active or protected data becomes available. " + + "(applicationState: \(state), " + + "isProtectedDataAvailable: \(application.isProtectedDataAvailable))" + ) + } + } + func removeCurrentAccessToken() throws { + lock.lock() + _tokenPersistencePending = false + lock.unlock() + let key = storeVersion.tokenKey(for: configuration) if try keychainStore.contains(key) { try keychainStore.remove(key) - - // TODO: We need to consider the location of setting `nil` carefully. - // In normal case if keychainStore works well, everything should be fine. - // But what will happen if revoke request succeeded, then keychain operation fails? - // Do we want to keep `current` token or should be put it outside the if statement - // and always reset it? - let token = current + } + + // TODO: We need to consider the location of setting `nil` carefully. + // In normal case if keychainStore works well, everything should be fine. + // But what will happen if revoke request succeeded, then keychain operation fails? + // Now `current` is kept in that case since the throws above skip the code below. + // + // The in-memory token needs to be cleared even when the keychain does not contain + // the key. A pending token which failed to be stored only exists in memory. + if let token = current { current = nil NotificationCenter.default.post(name: .LineSDKAccessTokenDidRemove, object: token, userInfo: nil) } diff --git a/LineSDK/LineSDK/Networking/API/API+Auth.swift b/LineSDK/LineSDK/Networking/API/API+Auth.swift index de9e3451..84576109 100644 --- a/LineSDK/LineSDK/Networking/API/API+Auth.swift +++ b/LineSDK/LineSDK/Networking/API/API+Auth.swift @@ -57,7 +57,7 @@ extension API { case .success(let newToken): do { let combinedToken = try AccessToken(token: newToken, currentIDTokenRaw: token.IDTokenRaw) - try AccessTokenStore.shared.setCurrentToken(combinedToken) + AccessTokenStore.shared.setCurrentToken(combinedToken) completion(.success(combinedToken)) } catch { completion(.failure(error.sdkError)) diff --git a/LineSDK/LineSDK/Utils/KeychainStore.swift b/LineSDK/LineSDK/Utils/KeychainStore.swift index d74580e5..13cb487c 100644 --- a/LineSDK/LineSDK/Utils/KeychainStore.swift +++ b/LineSDK/LineSDK/Utils/KeychainStore.swift @@ -34,6 +34,13 @@ private let kAttributeSynchronizable = String(kSecAttrSynchronizable) /// It does not provide full set of functionality of keychain access now. /// Only features used in LINE SDK are added. struct KeychainStore { + + // Seam for tests to simulate keychain write failures (such as `errSecNotAvailable`), + // which cannot be triggered on a normal simulator or device. Never mutated in production code. + nonisolated(unsafe) static var secItemUpdate: (CFDictionary, CFDictionary) -> OSStatus = SecItemUpdate + nonisolated(unsafe) static var secItemAdd: (CFDictionary, UnsafeMutablePointer?) -> OSStatus + = SecItemAdd + struct Options { enum ItemClass { case genericPassword @@ -126,13 +133,13 @@ extension KeychainStore { query[kAttributeAccount] = key let attributes = options.attributes(for: nil, value: data) - let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let status = Self.secItemUpdate(query as CFDictionary, attributes as CFDictionary) switch status { case errSecSuccess: break case errSecItemNotFound: let a = options.attributes(for: key, value: data) - let status = SecItemAdd(a as CFDictionary, nil) + let status = Self.secItemAdd(a as CFDictionary, nil) if status != errSecSuccess { throw keychainError(status) } diff --git a/LineSDK/LineSDKObjC/Login/Model/LineSDKAccessTokenStore.swift b/LineSDK/LineSDKObjC/Login/Model/LineSDKAccessTokenStore.swift index db3d2b73..35f0480f 100644 --- a/LineSDK/LineSDKObjC/Login/Model/LineSDKAccessTokenStore.swift +++ b/LineSDK/LineSDKObjC/Login/Model/LineSDKAccessTokenStore.swift @@ -22,11 +22,13 @@ @objc extension NSNotification { public static let LineSDKAccessTokenDidUpdate = Notification.Name.LineSDKAccessTokenDidUpdate public static let LineSDKAccessTokenDidRemove = Notification.Name.LineSDKAccessTokenDidRemove + public static let LineSDKAccessTokenDidFailToPersist = Notification.Name.LineSDKAccessTokenDidFailToPersist } @objc extension NSNotification { public static let LineSDKOldAccessTokenKey = LineSDKNotificationKey.oldAccessToken public static let LineSDKNewAccessTokenKey = LineSDKNotificationKey.newAccessToken + public static let LineSDKPersistingErrorKey = LineSDKNotificationKey.persistingError } @objcMembers diff --git a/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift new file mode 100644 index 00000000..e1776a07 --- /dev/null +++ b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift @@ -0,0 +1,256 @@ +// +// AccessTokenStoreTests.swift +// +// Copyright (c) 2016-present, LY Corporation. All rights reserved. +// +// You are hereby granted a non-exclusive, worldwide, royalty-free license to use, +// copy and distribute this software in source code or binary form for use +// in connection with the web services and APIs provided by LY Corporation. +// +// As with any software that integrates with the LY Corporation platform, your use of this software +// is subject to the LINE Developers Agreement [http://terms2.line.me/LINE_Developers_Agreement]. +// This copyright notice shall be included in all copies or substantial portions of the software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import XCTest +import Security +@testable import LineSDK + +@MainActor +class AccessTokenStoreTests: XCTestCase, Sendable { + + override func setUp() async throws { + LoginManager.shared.setup(channelID: "123", universalLinkURL: nil) + try? AccessTokenStore.shared.removeCurrentAccessToken() + } + + override func tearDown() async throws { + restoreKeychainFunctions() + try? AccessTokenStore.shared.removeCurrentAccessToken() + LoginManager.shared.resetForTesting() + } + + private func makeToken(value: String) -> AccessToken { + let data = """ + { + "scope":"profile openid", + "access_token":"\(value)", + "token_type":"Bearer", + "refresh_token":"refresh_\(value)", + "expires_in":259200 + } + """.data(using: .utf8)! + return try! JSONDecoder().decode(AccessToken.self, from: data) + } + + private func breakKeychainWriting() { + KeychainStore.secItemUpdate = { _, _ in errSecNotAvailable } + KeychainStore.secItemAdd = { _, _ in errSecNotAvailable } + } + + private func restoreKeychainFunctions() { + KeychainStore.secItemUpdate = SecItemUpdate + KeychainStore.secItemAdd = SecItemAdd + } + + private func tokenInKeychain() -> AccessToken? { + let store = AccessTokenStore.shared + do { + return try store.keychainStore.token(for: store.configuration, version: store.storeVersion) + } catch { + return nil + } + } + + // Makes lifecycle-triggered retries run synchronously so tests do not depend on background + // dispatch timing. + private func useSynchronousRetryExecutor() { + AccessTokenStore.shared.persistenceRetryExecutor = { $0() } + } + + func testSetCurrentTokenStoresToKeychain() { + let token = makeToken(value: "token1") + AccessTokenStore.shared.setCurrentToken(token) + + XCTAssertEqual(AccessTokenStore.shared.current, token) + XCTAssertEqual(tokenInKeychain(), token) + XCTAssertFalse(AccessTokenStore.shared.isTokenPersistencePending) + } + + func testKeychainFailureKeepsTokenInMemory() { + let token = makeToken(value: "token1") + + expectation(forNotification: .LineSDKAccessTokenDidUpdate, object: nil) { notification in + let newToken = notification.userInfo?[LineSDKNotificationKey.newAccessToken] as? AccessToken + return newToken == token + } + expectation(forNotification: .LineSDKAccessTokenDidFailToPersist, object: nil) { notification in + return notification.userInfo?[LineSDKNotificationKey.persistingError] is LineSDKError + } + + breakKeychainWriting() + AccessTokenStore.shared.setCurrentToken(token) + + XCTAssertEqual(AccessTokenStore.shared.current, token) + XCTAssertNil(tokenInKeychain()) + XCTAssertTrue(AccessTokenStore.shared.isTokenPersistencePending) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testPendingTokenIsStoredOnRetry() { + let token = makeToken(value: "token1") + + breakKeychainWriting() + AccessTokenStore.shared.setCurrentToken(token) + XCTAssertTrue(AccessTokenStore.shared.isTokenPersistencePending) + + // Keychain is still broken. Retry keeps the pending state. + AccessTokenStore.shared.retryPendingTokenPersistence() + XCTAssertTrue(AccessTokenStore.shared.isTokenPersistencePending) + XCTAssertNil(tokenInKeychain()) + + restoreKeychainFunctions() + AccessTokenStore.shared.retryPendingTokenPersistence() + XCTAssertFalse(AccessTokenStore.shared.isTokenPersistencePending) + XCTAssertEqual(tokenInKeychain(), token) + } + + func testRetryFailurePostsDidFailToPersistNotification() { + let token = makeToken(value: "token1") + + breakKeychainWriting() + AccessTokenStore.shared.setCurrentToken(token) + + expectation(forNotification: .LineSDKAccessTokenDidFailToPersist, object: nil) { notification in + return notification.userInfo?[LineSDKNotificationKey.persistingError] is LineSDKError + } + AccessTokenStore.shared.retryPendingTokenPersistence() + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testPendingTokenIsStoredWhenAppBecomesActive() { + useSynchronousRetryExecutor() + let token = makeToken(value: "token1") + + breakKeychainWriting() + AccessTokenStore.shared.setCurrentToken(token) + XCTAssertTrue(AccessTokenStore.shared.isTokenPersistencePending) + + restoreKeychainFunctions() + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + + XCTAssertFalse(AccessTokenStore.shared.isTokenPersistencePending) + XCTAssertEqual(tokenInKeychain(), token) + } + + func testPendingTokenIsStoredWhenProtectedDataBecomesAvailable() { + useSynchronousRetryExecutor() + let token = makeToken(value: "token1") + + breakKeychainWriting() + AccessTokenStore.shared.setCurrentToken(token) + XCTAssertTrue(AccessTokenStore.shared.isTokenPersistencePending) + + restoreKeychainFunctions() + NotificationCenter.default.post( + name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil + ) + + XCTAssertFalse(AccessTokenStore.shared.isTokenPersistencePending) + XCTAssertEqual(tokenInKeychain(), token) + } + + func testNewTokenOverridesPendingState() { + let token1 = makeToken(value: "token1") + let token2 = makeToken(value: "token2") + + breakKeychainWriting() + AccessTokenStore.shared.setCurrentToken(token1) + XCTAssertTrue(AccessTokenStore.shared.isTokenPersistencePending) + + restoreKeychainFunctions() + AccessTokenStore.shared.setCurrentToken(token2) + XCTAssertFalse(AccessTokenStore.shared.isTokenPersistencePending) + XCTAssertEqual(AccessTokenStore.shared.current, token2) + XCTAssertEqual(tokenInKeychain(), token2) + } + + func testRemoveTokenClearsPendingState() { + useSynchronousRetryExecutor() + let token = makeToken(value: "token1") + + breakKeychainWriting() + AccessTokenStore.shared.setCurrentToken(token) + XCTAssertTrue(AccessTokenStore.shared.isTokenPersistencePending) + + restoreKeychainFunctions() + try! AccessTokenStore.shared.removeCurrentAccessToken() + + XCTAssertNil(AccessTokenStore.shared.current) + XCTAssertFalse(AccessTokenStore.shared.isTokenPersistencePending) + + // A lifecycle event must not resurrect the removed token. + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + XCTAssertNil(tokenInKeychain()) + } + + func testRemovePendingTokenPostsDidRemoveNotification() { + let token = makeToken(value: "token1") + + breakKeychainWriting() + AccessTokenStore.shared.setCurrentToken(token) + XCTAssertNil(tokenInKeychain()) + + expectation(forNotification: .LineSDKAccessTokenDidRemove, object: nil, handler: nil) + + restoreKeychainFunctions() + try! AccessTokenStore.shared.removeCurrentAccessToken() + XCTAssertNil(AccessTokenStore.shared.current) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testRefreshFlowSucceedsWhenKeychainIsUnavailable() { + let expect = expectation(description: "\(#file)_\(#line)") + + let delegate = SessionDelegateStub(stub: .init(string: PostRefreshTokenRequest.success, responseCode: 200)) + Session._shared = Session(configuration: LoginConfiguration.shared, delegate: delegate) + + let oldToken = makeToken(value: "old") + AccessTokenStore.shared.setCurrentToken(oldToken) + XCTAssertEqual(tokenInKeychain(), oldToken) + + breakKeychainWriting() + + let pipeline = RefreshTokenRedirector() + let request = StubRequestSimple() + let response = HTTPURLResponse.responseFromCode(401) + + try! pipeline.redirect(request: request, data: Data(), response: response) { action in + switch action { + case .restartWithout: + MainActor.assumeIsolated { + // The refresh succeeds with the new token in memory, even though it cannot be persisted. + XCTAssertEqual( + AccessTokenStore.shared.current?.value, PostRefreshTokenRequest.successToken + ) + XCTAssertTrue(AccessTokenStore.shared.isTokenPersistencePending) + // The keychain still holds the old token. + XCTAssertEqual(self.tokenInKeychain(), oldToken) + } + default: + XCTFail("The request should be restarted even if the keychain writing fails.") + } + expect.fulfill() + } + waitForExpectations(timeout: 1.0, handler: nil) + } +} diff --git a/LineSDK/LineSDKTests/OpenChat/OpenChatControllerTests.swift b/LineSDK/LineSDKTests/OpenChat/OpenChatControllerTests.swift index 01087b1c..e12f7bc8 100644 --- a/LineSDK/LineSDKTests/OpenChat/OpenChatControllerTests.swift +++ b/LineSDK/LineSDKTests/OpenChat/OpenChatControllerTests.swift @@ -87,7 +87,7 @@ class OpenChatCreatingControllerTests: XCTestCase, ViewControllerCompatibleTest """.data(using: .utf8)! let token = try! JSONDecoder().decode(AccessToken.self, from: tokenData) - try! AccessTokenStore.shared.setCurrentToken(token) + AccessTokenStore.shared.setCurrentToken(token) let statusWithToken = OpenChatCreatingController.localAuthorizationStatusForCreatingOpenChat() guard case .authorized = statusWithToken else { diff --git a/LineSDK/LineSDKTests/Utils/APITests.swift b/LineSDK/LineSDKTests/Utils/APITests.swift index 1a610736..8d1ed05b 100644 --- a/LineSDK/LineSDKTests/Utils/APITests.swift +++ b/LineSDK/LineSDKTests/Utils/APITests.swift @@ -25,7 +25,7 @@ import XCTest @MainActor func setupTestToken() { let token = try! JSONDecoder().decode(AccessToken.self, from: PostExchangeTokenRequest.successData) - try! AccessTokenStore.shared.setCurrentToken(token) + AccessTokenStore.shared.setCurrentToken(token) } class APITests: XCTestCase {