Skip to content

Protect private tabs with biometric authentication via tab tray#32411

Closed
DarkSatyr wants to merge 31 commits intomozilla-mobile:mainfrom
DarkSatyr:feature-lock-private-tabs
Closed

Protect private tabs with biometric authentication via tab tray#32411
DarkSatyr wants to merge 31 commits intomozilla-mobile:mainfrom
DarkSatyr:feature-lock-private-tabs

Conversation

@DarkSatyr
Copy link
Copy Markdown

Summary

This PR introduces a lock flow for Private Tabs using biometric authentication.

When the feature is enabled, private tabs are protected behind an authentication step before they become visible.

Behavior

  • Opening the Private Tabs tray requires authentication when the setting is enabled
  • If the app resumes into a private context, the user is redirected to the Private Tabs tray
  • Private tabs remain hidden until authentication succeeds
  • Failed authentication allows retry

This keeps the lock flow centralized in the tab tray rather than introducing a separate page overlay.

Implementation details

The implementation integrates with the existing Redux-style architecture used in Firefox iOS:

  • Introduced a PrivateLockDomainState to represent authentication and access state
  • Moved lock orchestration into middleware
  • UI reacts to state changes rather than triggering authentication directly
  • Added relock handling for background / foreground transitions

UI

A lock overlay is shown in the private tab tray until authentication succeeds.

Notes

This implementation intentionally keeps the authentication entry point in the tab tray to avoid duplicating lock logic on private page surfaces.

I’d be happy to iterate on this if adjustments are needed to better fit the existing architecture.

Demo

output.mp4

@DarkSatyr DarkSatyr requested a review from a team as a code owner March 7, 2026 23:31
@DarkSatyr DarkSatyr requested a review from Cramsden March 7, 2026 23:31
@Cramsden Cramsden requested a review from lmarceau March 9, 2026 15:21
@Cramsden
Copy link
Copy Markdown
Contributor

Cramsden commented Mar 9, 2026

HI there! thank you so much for this change! It is an exciting new feature. This is an actual feature addition, so we are going to need some input from design and product. It means that feedback will take a little bit longer. Thank you for your patience!

@DarkSatyr
Copy link
Copy Markdown
Author

HI there! thank you so much for this change! It is an exciting new feature. This is an actual feature addition, so we are going to need some input from design and product. It means that feedback will take a little bit longer. Thank you for your patience!

Thanks! Happy to wait for feedback from design and product.
Let me know if any adjustments are needed

@Cramsden
Copy link
Copy Markdown
Contributor

Hi @DarkSatyr! Product and Design are PUMPED about this feature! A few notes:

  • We will want to put this work behind a feature flag. This should be straight forward with our build tool fxios using the command fxios nimbus add [Name Of Feature Flag] --qa --user-toggleable -d "[Feature Flag description]" https://github.com/mozilla-mobile/fxios-ctl
  • Would it be possible to update this feature to act a bit more like saved credit cards? Where navigating to the private tabs automatically prompts you for face id when the feature is on instead of having to tap a button to unlock it.

Let me know if none of that makes sense! I will keep an eye on this PR for comments or we can chat in Matrix https://chat.mozilla.org/#/room/#fx-ios:mozilla.org.

I will provide my feedback on the code tomorrow!

@DarkSatyr
Copy link
Copy Markdown
Author

@Cramsden
Thanks for the feedback! I’ll work on adding the Nimbus feature flag and adjusting the UX.
Also joined the Matrix room as Serge in case it’s easier to discuss there

@DarkSatyr DarkSatyr force-pushed the feature-lock-private-tabs branch from 30708d4 to ed9a933 Compare March 11, 2026 12:58
@DarkSatyr
Copy link
Copy Markdown
Author

@Cramsden
Completed 2 improvements

output.mp4

Copy link
Copy Markdown
Contributor

@Cramsden Cramsden left a comment

Choose a reason for hiding this comment

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

Finished my first pass! Let me know if any comments are confusing! Happy to have any follow up discussion.

Could we add some tests for the new PrivateLockMiddleware? It is not super easy to add tests for BVC but any functionality we could test would be great!

Comment on lines +1296 to +1303
store.dispatch(
PrivateLockAction(
windowUUID: windowUUID,
actionType: PrivateLockActionType.setTrayDisplayContextAndPanelType,
trayDisplayContext: .page,
trayPanelType: panel
)
)
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.

It is not clear to me from the naming what the action is actually doing here. Is there a more intuitive name we could use? Here are the action naming conventions if helpful! https://github.com/mozilla-mobile/firefox-ios/wiki/Redux-Guidelines---FAQs#tips

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point, fixed

router.dismiss(animated: true, completion: nil)
router.dismiss(animated: true, completion: { [weak browserViewController] in
browserViewController?.settingsControllerDidHide()
})
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.

What is this change needed for?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

it's just a hook to check actual state on possible settings change in order to show tray and lock on it

Comment on lines +9 to +15
case requestAuth(String)
case setPrivateContext
case setTrayDisplayContext
case setTrayDisplayContextAndPanelType
case didEnterBackground
case willEnterForeground
case lockPrivateTabsSettingsDidChange
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.

I think we should work on these action names to align a bit more with the action naming recommendations https://github.com/mozilla-mobile/firefox-ios/wiki/Redux-Guidelines---FAQs#tips

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

good point, renamed

Comment on lines +36 to +38
case setPrivateLockState
case changedTabTrayPanelType
case setTrayDisplayContext
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.

I think these action names could use a little tweaking too.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

good catch, renamed


private func resolveTabPrivateLockActions(action: PrivateLockAction, state: AppState) {
guard isPrivateLockFeatureEnabled() else {
Self.unlock(windowUUID: action.windowUUID)
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.

nit: we should not need self here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

do you mean Self.? or just self. in line 34?
unlock(windowUUID: is a static func

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.

Oh this is my bad I missed that this was Self not self. Any reason that lock and unlock need to be static?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

it's been used in result closure of context.evaluatePolicy, static helps to avoid capturing of "self"

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.

Static makes unit tests harder to make so I would advise against that pattern. Also, we would need unit tests for PrivateLockMiddleware which I don't think this PR has right now

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

changed static with instance methods

Comment on lines +128 to +133
Task { @MainActor in
if success {
Self.unlock(windowUUID: windowUUID)
} else {
Self.lock(triggeredByFailure: true, windowUUID: windowUUID)
}
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.

since neither of these functions are async and we are in a completion handler could we use ensureMainThread here instead of creating a standalone task?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

changed, thanks

Comment on lines +8 to +18
final class PrivateTabsLockFeatureGate: FeatureFlaggable {
private let prefs: Prefs
init(prefs: Prefs) {
self.prefs = prefs
}

var isEnabled: Bool {
let shouldLock = prefs.boolForKey(PrefsKeys.Settings.lockPrivateTabs) ?? false
let featureEnabled = featureFlags.isFeatureEnabled(.privateTabsLock, checking: .buildOnly)
return shouldLock && featureEnabled
}
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.

I don't think we need this class. We should be able to handle features that are user and build configured using .buildAndUser. A good example is firefoxSuggestFeature

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

good point, removed, and made use of . buildAndUser

Comment on lines +41 to +83
enum PrivateAccessState: Equatable {
case locked
case unlocked
}

enum PrivateAuthState: Equatable {
case idle
case authenticating
case failed
}

enum TrayDisplayContext: Equatable {
case page
case tray
}

struct PrivateLockDomainState: Equatable {
var access: PrivateAccessState = .locked
var auth: PrivateAuthState = .idle
var lastUnlockedAt: Date?
let relockInterval: TimeInterval = 120

var shouldRelockByTime: Bool {
guard let lastUnlockedAt else { return true }
return Date().timeIntervalSince(lastUnlockedAt) > relockInterval
}

func copy(access: PrivateAccessState? = nil,
auth: PrivateAuthState? = nil,
lastUnlockedAt: Date? = nil) -> PrivateLockDomainState {
PrivateLockDomainState(access: access ?? self.access,
auth: auth ?? self.auth,
lastUnlockedAt: lastUnlockedAt ?? self.lastUnlockedAt)
}

func locked() -> PrivateLockDomainState {
copy(access: .locked)
}

func withLastUnlocked(at: Date?) -> PrivateLockDomainState {
PrivateLockDomainState(access: access, auth: auth, lastUnlockedAt: at)
}
}
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.

I think that these should live in their own class: PrivateLockDomainState

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

good point, moved to separate file

Comment on lines +291 to +314
@MainActor
static func reduceStateForPrivateLockAction(
action: PrivateLockMiddlewareAction,
state: BrowserViewControllerState
) -> BrowserViewControllerState {
switch action.actionType {
case PrivateLockMiddlewareActionType.setPrivateLockState:
guard let privateLockState = action.privateLockState else { return state }
var newState = state
newState.privateLockState = privateLockState
return newState
case PrivateLockMiddlewareActionType.changedTabTrayPanelType:
guard let panelType = action.trayPanelType else { return state }
var newState = state
newState.trayPanelType = panelType
return newState
case PrivateLockMiddlewareActionType.setTrayDisplayContext:
guard let trayDisplayContext = action.trayDisplayContext else { return state }
var newState = state
newState.trayDisplayContext = trayDisplayContext
return newState
default:
return state
}
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.

If we move out PrivateLockDomainState to its own struct it can have its own reducer to handle these state updates

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I see what you mean, although in the current reducer this case is mostly just assigning the already-produced privateLockState back into BrowserViewControllerState.
The tray-related actions still feel browser-level to me, so I’d prefer to keep this reducer here for now unless you’d like me to do a broader refactor of the private lock state transitions

Comment on lines +419 to +420

navigationController?.setToolbarHidden(state.hideToolbar ?? false, animated: false)
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.

What is this change for?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

image

it's actually here just to hide buttons from a toolbar in locked mode

…ivateLockActionType.didChangeTrayPresentation, suppressed lint error for tabManager(_ tabManager:..) delegate method and added comment to fix it later, made use of ensureMainThread instead of Tasl { @mainactor
@mergify
Copy link
Copy Markdown
Contributor

mergify Bot commented Mar 13, 2026

This pull request has conflicts when rebasing. Could you fix it @DarkSatyr? 🙏

Copy link
Copy Markdown
Contributor

@lmarceau lmarceau left a comment

Choose a reason for hiding this comment

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

Thanks for the contribution! This is my first pass at reviewing the PR.

Before we can move forward any further, we’ll need some designs, as there are a few files we can’t properly review right now. I’ve left comments on them with some notes so we can come back to them later.

Also, please be aware of our guidelines for contributing. Since this PR is quite large, there’s a chance we may ask you to reopen it as smaller, incremental changes. We’re big fans of incremental PRs since they’re easier to review thoroughly.

Note that although my colleague already made great strides reviewing the work here, this PR may still sit in review for a while as we wait for feedback from the design team. Feel free to ask for an update at any time, but I just wanted to set expectations that this process may take some time.

Also, please make sure you’ve run Swiftlint locally and that you’re using the required version. I noticed a few Swiftlint warnings that will likely appear when the PR runs through Bitrise.

CODE_SIGN_ENTITLEMENTS = "";
COPY_PHASE_STRIP = NO;
DEFINES_MODULE = YES;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 5R87WU4VHL;
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.

We shouldn't commit development team changes

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

reverted changes

UIDevice.current.userInterfaceIdiom != .pad && selectedPanel != .syncedTabs {
guard let tabTrayVC = tabTrayCoordinator.tabTrayViewController else { return }
present(navigationController, customTransition: tabTrayVC, style: modalPresentationStyle)
present(navigationController,
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.

Is there a case where animated is false now?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

showPrivacyOverlayIfNeeded in bvc calls with animated false on appDidEnterBackgroundNotification event


private func resolveTabPrivateLockActions(action: PrivateLockAction, state: AppState) {
guard isPrivateLockFeatureEnabled() else {
Self.unlock(windowUUID: action.windowUUID)
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.

Static makes unit tests harder to make so I would advise against that pattern. Also, we would need unit tests for PrivateLockMiddleware which I don't think this PR has right now

))

let context = LAContext()
context.localizedCancelTitle = "Cancel"
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.

This will need to be localized under the Strings file

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

moved to Strings, please check

}

private func showPrivacyOverlayIfNeeded(checkActualState: Bool = false) {
if let state = browserViewControllerState {
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.

Could use a guard here to reduce indentation levels, would make this easier to read

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

fixed

}
}

@objc private func retryTapped() { onRetryTapped?() }
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.

Suggested change
@objc private func retryTapped() { onRetryTapped?() }
@objc private func retryTapped() {
onRetryTapped?()
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

changed


spinner.hidesWhenStopped = true

retryButton.setTitle("Try Again", for: .normal)
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.

String

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

ignored for now

store.dispatch(
PrivateLockAction(
windowUUID: windowUUID,
actionType: PrivateLockActionType.privateAuthRequested("Unlock your private tabs")
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.

String?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

added

// Only adjust the empty view if we are in private mode
shouldShowEmptyView(tabsState.isPrivateTabsEmpty)
}
if panelType == .privateTabs {
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.

We shouldn't check for the tabsState.isPrivateMode as well?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

moved up

Comment on lines +446 to +447
titleText: "Lock Private Tabs",
statusText: "Use Biometrics or Passcode to see Private Tabs"
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.

Strings

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

added

@DarkSatyr
Copy link
Copy Markdown
Author

@lmarceau thanks for guiding me through the process,
I will check your comments and will do the fixes very soon where possible right now, feel free to tag me when there are any updates :)

@DarkSatyr
Copy link
Copy Markdown
Author

DarkSatyr commented Mar 15, 2026

@Cramsden @lmarceau
Based on what we discussed in Matrix, I’ve made a prototype of one possible way to handle quick actions and long-press-to-open-private cases. In this version, the private page itself is also blocked (see the video).

Another possible solution would be to detect transitions coming from quick actions and long-press-to-open-private links, and in those cases open the private tab tray instead. That would preserve the same behavior and keep a single entry point for locked private tabs.

By the way, I checked Safari Quick actions flow and they are using tray to auth as a single entry point (solution #2). The only thing that they focus on a page after auth in a tray.

Just to summarise:
Approach 1: it's what you see on the video and from code perspective it's almost done. Drawbacks: 2 entry points, transition from locked page to locked tray needs to be fixed (possibly animated = false will be enough), the second thing is page actions needs to be disabled (0:26s on the video) or hidden but I don't see the simple way to do it, possibly TabItem model needs to be expanded to include hidden state since it's managed by TabManager.

Approach 2: I don't think that it will be hard to do but still it's not done. Good thing that it's going to be a single entry point, no problems with animations from page to tray.
Drawbacks: we need to detect transitions for these at least 2 cases and show a tray and after auth for better ux we need to open private page like Safari is doing

Let me know what you think

proto_output.mp4

@mergify
Copy link
Copy Markdown
Contributor

mergify Bot commented Mar 18, 2026

This pull request has conflicts when rebasing. Could you fix it @DarkSatyr? 🙏

@mergify
Copy link
Copy Markdown
Contributor

mergify Bot commented Mar 20, 2026

This pull request has conflicts when rebasing. Could you fix it @DarkSatyr? 🙏

@Cramsden
Copy link
Copy Markdown
Contributor

@DarkSatyr, Thank you so much for all of this work. This work and your most recent prototype raised a lot of good questions about what we actually want this feature to look like, which has been really helpful. This is a feature we do want to add, so we want to make sure we do it right.

The work here is great, but because there are so many different entry points into a private tab session, we want to make sure we are not missing anything. Even when Laurie and I were talking through this PR a few days ago, we identified 5+ entry points for creating a new private tab.

We would like to take a step back and put together a proper spec for this feature, including designs and an architecture proposal. If you are interested, we would love to keep working with you on it.

At the moment, the PR is too large for us to confidently review and merge, so we are going to close it for now. We plan to follow up with a contributor-OK epic that breaks the work into smaller incremental pieces that can be reviewed and merged more easily. If that is something you are still interested in, someone from our team will follow up with you soon.

@DarkSatyr
Copy link
Copy Markdown
Author

@Cramsden
Sounds like a plan 👍
I really enjoyed working with you guys and on this project.
I’d be happy to continue working on this feature when you’re ready :)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 4, 2026

This PR has been automatically marked as stale. Please leave any comment to keep this PR opened. It will be closed automatically if no further update occurs in the next 7 days. Thank you for your contributions!

@github-actions github-actions Bot added the stale Stalebot use this label to stale issues and PRs label Apr 4, 2026
@mergify
Copy link
Copy Markdown
Contributor

mergify Bot commented Apr 4, 2026

This pull request has conflicts when rebasing. Could you fix it @DarkSatyr? 🙏

@github-actions github-actions Bot removed the stale Stalebot use this label to stale issues and PRs label Apr 5, 2026
@lmarceau
Copy link
Copy Markdown
Contributor

lmarceau commented Apr 9, 2026

I'll close this PR for now as was discussed

@lmarceau lmarceau closed this Apr 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants