From 7e4d85a674006bcfa3c39dd5df13ff852cd18ec4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 24 Jul 2026 13:03:49 +0900 Subject: [PATCH 1/5] Keep refreshed token in memory when keychain write fails When storing a new access token to the keychain fails (e.g. errSecNotAvailable while securityd is unavailable), the token is now kept in memory instead of being discarded, so the current session continues 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, and logs diagnostics (raw status, app state, protected data availability) on failure. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 + LineSDK/LineSDK.xcodeproj/project.pbxproj | 4 + LineSDK/LineSDK/Login/LoginManager.swift | 2 +- .../Login/Model/AccessTokenStore.swift | 128 +++++++-- LineSDK/LineSDK/Networking/API/API+Auth.swift | 2 +- LineSDK/LineSDK/Utils/KeychainStore.swift | 11 +- .../Login/AccessTokenStoreTests.swift | 243 ++++++++++++++++++ .../OpenChat/OpenChatControllerTests.swift | 2 +- LineSDK/LineSDKTests/Utils/APITests.swift | 2 +- 9 files changed, 373 insertions(+), 25 deletions(-) create mode 100644 LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1a0732..cb66b5c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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. + ## [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..c590e143 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 @@ -123,7 +125,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 +134,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 +158,114 @@ 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] = [] + + 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() + + if let error = persistenceError { + logTokenPersistenceFailure(error) + } NotificationCenter.default.post(name: .LineSDKAccessTokenDidUpdate, object: token, userInfo: userInfo) } - + + // Retries to store the in-memory token when a previous keychain writing failed. + func retryPendingTokenPersistence() { + lock.lock() + defer { lock.unlock() } + guard _tokenPersistencePending, let token = _current else { return } + do { + try keychainStore.set(token, configuration: configuration, version: storeVersion) + _tokenPersistencePending = false + Log.print("The pending access token is now stored to keychain successfully.") + } catch { + // Keychain is still unavailable. Keep the pending state and wait for the next chance. + Log.print("Retrying token persistence failed again: \(error)") + } + } + + 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 } + // Retry off the main thread. Keychain calls can block when securityd is unresponsive. + DispatchQueue.global(qos: .utility).async { + 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/LineSDKTests/Login/AccessTokenStoreTests.swift b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift new file mode 100644 index 00000000..78ac3059 --- /dev/null +++ b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift @@ -0,0 +1,243 @@ +// +// 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() { + super.setUp() + LoginManager.shared.setup(channelID: "123", universalLinkURL: nil) + try? AccessTokenStore.shared.removeCurrentAccessToken() + } + + override func tearDown() { + restoreKeychainFunctions() + try? AccessTokenStore.shared.removeCurrentAccessToken() + LoginManager.shared.resetForTesting() + super.tearDown() + } + + 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 + return try? store.keychainStore.token(for: store.configuration, version: store.storeVersion) + } + + private func waitUntil( + timeout: TimeInterval = 3, + _ condition: () -> Bool, + file: StaticString = #filePath, + line: UInt = #line + ) { + let start = Date() + while !condition() && Date().timeIntervalSince(start) < timeout { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + XCTAssertTrue(condition(), file: file, line: line) + } + + 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 + } + + 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 testPendingTokenIsStoredWhenAppBecomesActive() { + let token = makeToken(value: "token1") + + breakKeychainWriting() + AccessTokenStore.shared.setCurrentToken(token) + XCTAssertTrue(AccessTokenStore.shared.isTokenPersistencePending) + + restoreKeychainFunctions() + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + + waitUntil { !AccessTokenStore.shared.isTokenPersistencePending } + XCTAssertEqual(tokenInKeychain(), token) + } + + func testPendingTokenIsStoredWhenProtectedDataBecomesAvailable() { + let token = makeToken(value: "token1") + + breakKeychainWriting() + AccessTokenStore.shared.setCurrentToken(token) + XCTAssertTrue(AccessTokenStore.shared.isTokenPersistencePending) + + restoreKeychainFunctions() + NotificationCenter.default.post( + name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil + ) + + waitUntil { !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() { + 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) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + 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 { From 4d784598ccf4a30dfc0955dc1509aafa15d14e2a Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 24 Jul 2026 13:17:06 +0900 Subject: [PATCH 2/5] Use async setUp/tearDown in AccessTokenStoreTests for CI compatibility The synchronous overrides are treated as nonisolated by the XCTest SDK on CI's Xcode version, so calling main actor-isolated helpers from them fails to compile. Follow the async throws pattern used by RefreshTokenPipelineTests. Co-Authored-By: Claude Fable 5 --- LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift index 78ac3059..82a8502d 100644 --- a/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift +++ b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift @@ -26,17 +26,15 @@ import Security @MainActor class AccessTokenStoreTests: XCTestCase, Sendable { - override func setUp() { - super.setUp() + override func setUp() async throws { LoginManager.shared.setup(channelID: "123", universalLinkURL: nil) try? AccessTokenStore.shared.removeCurrentAccessToken() } - override func tearDown() { + override func tearDown() async throws { restoreKeychainFunctions() try? AccessTokenStore.shared.removeCurrentAccessToken() LoginManager.shared.resetForTesting() - super.tearDown() } private func makeToken(value: String) -> AccessToken { From fede3bd36c9d044eb77def292b7284e1e704f37c Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 24 Jul 2026 13:31:00 +0900 Subject: [PATCH 3/5] Avoid try? on optional-returning call for Swift 4.2 compatibility In Swift 4.2 mode try? does not flatten the resulting optional (pre SE-0230), so the expression had type AccessToken??. Use do/catch instead. Co-Authored-By: Claude Fable 5 --- LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift index 82a8502d..0c8405ec 100644 --- a/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift +++ b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift @@ -62,7 +62,11 @@ class AccessTokenStoreTests: XCTestCase, Sendable { private func tokenInKeychain() -> AccessToken? { let store = AccessTokenStore.shared - return try? store.keychainStore.token(for: store.configuration, version: store.storeVersion) + do { + return try store.keychainStore.token(for: store.configuration, version: store.storeVersion) + } catch { + return nil + } } private func waitUntil( From 8e060678135fe1448beadefcb19958f065072b63 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 24 Jul 2026 14:02:00 +0900 Subject: [PATCH 4/5] Add LineSDKAccessTokenDidFailToPersist notification Token refresh and login no longer report a failure when the keychain write fails, so apps lost the ability to observe such failures. Post a public notification (with the underlying error in userInfo) on the initial write failure and on each failed retry, and expose it to Objective-C as well. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++ .../Login/Model/AccessTokenStore.swift | 43 ++++++++++++++++--- .../Login/Model/LineSDKAccessTokenStore.swift | 2 + .../Login/AccessTokenStoreTests.swift | 16 +++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb66b5c8..a48e5fd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. - 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/Login/Model/AccessTokenStore.swift b/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift index c590e143..4facd867 100644 --- a/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift +++ b/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift @@ -37,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. @@ -192,24 +206,43 @@ final public class AccessTokenStore: @unchecked Sendable { _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] + ) } - NotificationCenter.default.post(name: .LineSDKAccessTokenDidUpdate, object: token, userInfo: userInfo) } // Retries to store the in-memory token when a previous keychain writing failed. func retryPendingTokenPersistence() { lock.lock() - defer { lock.unlock() } - guard _tokenPersistencePending, let token = _current else { return } + guard _tokenPersistencePending, let token = _current else { + lock.unlock() + return + } + var persistenceError: Error? do { try keychainStore.set(token, configuration: configuration, version: storeVersion) _tokenPersistencePending = false - Log.print("The pending access token is now stored to keychain successfully.") } 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.") } } 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 index 0c8405ec..9857272a 100644 --- a/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift +++ b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift @@ -98,6 +98,9 @@ class AccessTokenStoreTests: XCTestCase, Sendable { 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) @@ -127,6 +130,19 @@ class AccessTokenStoreTests: XCTestCase, Sendable { 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() { let token = makeToken(value: "token1") From c73ad42edfddd1bf281550009fec7bc4ca890a5f Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 24 Jul 2026 14:14:05 +0900 Subject: [PATCH 5/5] Make lifecycle-triggered persistence retry deterministic in tests The retry runs on a background queue in production, which made the lifecycle-notification tests depend on dispatch timing and flaky on CI. Inject the retry executor so tests run it synchronously. Co-Authored-By: Claude Fable 5 --- .../Login/Model/AccessTokenStore.swift | 9 ++++++-- .../Login/AccessTokenStoreTests.swift | 23 ++++++++----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift b/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift index 4facd867..ea8b536c 100644 --- a/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift +++ b/LineSDK/LineSDK/Login/Model/AccessTokenStore.swift @@ -183,6 +183,12 @@ final public class AccessTokenStore: @unchecked Sendable { 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 } @@ -254,8 +260,7 @@ final public class AccessTokenStore: @unchecked Sendable { 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 } - // Retry off the main thread. Keychain calls can block when securityd is unresponsive. - DispatchQueue.global(qos: .utility).async { + self.persistenceRetryExecutor { self.retryPendingTokenPersistence() } } diff --git a/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift index 9857272a..e1776a07 100644 --- a/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift +++ b/LineSDK/LineSDKTests/Login/AccessTokenStoreTests.swift @@ -69,17 +69,10 @@ class AccessTokenStoreTests: XCTestCase, Sendable { } } - private func waitUntil( - timeout: TimeInterval = 3, - _ condition: () -> Bool, - file: StaticString = #filePath, - line: UInt = #line - ) { - let start = Date() - while !condition() && Date().timeIntervalSince(start) < timeout { - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - XCTAssertTrue(condition(), file: file, line: line) + // Makes lifecycle-triggered retries run synchronously so tests do not depend on background + // dispatch timing. + private func useSynchronousRetryExecutor() { + AccessTokenStore.shared.persistenceRetryExecutor = { $0() } } func testSetCurrentTokenStoresToKeychain() { @@ -144,6 +137,7 @@ class AccessTokenStoreTests: XCTestCase, Sendable { } func testPendingTokenIsStoredWhenAppBecomesActive() { + useSynchronousRetryExecutor() let token = makeToken(value: "token1") breakKeychainWriting() @@ -153,11 +147,12 @@ class AccessTokenStoreTests: XCTestCase, Sendable { restoreKeychainFunctions() NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) - waitUntil { !AccessTokenStore.shared.isTokenPersistencePending } + XCTAssertFalse(AccessTokenStore.shared.isTokenPersistencePending) XCTAssertEqual(tokenInKeychain(), token) } func testPendingTokenIsStoredWhenProtectedDataBecomesAvailable() { + useSynchronousRetryExecutor() let token = makeToken(value: "token1") breakKeychainWriting() @@ -169,7 +164,7 @@ class AccessTokenStoreTests: XCTestCase, Sendable { name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil ) - waitUntil { !AccessTokenStore.shared.isTokenPersistencePending } + XCTAssertFalse(AccessTokenStore.shared.isTokenPersistencePending) XCTAssertEqual(tokenInKeychain(), token) } @@ -189,6 +184,7 @@ class AccessTokenStoreTests: XCTestCase, Sendable { } func testRemoveTokenClearsPendingState() { + useSynchronousRetryExecutor() let token = makeToken(value: "token1") breakKeychainWriting() @@ -203,7 +199,6 @@ class AccessTokenStoreTests: XCTestCase, Sendable { // A lifecycle event must not resurrect the removed token. NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) XCTAssertNil(tokenInKeychain()) }