Skip to content

[PM-40314] fix: Enforce Master Password policy on SSO/TDE vault unlock - #2980

Open
matt-livefront wants to merge 1 commit into
mainfrom
matt/PM-40314-sso-update-mp
Open

[PM-40314] fix: Enforce Master Password policy on SSO/TDE vault unlock#2980
matt-livefront wants to merge 1 commit into
mainfrom
matt/PM-40314-sso-update-mp

Conversation

@matt-livefront

@matt-livefront matt-livefront commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

🎟️ Tracking

PM-40314

📔 Objective

Users signing in via SSO or TDE with a master password were not being checked against their organization's active Master Password policy, and were never prompted to update a non-conforming master password — unlike non-SSO logins, which already enforce this correctly.

Non-SSO login already runs this check via AuthService.checkMasterPasswordPoliciesrequirePasswordChange, setting forcePasswordResetReason on failure so the existing AuthRouter+Redirects logic redirects to the Update Master Password screen. SSO and TDE master-password entry both converge on AuthRepository.unlockVaultWithPassword(password:), which never called this check.

  • Capture IdentityTokenResponseModel.masterPasswordPolicy in AuthService.loginWithSingleSignOn, keyed by email, immediately after the identity token response is fetched — before branching into deviceKey / masterPassword / keyConnector / TDE-decryption-options handling, so it's captured regardless of which branch is taken. Also captured in loginWithTwoFactorCode, since an SSO/TDE login that also requires 2FA gets its final identity token response there instead, after the initial loginWithSingleSignOn attempt threw twoFactorRequired before it could cache anything.
  • Add a new AuthService.checkMasterPasswordPolicyAfterUnlock(email:masterPassword:) method that consumes that cache (clearing it unconditionally on read, and only trusting it when the email matches, to avoid a stale policy leaking into an unrelated account's later unlock) and reuses the existing checkMasterPasswordPolicies orchestration, falling back to PolicyService.getMasterPasswordPolicyOptions() when nothing is cached.
  • Call the new method from AuthRepository.unlockVaultWithPassword(password:) after a successful unlock, non-fatally (logged, not thrown) so a policy-check failure can never be mistaken for a wrong master password or block an unlock that already succeeded.
  • This intentionally makes the check run on every master-password vault unlock, not just at login — a safe, backward-compatible improvement for the existing vault-timeout re-lock flow, since it only uses whatever policy data is already locally available and never forces an extra sync.

@matt-livefront
matt-livefront requested a review from a team as a code owner August 19, 2026 16:06
@matt-livefront matt-livefront added ai-review Request a Claude code review t:bug Change Type - Bug labels Aug 19, 2026
@github-actions github-actions Bot added the app:password-manager Bitwarden Password Manager app context label Aug 19, 2026
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the new AuthService.checkMasterPasswordPolicyAfterUnlock(email:masterPassword:) method, the email-keyed ssoMasterPasswordPolicy cache populated in loginWithSingleSignOn / loginWithTwoFactorCode, and the non-fatal call added to AuthRepository.unlockVaultWithPassword(password:). I traced the SSO master-password, TDE "approve with master password", SSO+2FA, and plain-password+2FA paths, and confirmed the .authorizationCode guard correctly avoids double-checking password logins that already run the pre-auth check in getIdentityTokenResponse. Also verified the cache is not consumed on a failed unlock (the check runs after unlockVault succeeds), that requirePasswordChange prefers PolicyService data and only falls back to the cached token policy — which is the nil case immediately after login and before the first sync — and that UpdateMasterPasswordProcessor.syncVault() self-heals by clearing the reason when no policy turns out to apply. Test coverage is thorough and the new JSON fixture is picked up by the **/Fixtures/* glob in project-common.yml.

Code Review Details
  • ❓ : Policy check now runs on every master-password unlock, so the Update Master Password redirect is reachable from inside app extensions (AuthRouter+Redirects.swift:35 checks forcePasswordResetReason above the isInAppExtension guard)
    • BitwardenShared/Core/Auth/Repositories/AuthRepository.swift:1162

Comment on lines +1162 to +1170
do {
try await authService.checkMasterPasswordPolicyAfterUnlock(
email: account.profile.email,
masterPassword: password,
)
} catch {
// Don't let a policy-check failure block an unlock that already succeeded.
errorReporter.log(error: error)
}

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.

QUESTION: Running this on every master-password unlock means the Update Master Password screen can now be triggered from inside an app extension.

Trace and consideration

VaultUnlockProcessor.unlockVault() calls unlockVaultWithPassword and then coordinator.handleEvent(.didCompleteAuth), which routes through AuthRouter.completeAuthRedirect():

if account.profile.forcePasswordResetReason != nil {
    return .updateMasterPassword
}

if !isInAppExtension {
    ...
}

The forcePasswordResetReason check sits above the isInAppExtension guard, so this path applies in the AutoFill/Share/Action extensions too. An SSO/TDE user whose master password violates the policy — precisely the population this PR targets — may well perform their first master-password unlock inside the AutoFill extension. They would then be dropped into Update Master Password mid-autofill (which triggers settingsRepository.fetchSync() and, on success, logout) instead of getting their credential.

This was reachable before only via a reason already persisted from a main-app login; this change makes the extension itself a place where the reason first gets set. Was the extension context considered? If enforcement should be main-app-only, gating the new call (or the router's redirect) on isInAppExtension would keep autofill unaffected — the reason would still be set on the next main-app unlock. Note AppProcessor.removeMasterPassword already applies exactly this kind of guard.

The PR description discusses the vault-timeout re-lock flow but not extensions, so flagging in case it was an unconsidered side effect rather than an intentional trade-off.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.80%. Comparing base (b714401) to head (5ed3325).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2980      +/-   ##
==========================================
+ Coverage   79.50%   81.80%   +2.29%     
==========================================
  Files        1170     1047     -123     
  Lines       75192    67934    -7258     
==========================================
- Hits        59781    55572    -4209     
+ Misses      15411    12362    -3049     

☔ View full report in Codecov by Harness.
📢 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.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant