Skip to content

[PM-24195] fix: Pass userId to refreshToken and setTokens#2374

Open
LRNcardozoWDF wants to merge 24 commits intomainfrom
cmcg/pm-24195-log-error-response-model
Open

[PM-24195] fix: Pass userId to refreshToken and setTokens#2374
LRNcardozoWDF wants to merge 24 commits intomainfrom
cmcg/pm-24195-log-error-response-model

Conversation

@LRNcardozoWDF
Copy link
Copy Markdown
Member

@LRNcardozoWDF LRNcardozoWDF commented Feb 24, 2026

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-24195

📔 Objective

There's a possible race condition when refreshing the token with that in mind I'm adding more logs and passing the userId of the active account.

@LRNcardozoWDF LRNcardozoWDF requested review from a team and matt-livefront as code owners February 24, 2026 15:52
@github-actions github-actions bot added app:password-manager Bitwarden Password Manager app context t:misc Change Type - ¯\_(ツ)_/¯ labels Feb 24, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 24, 2026

Logo
Checkmarx One – Scan Summary & Detailsc7dde1e9-45c0-4bdc-b37a-919bc2254259

Great job! No new security vulnerabilities introduced in this pull request

@LRNcardozoWDF LRNcardozoWDF changed the title [PM-24195] Log error response model [PM-24195] chore: Log error response model Feb 24, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Feb 24, 2026

Codecov Report

❌ Patch coverage is 96.47577% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.81%. Comparing base (daaf1e8) to head (70f5629).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...atform/Services/TestHelpers/MockTokenService.swift 84.61% 4 Missing ⚠️
...tform/Services/API/AccountTokenProviderTests.swift 96.34% 3 Missing ⚠️
...red/Core/Platform/Services/TokenServiceTests.swift 97.56% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2374      +/-   ##
==========================================
- Coverage   86.92%   85.81%   -1.12%     
==========================================
  Files        1852     2083     +231     
  Lines      163591   178622   +15031     
==========================================
+ Hits       142203   153285   +11082     
- Misses      21388    25337    +3949     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@LRNcardozoWDF LRNcardozoWDF marked this pull request as draft February 25, 2026 13:28
@LRNcardozoWDF LRNcardozoWDF force-pushed the cmcg/pm-24195-log-error-response-model branch from dc4164c to 0c069d7 Compare February 25, 2026 13:50
@github-actions github-actions bot added the t:tech-debt Change Type - Tech debt label Feb 25, 2026
@LRNcardozoWDF LRNcardozoWDF marked this pull request as ready for review March 3, 2026 10:58
@LRNcardozoWDF LRNcardozoWDF added ai-review Request a Claude code review and removed t:tech-debt Change Type - Tech debt labels Mar 3, 2026
@github-actions github-actions bot added the t:tech-debt Change Type - Tech debt label Mar 3, 2026
@claude
Copy link
Copy Markdown

claude bot commented Mar 3, 2026

Claude finished @LRNcardozoWDF's task in 3m 14s —— View job


Code Review — PR #2374

  • Gather context and read changed files
  • Review code changes against Bitwarden standards
  • Post review findings

Overall Assessment: APPROVE

This PR fixes a race condition in token refresh by capturing the active userId upfront and threading it explicitly through getRefreshToken(userId:) and setTokens(..., userId:), preventing tokens from being stored under the wrong account during account switches. It also adds targeted Crashlytics logging for the "Cipher was not encrypted for the current user" server error by wrapping it in BitwardenError.generalError to bypass NonLoggableError suppression, while preserving generic error logging for all other failures.

The implementation addresses all previous review feedback (both Claude and fedemkr). Test coverage is comprehensive, including race condition simulation via MockHTTPClient.onRequest callbacks. No new issues found.

Code Review Details

No findings to report. All changed code follows established patterns, security requirements are met, and error handling is correct.

Comment on lines +700 to +708
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 Addressedelse 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

Comment on lines +111 to +119
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)
}

