Skip to content

feat: conditional passkey (autofill) support#227

Open
iamEvanYT wants to merge 9 commits intomainfrom
opencode/conditional-passkey-autofill
Open

feat: conditional passkey (autofill) support#227
iamEvanYT wants to merge 9 commits intomainfrom
opencode/conditional-passkey-autofill

Conversation

@iamEvanYT
Copy link
Member

Summary

Implements conditional passkey (autofill) support so that when a website calls navigator.credentials.get({ mediation: "conditional" }), Flow Browser queries available passkeys via electron-webauthn's listPasskeys(rpId) API, monitors focus events on <input autocomplete="webauthn"> fields, and shows a dropdown overlay near the focused input. On passkey selection, authentication completes using the existing getCredential() flow.

Changes

  • Design document (design/CONDITIONAL_PASSKEY_SUPPORT.md) — full architecture, data flow, edge cases
  • Shared types (src/shared/flow/interfaces/browser/passkey-overlay.ts) — PasskeyCredentialInfo, PasskeyOverlayPosition, FlowPasskeyOverlayAPI interfaces; registered on the global flow type
  • Main process IPC (src/main/ipc/webauthn/index.ts) — conditional session tracking with long-lived promises, listPasskeys() integration, overlay position computation from tab bounds, select/dismiss handlers, cleanup on tab destruction/navigation
  • Preload tab-side (src/preload/index.ts) — reportInputFocus, reportInputBlur, onConditionalPasskeys IPC methods; setupConditionalUI() with MutationObserver + focus/blur/scroll listeners for autocomplete="webauthn" inputs; isConditionalMediationAvailable() now returns true on macOS
  • Preload chrome-side (src/preload/index.ts) — passkeyOverlayAPI exposed as flow.passkeyOverlay with onShow, onHide, select, dismiss
  • Renderer (src/renderer/src/components/browser-ui/passkey-overlay.tsx) — PasskeyOverlay component using PortalComponent pattern (matching find-in-page), with animated dropdown, keyboard navigation, and auto-focus

Key Design Decisions

Decision Choice Rationale
Trigger event Focus (not hover) Matches WebAuthn spec + Chrome/Safari behavior
Scroll behavior Close on scroll Matches Chrome autofill; avoids continuous IPC position updates
Unsupported platform Return false from isConditionalMediationAvailable() Graceful degradation; websites fall back to non-conditional flow

Requirements

  • macOS 13.3+ (Ventura) — listPasskeys() requires this at the OS level
  • electron-webauthn@^1.2.0 — provides listPasskeys(relyingPartyId: string)

Validation

  • bun lint — passes
  • bun typecheck — passes (both node and web configs)
  • bun format — all files clean

Describes the full architecture for handling navigator.credentials.get()
with mediation='conditional'. Covers data flow across all three layers
(tab preload -> main process -> browser chrome), key decisions (trigger
on focus, close on scroll, return false on unsupported platforms), edge
cases, and the file-by-file implementation plan.
Introduce PasskeyCredentialInfo, PasskeyOverlayPosition, and
FlowPasskeyOverlayAPI interfaces. Register passkeyOverlay on the
global flow type so both the preload and renderer can consume it.
Rewrite webauthn:get handler to detect mediation='conditional', call
listPasskeys(rpId), store a long-lived promise in a ConditionalSession
map, and send the passkey list to the tab preload.

Add four new IPC handlers:
- webauthn:conditional-input-focus: computes window-relative overlay
  position from tab bounds + input rect, forwards to browser chrome
- webauthn:conditional-input-blur: hides the overlay
- webauthn:conditional-select: calls getCredential() with the selected
  credential's allowCredentials, resolves the pending promise
- webauthn:conditional-dismiss: resolves with NotAllowedError

Add webauthn:is-conditional-available handler and cleanup on tab
destruction/navigation.
Tab-side changes:
- Extend PatchedCredentialsContainer with reportInputFocus,
  reportInputBlur, and onConditionalPasskeys IPC methods
- Change isConditionalMediationAvailable from returning false to
  calling webauthn:is-conditional-available IPC
- Replace mediation='conditional' NotSupportedError throw with full
  conditional flow: listen for passkey list, set up setupConditionalUI()
  with MutationObserver + focus/blur listeners on autocomplete='webauthn'
  inputs, close overlay on scroll, wire AbortSignal cleanup
- Extract throwWebauthnError helper to deduplicate DOMException logic

Browser-chrome-side changes:
- Add passkeyOverlayAPI implementing FlowPasskeyOverlayAPI (onShow,
  onHide, select, dismiss) using listenOnIPCChannel
- Expose as flow.passkeyOverlay via wrapAPI with 'browser' permission
Create passkey-overlay.tsx with three components following the
find-in-page PortalComponent pattern:
- PasskeyItem: individual passkey row with icon, userName, rpId
- PasskeyDropdown: animated dropdown with keyboard navigation
  (arrow keys, Enter to select, Escape to dismiss), auto-focus
- PasskeyOverlay: orchestrator listening to flow.passkeyOverlay
  onShow/onHide, rendering via PortalComponent at ViewLayer.OVERLAY

Mount <PasskeyOverlay /> alongside <FindInPage /> in browser-ui main.
@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

Build artifacts for all platforms are ready! 🚀

Download the artifacts for:

One-line installer (Unstable):
bunx flow-debug-build --open 22650188362

(execution 22650188362 / attempt 1)

@greptile-apps
Copy link

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR implements conditional passkey (WebAuthn autofill) support for Flow Browser, covering the full stack from main-process IPC session management to tab-side DOM observers and a new browser-chrome dropdown overlay component. The architecture closely mirrors the existing FindInPage portal pattern and correctly handles cleanup on navigation, tab destruction, and AbortSignal.

Key areas that need attention before merging:

  • Session identity not re-checked after async gap — the webauthn:conditional-select handler captures a session reference before await webauthn.getCredential(...). If navigation fires during that async window, the session is cancelled and potentially replaced; the subsequent conditionalSessions.delete() could then delete the new session, orphaning it silently. The fix is to verify the Map still holds the same session object after the await.
  • Missing credential-ID validation — the credentialId received from the chrome renderer is used directly without being checked against session.passkeys. Validating it against the passkey list returned by listPasskeys() would prevent an out-of-sync renderer state from triggering authentication with an arbitrary credential.
  • Cleanup uses did-navigate instead of did-start-navigationdid-navigate fires after a cross-document navigation completes rather than when it begins, leaving the long-lived promise alive for the full page-load duration.
  • Identical handler bodieswebauthn:is-conditional-available and webauthn:is-available share the exact same implementation with no comment explaining the intentional design decision to defer the macOS-version check to listPasskeys() at runtime.

Confidence Score: 3/5

  • Functional for the common path, but a session-identity race in the select handler and missing credential-ID validation should be addressed before merging.
  • The overall architecture is sound and the vast majority of the implementation is correct. The score is reduced because the webauthn:conditional-select handler has a genuine bug where a stale conditionalSessions.delete() can orphan a newly-started session after an async gap, and because the credential ID accepted from the renderer is never validated against the session's passkey list. These are correctness and security issues in the core authentication path.
  • Pay close attention to src/main/ipc/webauthn/index.ts, specifically the webauthn:conditional-select handler (lines 281–336) and the availability check handlers (lines 354–362).

Important Files Changed

