Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions LineSDK/LineSDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -681,6 +682,7 @@
4B6CACC8239F4A2B00BD6C35 /* AssertionHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertionHelpers.swift; sourceTree = "<group>"; };
4B792FB021102D9200EDDD1E /* LoginProcessURLResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginProcessURLResponseTests.swift; sourceTree = "<group>"; };
4B792FB221103A0200EDDD1E /* LoginManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginManagerTests.swift; sourceTree = "<group>"; };
A7C0FFED2607250001ACCE55 /* AccessTokenStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessTokenStoreTests.swift; sourceTree = "<group>"; };
4B792FB721103D4B00EDDD1E /* ResponseDataStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseDataStub.swift; sourceTree = "<group>"; };
4B7D8C1D2137B9FA00C6855A /* public_base_64_newline */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = public_base_64_newline; sourceTree = "<group>"; };
4B7D8C1E2137B9FA00C6855A /* public_base_64_header */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = public_base_64_header; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1197,6 +1199,7 @@
4B8A965621100A5800760219 /* LoginConfigurationTests.swift */,
4B792FB021102D9200EDDD1E /* LoginProcessURLResponseTests.swift */,
4B792FB221103A0200EDDD1E /* LoginManagerTests.swift */,
A7C0FFED2607250001ACCE55 /* AccessTokenStoreTests.swift */,
DB09851423D5AF9D0001A3B8 /* PKCETests.swift */,
);
path = Login;
Expand Down Expand Up @@ -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 */,
Expand Down
2 changes: 1 addition & 1 deletion LineSDK/LineSDK/Login/LoginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
168 changes: 148 additions & 20 deletions LineSDK/LineSDK/Login/Model/AccessTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion LineSDK/LineSDK/Networking/API/API+Auth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
11 changes: 9 additions & 2 deletions LineSDK/LineSDK/Utils/KeychainStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CFTypeRef?>?) -> OSStatus
= SecItemAdd

struct Options {
enum ItemClass {
case genericPassword
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions LineSDK/LineSDKObjC/Login/Model/LineSDKAccessTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading