Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8b1762b
pm-24195 Log this error as general error
LRNcardozoWDF Feb 15, 2026
08eb750
pm-32276 Add new funcs and tests with explicit userId
LRNcardozoWDF Feb 15, 2026
e927d7a
pm-24195 Log error in account token provider
LRNcardozoWDF Feb 16, 2026
0c069d7
Merge branch 'main' into cmcg/pm-24195-log-error-response-model
LRNcardozoWDF Feb 24, 2026
4b4893d
Merge branch 'main' into cmcg/pm-24195-log-error-response-model
LRNcardozoWDF Feb 25, 2026
0b57b8a
[PM-24195] Force log to crashlytics
LRNcardozoWDF Mar 3, 2026
f0b166f
Merge branch 'main' into cmcg/pm-24195-log-error-response-model
LRNcardozoWDF Mar 3, 2026
2ef74b9
Merge branch 'main' into cmcg/pm-32276-verify-user-id-before-refresh-…
LRNcardozoWDF Mar 3, 2026
0c24943
Merge remote-tracking branch 'origin/main' into cmcg/pm-24195-log-err…
LRNcardozoWDF Mar 3, 2026
87c4115
[PM-24195] Fix tests
LRNcardozoWDF Mar 10, 2026
2db926d
Merge branch 'main' into cmcg/pm-24195-log-error-response-model
LRNcardozoWDF Mar 10, 2026
c758640
[PM-24195] Fix pr comment
LRNcardozoWDF Mar 10, 2026
0c40b7e
Merge branch 'main' into cmcg/pm-24195-log-error-response-model
LRNcardozoWDF Mar 10, 2026
c29a11a
Merge branch 'main' into cmcg/pm-24195-log-error-response-model
LRNcardozoWDF Mar 12, 2026
6ea36da
Merge branch 'main' into cmcg/pm-24195-log-error-response-model
LRNcardozoWDF Mar 13, 2026
1f2667a
Merge branch 'main' of https://github.com/bitwarden/ios into cmcg/pm-…
LRNcardozoWDF Mar 13, 2026
a62f0ed
Merge branch 'main' into cmcg/pm-24195-log-error-response-model
LRNcardozoWDF Mar 13, 2026
764ca8f
Merge remote-tracking branch 'origin/main' into cmcg/pm-32276-verify-…
LRNcardozoWDF Mar 16, 2026
0e254f8
[PM-24195] Fix pr comments
LRNcardozoWDF Mar 17, 2026
766613a
Merge branch 'main' into cmcg/pm-32276-verify-user-id-before-refresh-…
LRNcardozoWDF Mar 17, 2026
d667f2c
Merge branch 'main' into cmcg/pm-24195-log-error-response-model
LRNcardozoWDF Mar 17, 2026
4ac7416
Merge branch 'cmcg/pm-32276-verify-user-id-before-refresh-token' into…
LRNcardozoWDF Mar 17, 2026
a7d3bcf
[PM-24195] Fix pr comment and remove unnecessary code
LRNcardozoWDF Mar 27, 2026
70f5629
Merge branch 'main' into cmcg/pm-24195-log-error-response-model
LRNcardozoWDF Mar 30, 2026
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
4 changes: 4 additions & 0 deletions BitwardenShared/Core/Platform/Services/API/APIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class APIService {
/// - client: The underlying `HTTPClient` that performs the network request. Defaults
/// to `URLSession.shared`.
/// - environmentService: The service used by the application to retrieve the environment settings.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - flightRecorder: The service used by the application for recording temporary debug logs.
/// - serverCommunicationConfigClientSingleton: The service to get the server communication client
/// used to break circular dependency.
Expand All @@ -55,6 +56,7 @@ class APIService {
accountTokenProvider: AccountTokenProvider? = nil,
client: HTTPClient = URLSession.shared,
environmentService: EnvironmentService,
errorReporter: ErrorReporter,
flightRecorder: FlightRecorder,
serverCommunicationConfigClientSingleton: @escaping () -> ServerCommunicationConfigClientSingleton?,
stateService: StateService,
Expand Down Expand Up @@ -85,6 +87,8 @@ class APIService {
self.accountTokenProvider = accountTokenProvider ?? DefaultAccountTokenProvider(
httpService: httpServiceBuilder.makeService(baseURLGetter: { environmentService.identityURL }),
tokenService: tokenService,
errorReporter: errorReporter,
stateService: stateService
)

apiService = httpServiceBuilder.makeService(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@ actor DefaultAccountTokenProvider: AccountTokenProvider {
/// The delegate to use for specific operations on the token provider.
private weak var accountTokenProviderDelegate: AccountTokenProviderDelegate?

/// The service used to report non-fatal errors.
private let errorReporter: ErrorReporter

/// The `HTTPService` used to make the API call to refresh the access token.
private let httpService: HTTPService

/// The task associated with refreshing the token, if one is in progress.
private(set) var refreshTask: Task<String, Error>?

/// The service used to manage account state.
private let stateService: StateService

/// The service used to get the present time.
private let timeProvider: TimeProvider

Expand All @@ -42,15 +48,21 @@ actor DefaultAccountTokenProvider: AccountTokenProvider {
/// - httpService: The service used to make the API call to refresh the access token.
/// - timeProvider: The service used to get the present time.
/// - tokenService: The service used to get the current tokens from.
/// - errorReporter: The service used to report non-fatal errors.
/// - stateService: The service used to manage account state.
///
init(
httpService: HTTPService,
timeProvider: TimeProvider = CurrentTime(),
tokenService: TokenService,
errorReporter: ErrorReporter,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€” Should these be alphabetized?

stateService: StateService,
) {
self.httpService = httpService
self.timeProvider = timeProvider
self.tokenService = tokenService
self.errorReporter = errorReporter
self.stateService = stateService
}

// MARK: Methods
Expand Down Expand Up @@ -81,6 +93,9 @@ actor DefaultAccountTokenProvider: AccountTokenProvider {
defer { self.refreshTask = nil }

do {
// TODO: PM-33074 Remove logs after confirmation that the error doesn't happen anymore.
let userIdBefore = try await stateService.getActiveAccountId()
Copy link
Copy Markdown
Member

@fedemkr fedemkr Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€” I think you can remove the comment if you start passing the user ID to the token service functions. Also I think userIdBefore should be renamed to userIdToRefresh or something like that so it's easier to know what it refers to.


let refreshToken = try await tokenService.getRefreshToken()
let response = try await httpService.send(
IdentityTokenRefreshRequest(refreshToken: refreshToken),
Expand All @@ -93,6 +108,19 @@ actor DefaultAccountTokenProvider: AccountTokenProvider {
expirationDate: expirationDate,
)

do {
let userIdAfter = try await stateService.getActiveAccountId()
if userIdBefore != userIdAfter {
let error = AccountTokenProviderError(
userIdBefore: userIdBefore,
userIdAfter: userIdAfter,
)
errorReporter.log(error: error)
}
} catch {
errorReporter.log(error: error)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€” If this would happen then I think we shouldn't be setting the tokens in the wrong account. So you could just add the check before setTokens and add the userId: String? as parameter to setTokens so you can pass the user id you have in there instead of getting it again inside the function:

Suggested change
let userIdAfter = try await stateService.getActiveAccountId()
guard userIdBefore == userIdAfter else {
let error = AccountTokenProviderError(
userIdBefore: userIdBefore,
userIdAfter: userIdAfter,
)
errorReporter.log(error: error)
return
}
try await tokenService.setTokens(
accessToken: response.accessToken,
refreshToken: response.refreshToken,
expirationDate: expirationDate,
userId: userIdBefore // <-- maybe rename to expectedUserId instead of userIdBefore
)

πŸ€” Moreover, there could potentially be a race condition between these two as well:

                let userIdBefore = try await stateService.getActiveAccountId()

                let refreshToken = try await tokenService.getRefreshToken()

So perhaps you could add userId: String? to getRefreshToken as well thus you can pass it, so the userId used throughout this refreshToken is as stable as possible.

return response.accessToken
} catch {
if let accountTokenProviderDelegate {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

// MARK: - AccountTokenProviderError

/// Error logged when the active account changes during a token refresh operation.
///
struct AccountTokenProviderError: Error, CustomStringConvertible {
// MARK: Properties

/// The active user ID before the token refresh operation.
let userIdBefore: String

/// The active user ID after the token refresh operation.
let userIdAfter: String

// MARK: CustomStringConvertible

var description: String {
"""
Token refresh race condition detected: Active account changed from '\(userIdBefore)' to '\(userIdAfter)' \
during token refresh operation. Tokens may have been stored under wrong account.
"""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class AccountTokenProviderTests: BitwardenTestCase {
// MARK: Properties

var client: MockHTTPClient!
var errorReporter: MockErrorReporter!
var stateService: MockStateService!
var subject: DefaultAccountTokenProvider!
var timeProvider: MockTimeProvider!
var tokenService: MockTokenService!
Expand All @@ -25,20 +27,27 @@ class AccountTokenProviderTests: BitwardenTestCase {
super.setUp()

client = MockHTTPClient()
errorReporter = MockErrorReporter()
stateService = MockStateService()
stateService.activeAccount = .fixture()
timeProvider = MockTimeProvider(.mockTime(Date(year: 2025, month: 10, day: 2)))
tokenService = MockTokenService()

subject = DefaultAccountTokenProvider(
httpService: HTTPService(baseURL: URL(string: "https://example.com")!, client: client),
timeProvider: timeProvider,
tokenService: tokenService,
errorReporter: errorReporter,
stateService: stateService,
)
}

override func tearDown() async throws {
try await super.tearDown()

client = nil
errorReporter = nil
stateService = nil
subject = nil
timeProvider = nil
tokenService = nil
Expand Down Expand Up @@ -171,4 +180,63 @@ class AccountTokenProviderTests: BitwardenTestCase {
_ = try await subject.refreshToken()
}
}

/// `refreshToken()` logs an error when the active account changes during the token refresh operation.
func test_refreshToken_logsRaceCondition_whenUserIdChanges() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "user-1"))
tokenService.accessToken = "πŸ”‘"
tokenService.refreshToken = "πŸ”’"

client.result = .httpSuccess(testData: .identityTokenRefresh)

// Simulate account switch during HTTP request
client.onRequest = { _ in
self.stateService.activeAccount = .fixture(profile: .fixture(userId: "user-2"))
}

_ = try await subject.refreshToken()

// Verify error was logged
XCTAssertEqual(errorReporter.errors.count, 1)
let error = errorReporter.errors[0] as? AccountTokenProviderError
XCTAssertNotNil(error)
XCTAssertEqual(error?.userIdBefore, "user-1")
XCTAssertEqual(error?.userIdAfter, "user-2")
}

/// `refreshToken()` does not log an error when the active account remains the same.
func test_refreshToken_doesNotLogError_whenUserIdStaysSame() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "user-1"))
tokenService.accessToken = "πŸ”‘"
tokenService.refreshToken = "πŸ”’"

client.result = .httpSuccess(testData: .identityTokenRefresh)

_ = try await subject.refreshToken()

// Verify no error was logged
XCTAssertEqual(errorReporter.errors.count, 0)
}

/// `refreshToken()` logs an error when `getActiveAccountId` throws after tokens are stored,
/// but still returns the access token successfully.
func test_refreshToken_logsError_whenGetUserIdAfterThrows() async throws {
tokenService.accessToken = "πŸ”‘"
tokenService.refreshToken = "πŸ”’"
client.result = .httpSuccess(testData: .identityTokenRefresh)

// Remove the active account during the HTTP request so the second
// getActiveAccountId() call (after setTokens) throws noActiveAccount.
client.onRequest = { _ in
self.stateService.activeAccount = nil
}

let token = try await subject.refreshToken()

// Token is still returned successfully despite the diagnostic check throwing
XCTAssertEqual(token, "ACCESS_TOKEN")
// The thrown error was logged via errorReporter
XCTAssertEqual(errorReporter.errors.count, 1)
XCTAssertEqual(errorReporter.errors[0] as? StateServiceError, StateServiceError.noActiveAccount)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class RefreshableAPIServiceTests: BitwardenTestCase {
subject = APIService(
accountTokenProvider: accountTokenProvider,
environmentService: MockEnvironmentService(),
errorReporter: MockErrorReporter(),
flightRecorder: MockFlightRecorder(),
serverCommunicationConfigClientSingleton: { MockServerCommunicationConfigClientSingleton() },
stateService: MockStateService(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ extension APIService {
accountTokenProvider: AccountTokenProvider? = nil,
client: HTTPClient,
environmentService: EnvironmentService = MockEnvironmentService(),
errorReporter: ErrorReporter = MockErrorReporter(),
flightRecorder: FlightRecorder = MockFlightRecorder(),
// swiftlint:disable:next line_length
serverCommunicationConfigClientSingleton: ServerCommunicationConfigClientSingleton = MockServerCommunicationConfigClientSingleton(),
Expand All @@ -18,6 +19,7 @@ extension APIService {
accountTokenProvider: accountTokenProvider,
client: client,
environmentService: environmentService,
errorReporter: errorReporter,
flightRecorder: flightRecorder,
serverCommunicationConfigClientSingleton: { serverCommunicationConfigClientSingleton },
stateService: stateService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
let apiService = APIService(
client: noRedirectSession,
environmentService: environmentService,
errorReporter: errorReporter,
flightRecorder: flightRecorder,
serverCommunicationConfigClientSingleton: { serverCommConfigClientSingletonHolder },
stateService: stateService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,16 @@ final class AddEditItemProcessor: StateProcessor<// swiftlint:disable:this type_
return
} catch {
await coordinator.showErrorAlert(error: error)
services.errorReporter.log(error: error)
if case let ServerError.error(errorResponse: errorResponse) = error,
errorResponse.message.contains("Cipher was not encrypted for the current user") {
services.errorReporter.log(error: BitwardenError.generalError(
type: "Save item failed",
message: errorResponse.message,
error: error,
))
} else {
services.errorReporter.log(error: error)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€” Is this the only endpoint that can throw this error? If not, can we extract to some other place more generic?
🎨 Instead of using if case you can use catch with the specific error and avoid the if else:

catch let ServerError.error(errorResponse):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't found other endpoints, I'll look into it again.

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,39 @@ class AddEditItemProcessorTests: BitwardenTestCase {
XCTAssertEqual(coordinator.errorAlertsShown as? [ServerError], [error])
}

/// `perform(_:)` with `.savePressed` logs a `BitwardenError.generalError` when saving
/// fails with a `ServerError.error` carrying the targeted cipher-encryption message,
/// allowing it to reach Crashlytics despite `ServerError` being a `NonLoggableError`.
@MainActor
func test_perform_savePressed_serverError_logsGeneralError() async throws {
let body = #"{"Message":"Cipher was not encrypted for the current user","Object":"error"}"#
let response = HTTPResponse.failure(statusCode: 400, body: Data(body.utf8))
let error = try ServerError.error(errorResponse: ErrorResponseModel(response: response))
vaultRepository.addCipherResult = .failure(error)
subject.state.name = "vault item"

await subject.perform(.savePressed)

XCTAssertEqual(errorReporter.errors.count, 1)
let generalError = try XCTUnwrap(errorReporter.errors.last as? NSError)
XCTAssertEqual(generalError.domain, "General Error: Save item failed")
}

/// `perform(_:)` with `.savePressed` does not wrap the error in a `BitwardenError.generalError`
/// when a `ServerError.error` carries a message other than the targeted cipher-encryption
/// message. The raw `ServerError` is still sent to the reporter via the generic catch-all log.
@MainActor
func test_perform_savePressed_serverError_otherMessage_doesNotLog() async throws {
let response = HTTPResponse.failure(statusCode: 400, body: APITestData.bitwardenErrorMessage.data)
let error = try ServerError.error(errorResponse: ErrorResponseModel(response: response))
vaultRepository.addCipherResult = .failure(error)
subject.state.name = "vault item"

await subject.perform(.savePressed)

XCTAssertEqual(errorReporter.errors.count, 1)
}

/// `perform(_:)` with `.savePressed` saves the item.
@MainActor
func test_perform_savePressed_secureNote() async {
Expand Down
7 changes: 7 additions & 0 deletions TestHelpers/API/MockHTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public final class MockHTTPClient: HTTPClient {
/// A list of results that will be returned in order for future requests.
public var results: [Result<HTTPResponse, Error>] = []

/// An optional callback that is invoked when a request is sent, useful for simulating
/// state changes during async operations in tests.
public var onRequest: ((HTTPRequest) -> Void)?

// MARK: Initializer

/// Initializes a `MockHTTPClient`.
Expand Down Expand Up @@ -62,6 +66,9 @@ public final class MockHTTPClient: HTTPClient {
public func send(_ request: HTTPRequest) async throws -> HTTPResponse {
requests.append(request)

// Invoke callback if provided (useful for simulating state changes during async operations)
onRequest?(request)

guard !results.isEmpty else { throw MockHTTPClientError.noResultForRequest }

let result = results.removeFirst()
Expand Down
Loading