Filename Overview
src/main/ipc/webauthn/index.ts Core conditional passkey IPC logic. Contains a potential session-identity bug in webauthn:conditional-select after the async getCredential gap, missing credential-ID validation against the session passkey list, a cleanup event mismatch (did-navigate vs did-start-navigation), and identical implementations for is-available and is-conditional-available without explanatory comments.
src/preload/index.ts Adds conditional passkey preload methods and DOM observer setup (setupConditionalUI). The 150ms blur debounce, MutationObserver pattern, AbortSignal wiring, and cleanup logic are all well-implemented. Minor: onConditionalPasskeys callback types any[] instead of PasskeyCredentialInfo[].
src/renderer/src/components/browser-ui/passkey-overlay.tsx New PasskeyOverlay component following the PortalComponent pattern. Clean implementation with keyboard navigation, animated enter/exit, and correct IPC listener cleanup via useEffect return values.
src/shared/flow/interfaces/browser/passkey-overlay.ts New shared type definitions for PasskeyCredentialInfo, PasskeyOverlayPosition, and FlowPasskeyOverlayAPI. Well-structured and consistent with the existing codebase patterns.
src/renderer/src/components/browser-ui/main.tsx Trivial one-line mount of <PasskeyOverlay /> alongside <FindInPage />, consistent with the existing component mounting pattern.
src/shared/flow/flow.ts Adds passkeyOverlay: FlowPasskeyOverlayAPI to the global flow type declaration, consistent with existing API registrations.

Sequence Diagram

sequenceDiagram
    participant Website as Website (Tab)
    participant Preload as Tab Preload
    participant Main as Main Process IPC
    participant Chrome as Browser Chrome
    participant Overlay as PasskeyOverlay

    Website->>Preload: credentials.get({ mediation: "conditional" })
    Preload->>Main: ipcRenderer.invoke("webauthn:get", options)
    Main->>Main: listPasskeys(rpId)
    Main->>Preload: send("webauthn:conditional-passkeys", passkeys)
    Note over Main: Long-lived Promise stored in conditionalSessions

    Preload->>Preload: setupConditionalUI()<br/>MutationObserver + focus/blur listeners

    Website->>Preload: User focuses input[autocomplete=webauthn]
    Preload->>Main: send("webauthn:conditional-input-focus", rect)
    Main->>Main: Compute overlay position<br/>(tabBounds + inputRect)
    Main->>Chrome: sendMessageToCoreWebContents("webauthn:conditional-show-overlay")
    Chrome->>Overlay: onShow({ passkeys, position })
    Overlay->>Overlay: Render PasskeyDropdown

    Overlay->>Chrome: User selects passkey
    Chrome->>Main: send("webauthn:conditional-select", credentialId)
    Main->>Main: getCredential(options + allowCredentials)
    Main->>Main: session.resolve(result)
    Main->>Preload: ipcRenderer.invoke resolves
    Preload->>Website: Return PublicKeyCredential

    Note over Main,Chrome: On navigation/destroy: cancelConditionalSession → hide overlay
Loading

Last reviewed commit: 7f8642f

… keyboard nav, and tab-switch handling

- Add enablePasskeyAutofill setting to disable passkey autofill globally
- Hide overlay instantly on tab switch using active-tab-changed listener
- Support both dark and light mode theming in overlay
- Implement keyboard navigation (arrows, enter, escape) without stealing focus
- Increase border/shadow visibility for the autofill portal UI
- Guard against stale session reference after async getCredential call
- Use did-start-navigation instead of did-navigate for earlier cleanup
- Validate credentialId against session passkey list (defense-in-depth)
- Add clarifying comment on is-conditional-available handler
…e-input-event

Keyboard events (arrows, enter, escape) were not reaching the passkey
overlay because it runs in a separate unfocused WebContentsView. Move
selection state and keyboard handling to the main process using
before-input-event on the tab's webContents, which allows synchronous
preventDefault (critical for Enter). Also fix scrollbar color to follow
space theme instead of system theme.
Reset renderer visible state when focusedTabId no longer matches
shownForTabId, preventing the overlay from reappearing without main
process knowledge (which broke keyboard handling and dismiss).

Gate preload dismiss handlers (scroll, focusin, pointerdown,
visibilitychange) behind an overlayShown boolean to avoid unnecessary
IPC traffic on every click/focus change/scroll event.
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.

1 participant