@LRNcardozoWDF LRNcardozoWDF removed the t:tech-debt Change Type - Tech debt label Mar 10, 2026
@github-actions github-actions bot added the t:tech-debt Change Type - Tech debt label Mar 10, 2026
@LRNcardozoWDF LRNcardozoWDF removed the t:tech-debt Change Type - Tech debt label Mar 12, 2026
@github-actions github-actions bot added the t:tech-debt Change Type - Tech debt label Mar 13, 2026
@LRNcardozoWDF LRNcardozoWDF removed the t:tech-debt Change Type - Tech debt label Mar 16, 2026
Comment on lines +701 to +710
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.

Comment on lines +105 to +123
} 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.

Comment on lines +96 to +97
// 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.

@github-actions github-actions bot added the t:tech-debt Change Type - Tech debt label Mar 17, 2026
return
} catch ServerError.error(errorResponse: let errorResponse)
where errorResponse.message.contains("Cipher was not encrypted for the current user") {
let serverError = ServerError.error(errorResponse: errorResponse)
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.

🎨 No need to recreate the server error, you already have it in the error variable.

@LRNcardozoWDF LRNcardozoWDF force-pushed the cmcg/pm-24195-log-error-response-model branch from f466c8e to 50d0f59 Compare March 17, 2026 14:30
… cmcg/pm-24195-log-error-response-model

# Conflicts:
#	BitwardenShared/Core/Platform/Services/API/AccountTokenProvider.swift
#	BitwardenShared/Core/Platform/Services/API/AccountTokenProviderTests.swift
#	TestHelpers/API/MockHTTPClient.swift
@LRNcardozoWDF LRNcardozoWDF force-pushed the cmcg/pm-24195-log-error-response-model branch from 50d0f59 to 4ac7416 Compare March 17, 2026 14:44
@LRNcardozoWDF LRNcardozoWDF changed the title [PM-24195] chore: Log error response model [PM-24195] fix: Pass userId to refreshToken and setTokens Mar 17, 2026
@LRNcardozoWDF LRNcardozoWDF added t:bug Change Type - Bug and removed t:misc Change Type - ¯\_(ツ)_/¯ t:tech-debt Change Type - Tech debt labels Mar 17, 2026
@LRNcardozoWDF LRNcardozoWDF requested a review from fedemkr March 17, 2026 16:18
@github-actions github-actions bot added the t:misc Change Type - ¯\_(ツ)_/¯ label Mar 30, 2026
Copy link
Copy Markdown
Member

@fedemkr fedemkr left a comment

Choose a reason for hiding this comment

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

👍 Looks good to me, I'd wait for @KatherineInCode or @matt-livefront's approval as well in case I've missed anything as this touches the account tokens.

Comment on lines +39 to +40
var getAccessTokenUserId: String?
var getRefreshTokenUserId: String?
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏️ Does it make sense to keep these next to getRefreshTokenResult and getRefreshTokenResult. That's the pattern we've used elsewhere and keeps them alphabetized.

Comment on lines +140 to +141
func getActiveAccountId() async throws -> String {
try await stateService.getActiveAccountId()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤔 Instead of exposing this via TokenService, would it be better to pass ActiveAccountStateProvider (implemented by StateService) to AccountTokenProvider?

Comment on lines +13 to +19
// Track which userId was used in explicit userId methods
var getAccessTokenCalledWithUserId: String?
var getRefreshTokenCalledWithUserId: String?
var setTokensCalledWithUserId: String?
var activeAccountId: String = "1"
var accessTokenByUserId: [String: String] = [:]
var refreshTokenByUserId: [String: String] = [:]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏️ Can you alphabetize these with the other properties?

Comment on lines +136 to +140
func getAccessToken(userId: String) async throws -> String {
try await keychainRepository.getAccessToken(userId: userId)
}

func getActiveAccountId() async throws -> String {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏️ Can you alphabetize these methods?

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review app:password-manager Bitwarden Password Manager app context t:bug Change Type - Bug t:misc Change Type - ¯\_(ツ)_/¯

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants