Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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.
/// - stateService: The service used by the application to manage account state.
/// - tokenService: The `TokenService` which manages accessing and updating the active
Expand All @@ -53,6 +54,7 @@ class APIService {
accountTokenProvider: AccountTokenProvider? = nil,
client: HTTPClient = URLSession.shared,
environmentService: EnvironmentService,
errorReporter: ErrorReporter,
flightRecorder: FlightRecorder,
stateService: StateService,
tokenService: TokenService,
Expand All @@ -76,6 +78,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,16 @@ actor DefaultAccountTokenProvider: AccountTokenProvider {
expirationDate: expirationDate,
)

let userIdAfter = try await stateService.getActiveAccountId()

if userIdBefore != userIdAfter {
let error = TokenRefreshRaceConditionError(
userIdBefore: userIdBefore,
userIdAfter: userIdAfter,
)
errorReporter.log(error: error)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎨 SUGGESTED - Diagnostic code shares the same do block as the main refresh logic

If getActiveAccountId() on line 111 throws (e.g., the active account was removed during the refresh -- precisely the race condition being investigated), the error propagates to the catch block, which calls onRefreshTokenError and re-throws. This would make a successful token refresh appear as a failure to callers, since setTokens() on line 105 already completed.

Consider wrapping the post-refresh diagnostic section in its own do/catch to keep it isolated from the success path:

// After setTokens succeeds:
do {
    let userIdAfter = try await stateService.getActiveAccountId()
    if userIdBefore != userIdAfter {
        let error = TokenRefreshRaceConditionError(
            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: - TokenRefreshRaceConditionError

/// Error logged when the active account changes during a token refresh operation.
///
struct TokenRefreshRaceConditionError: 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 @@ -10,6 +10,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 @@ -24,20 +26,26 @@ class AccountTokenProviderTests: BitwardenTestCase {
super.setUp()

client = MockHTTPClient()
errorReporter = MockErrorReporter()
stateService = MockStateService()
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() {
super.tearDown()

client = nil
errorReporter = nil
stateService = nil
subject = nil
timeProvider = nil
tokenService = nil
Expand Down Expand Up @@ -170,4 +178,41 @@ 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? TokenRefreshRaceConditionError
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)
}
}
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(),
stateService: MockStateService(),
tokenService: MockTokenService(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ extension APIService {
accountTokenProvider: AccountTokenProvider? = nil,
client: HTTPClient,
environmentService: EnvironmentService = MockEnvironmentService(),
errorReporter: ErrorReporter = MockErrorReporter(),
flightRecorder: FlightRecorder = MockFlightRecorder(),
stateService: StateService = MockStateService(),
) {
self.init(
accountTokenProvider: accountTokenProvider,
client: client,
environmentService: environmentService,
errorReporter: errorReporter,
flightRecorder: flightRecorder,
stateService: stateService,
tokenService: MockTokenService(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
)
let apiService = APIService(
environmentService: environmentService,
errorReporter: errorReporter,
flightRecorder: flightRecorder,
stateService: stateService,
tokenService: tokenService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,14 @@ 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,
))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ IMPORTANT - Generic error logging removed, creating a monitoring gap

The original code called services.errorReporter.log(error: error) for all errors from save operations. The new code only logs the specific ServerError matching "Cipher was not encrypted for the current user".

While ServerError (as a NonLoggableError) and URLError were already filtered out by CrashlyticsErrorReporter, other non-network errors (encryption failures, SDK errors, state service errors) were reaching Crashlytics before and will now be silently dropped.

The targeted BitwardenError.generalError logging should be added alongside the existing generic logging, not replace it:

Suggested change
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,
))
}
await coordinator.showErrorAlert(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,
))
}
services.errorReporter.log(error: error)

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.

@claude this is an old version, I already updated this. Review the last changes.

Copy link
Copy Markdown

@claude claude bot Mar 10, 2026

Choose a reason for hiding this comment

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

Claude finished @LRNcardozoWDF's task in 1m 32s β€”β€” View job


Re-Review of Latest Changes

  • Gather context on latest changes
  • Review updated code
  • Post review findings

Commits reviewed since last review: 87c4115 ([PM-24195] Fix tests), c758640 ([PM-24195] Fix pr comment)

Previous Findings Status

Finding Severity Status
Generic error logging removed in AddEditItemProcessor IMPORTANT Addressed β€” else branch restores generic logging for non-targeted errors
Diagnostic code shares same do block as refresh logic SUGGESTED Addressed β€” Post-refresh diagnostic wrapped in its own do/catch

Changes Since Last Review

  1. AddEditItemProcessor.swift β€” Added else branch at line 708 that calls services.errorReporter.log(error: error) for errors not matching the targeted "Cipher was not encrypted for the current user" message. This restores generic error logging for all other errors.

  2. AccountTokenProvider.swift β€” The race condition diagnostic (getActiveAccountId comparison) is now isolated in its own do/catch block (lines 111–122), preventing diagnostic failures from affecting the success path. Error type renamed from TokenRefreshRaceConditionError to AccountTokenProviderError.

  3. AccountTokenProviderTests.swift β€” Added @MainActor, fixed async tearDown, set stateService.activeAccount fixture in setUp, and added test_refreshToken_logsError_whenGetUserIdAfterThrows to verify the inner do/catch isolation works correctly (token is returned even when the diagnostic check throws).

  4. AddEditItemProcessorTests.swift β€” Updated test_perform_savePressed_serverError_otherMessage_doesNotLog to expect 1 logged error (matching restored generic logging).

Verdict

Both previous findings have been properly addressed. No new issues found in the incremental changes. The code is clean, well-tested, and ready to merge.

Approved βœ…

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,38 @@ 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)

let loggedError = try XCTUnwrap(errorReporter.errors.first as? NSError)
XCTAssertEqual(loggedError.domain, "General Error: Save item failed")
XCTAssertEqual(loggedError.code, BitwardenError.Code.generalError.rawValue)
}

/// `perform(_:)` with `.savePressed` does not log to the error reporter when a
/// `ServerError.error` carries a message other than the targeted cipher-encryption message.
@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)

XCTAssertTrue(errorReporter.errors.isEmpty)
}

/// `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 @@ -32,6 +32,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 @@ -61,6 +65,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