Skip to content

Add cross-platform session-scoped secure unlock retention#158

Merged
championswimmer merged 10 commits into
mainfrom
arnav/fix-web-auth-extension
May 31, 2026
Merged

Add cross-platform session-scoped secure unlock retention#158
championswimmer merged 10 commits into
mainfrom
arnav/fix-web-auth-extension

Conversation

@championswimmer

Copy link
Copy Markdown
Owner

Summary

  • add shared secure-unlock retention policy and session-passkey cache contracts in common code
  • wire Android, iOS, desktop, and browser session managers into the shared retention flow, including browser-extension storage.session
  • expose the retention toggle in shared settings UI and add cross-platform coverage for retention behavior

Testing

  • ./gradlew :composeApp:desktopTest
  • ./gradlew :composeApp:wasmJsTest
  • ./gradlew :composeApp:iosSimulatorArm64Test
  • ./gradlew :composeApp:compileAndroidMain :composeApp:assembleAndroidTest

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@qodo-code-review

Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Add cross-platform session-scoped secure unlock retention

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add shared secure-unlock retention policy and session-passkey cache contracts
• Implement session retention across Android, iOS, desktop, and browser platforms
• Integrate browser-extension storage.session backend for retention persistence
• Expose retention toggle in settings UI with cross-platform capability gating
Diagram
flowchart LR
  A["Shared Retention Policy<br/>PROMPT_EVERY_TIME vs<br/>RETAIN_FOR_CURRENT_SESSION"] -->|"implements"| B["SessionRetentionCapableSecureSessionManager"]
  B -->|"uses"| C["SessionPasskeyCache"]
  C -->|"backed by"| D["Platform Backends"]
  D -->|"Android/iOS/Desktop"| E["InMemorySessionPasskeyCache"]
  D -->|"Browser Extension"| F["BrowserExtensionSessionPasskeyCache<br/>storage.session"]
  B -->|"exposed in"| G["SettingsViewModel"]
  G -->|"drives"| H["SessionRetentionCard UI"]

Loading

Grey Divider

File Changes

1. composeApp/src/commonMain/kotlin/tech/arnav/twofac/session/SecureUnlockRetentionPolicy.kt ✨ Enhancement +61/-0

Add shared retention policy and capability interface

composeApp/src/commonMain/kotlin/tech/arnav/twofac/session/SecureUnlockRetentionPolicy.kt


2. composeApp/src/commonMain/kotlin/tech/arnav/twofac/session/SessionPasskeyCache.kt ✨ Enhancement +29/-0

Add session-scoped passkey cache abstraction

composeApp/src/commonMain/kotlin/tech/arnav/twofac/session/SessionPasskeyCache.kt


3. composeApp/src/commonMain/kotlin/tech/arnav/twofac/components/settings/SessionRetentionCard.kt ✨ Enhancement +36/-0

Add UI component for session retention toggle

composeApp/src/commonMain/kotlin/tech/arnav/twofac/components/settings/SessionRetentionCard.kt


View more (16)
4. composeApp/src/commonMain/kotlin/tech/arnav/twofac/screens/SettingsScreen.kt ✨ Enhancement +35/-0

Integrate session retention card into settings flow

composeApp/src/commonMain/kotlin/tech/arnav/twofac/screens/SettingsScreen.kt


5. composeApp/src/commonMain/kotlin/tech/arnav/twofac/viewmodels/SettingsViewModel.kt ✨ Enhancement +49/-0

Add session retention state and toggle handler

composeApp/src/commonMain/kotlin/tech/arnav/twofac/viewmodels/SettingsViewModel.kt


6. composeApp/src/commonMain/composeResources/values/strings_settings.xml 📝 Documentation +4/-0

Add localized strings for session retention settings

composeApp/src/commonMain/composeResources/values/strings_settings.xml


7. composeApp/src/androidMain/kotlin/tech/arnav/twofac/session/AndroidBiometricSessionManager.kt ✨ Enhancement +39/-16

Implement session retention with in-memory cache

composeApp/src/androidMain/kotlin/tech/arnav/twofac/session/AndroidBiometricSessionManager.kt


8. composeApp/src/androidInstrumentedTest/kotlin/tech/arnav/twofac/session/AndroidBiometricSessionManagerInstrumentedTest.kt 🧪 Tests +12/-0

Add tests for Android session retention behavior

composeApp/src/androidInstrumentedTest/kotlin/tech/arnav/twofac/session/AndroidBiometricSessionManagerInstrumentedTest.kt


9. composeApp/src/iosMain/kotlin/tech/arnav/twofac/session/IosBiometricSessionManager.kt ✨ Enhancement +36/-9

Implement session retention with in-memory cache

composeApp/src/iosMain/kotlin/tech/arnav/twofac/session/IosBiometricSessionManager.kt


10. composeApp/src/iosTest/kotlin/tech/arnav/twofac/session/IosBiometricSessionManagerTest.kt 🧪 Tests +32/-4

Add tests for iOS session retention policy

composeApp/src/iosTest/kotlin/tech/arnav/twofac/session/IosBiometricSessionManagerTest.kt


11. composeApp/src/desktopMain/kotlin/tech/arnav/twofac/session/DesktopBiometricSessionManager.kt ✨ Enhancement +36/-18

Implement session retention with in-memory cache

composeApp/src/desktopMain/kotlin/tech/arnav/twofac/session/DesktopBiometricSessionManager.kt


12. composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserSessionManager.kt ✨ Enhancement +54/-16

Integrate session retention with browser cache backend

composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserSessionManager.kt


13. composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserExtensionSessionPasskeyCache.kt ✨ Enhancement +70/-0

Implement browser extension storage.session cache backend

composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserExtensionSessionPasskeyCache.kt


14. composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/interop/WebStorageClient.kt ✨ Enhancement +53/-0

Add extension session storage interop interfaces

composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/interop/WebStorageClient.kt


15. composeApp/src/wasmJsMain/typescript/src/storage.mts ✨ Enhancement +149/-0

Implement Chrome and Firefox session storage APIs

composeApp/src/wasmJsMain/typescript/src/storage.mts


16. composeApp/src/wasmJsTest/kotlin/tech/arnav/twofac/session/BrowserSessionManagerTest.kt 🧪 Tests +88/-0

Add tests for browser session retention behavior

composeApp/src/wasmJsTest/kotlin/tech/arnav/twofac/session/BrowserSessionManagerTest.kt


17. composeApp/src/commonTest/kotlin/tech/arnav/twofac/session/SessionPasskeyCacheTest.kt 🧪 Tests +81/-0

Add tests for session cache and retention helpers

composeApp/src/commonTest/kotlin/tech/arnav/twofac/session/SessionPasskeyCacheTest.kt


18. composeApp/src/commonTest/kotlin/tech/arnav/twofac/viewmodels/SettingsViewModelTest.kt 🧪 Tests +105/-1

Add tests for session retention state management

composeApp/src/commonTest/kotlin/tech/arnav/twofac/viewmodels/SettingsViewModelTest.kt


19. .agents/plans/42-cross-platform-session-auth-retention-plan.md 📝 Documentation +335/-0

Document cross-platform retention architecture plan

.agents/plans/42-cross-platform-session-auth-retention-plan.md


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Session cache write not durable 🐞 Bug ☼ Reliability
Description
BrowserExtensionSessionPasskeyCache.write()/clear() launch async storage.session mutations and
return immediately, so retention across popup close/reopen is not guaranteed if the popup/runtime
tears down before the coroutine runs/completes. Since AccountsViewModel calls
SessionManager.savePasskey(passkey) after unlock, a missed write means the next popup will prompt
again even when RETAIN_FOR_CURRENT_SESSION is enabled.
Code

composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserExtensionSessionPasskeyCache.kt[R49-69]

Evidence
The cache persists via scope.launch without awaiting, and the common unlock flow calls
savePasskey immediately after unlock; if the popup closes before the launched coroutine completes,
the next instance cannot read from storage.session and retention silently fails.

composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserExtensionSessionPasskeyCache.kt[28-69]
composeApp/src/commonMain/kotlin/tech/arnav/twofac/viewmodels/AccountsViewModel.kt[103-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`BrowserExtensionSessionPasskeyCache` persists the retained passkey using `scope.launch { storageClient.setItem/removeItem(...) }` and does not wait for completion. In an extension popup lifecycle, this can be torn down before the async write finishes, so the passkey is not actually retained across popup reopen (the main reason for using `storage.session`).

## Issue Context
- The call site that persists the passkey after unlock is synchronous (`SessionManager.savePasskey(...)`), and common code invokes it immediately after unlocking.
- The extension storage client APIs are suspendable (Promise-backed), but the cache API is not, so the implementation currently uses fire-and-forget background work.

## Fix Focus Areas
- composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserExtensionSessionPasskeyCache.kt[32-69]
- composeApp/src/commonMain/kotlin/tech/arnav/twofac/session/SessionPasskeyCache.kt[9-13]
- composeApp/src/commonMain/kotlin/tech/arnav/twofac/session/SessionManager.kt[12-29]
- composeApp/src/commonMain/kotlin/tech/arnav/twofac/viewmodels/AccountsViewModel.kt[103-114]

## Implementation direction (pick one)
1) **Preferred (correctness):** make `SessionPasskeyCache.write()`/`clear()` suspend, and update the call graph so the extension path can `await` `storage.session` writes (likely requiring `SessionManager.savePasskey` to become `suspend` and updating call sites).
2) **Alternative:** keep interfaces but move the *awaited* persistence into suspend call sites that already exist (e.g., a dedicated `suspend fun persistRetainedPasskey(...)` on the extension cache that `BrowserSessionManager` calls from its suspend unlock/enroll flows), and ensure the manual-passkey path (`savePasskey`) also performs a best-effort awaited write from a lifecycle-stable scope (background/page/service worker), not a popup-tied scope.

Include a test that simulates "write then new manager instance reads" (i.e., cross-reopen) and fails without awaiting persistence.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Unscoped cache coroutine lifetime ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
BrowserExtensionSessionPasskeyCache creates its own CoroutineScope(SupervisorJob()) with no
cancellation/close hook, which is unstructured concurrency and can leave pending storage jobs
running across cache/manager recreations. This makes retention behavior harder to reason about and
complicates teardown/testing.
Code

composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserExtensionSessionPasskeyCache.kt[R28-33]

Evidence
The cache instantiates an owned scope with a standalone Job and no teardown API, and
BrowserSessionManager constructs the cache by default, so repeated instantiation makes coroutine
lifetime management ambiguous.

composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserExtensionSessionPasskeyCache.kt[28-33]
composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserSessionManager.kt[86-96]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`BrowserExtensionSessionPasskeyCache` owns a private `CoroutineScope(SupervisorJob())` that is never cancelled. This violates structured concurrency and makes it unclear when in-flight storage mutations stop.

## Issue Context
The cache is constructed by default in `BrowserSessionManager` and can be recreated (e.g., popup reopen / new app instance). Without a cancellation hook, the cache cannot participate in lifecycle teardown.

## Fix Focus Areas
- composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserExtensionSessionPasskeyCache.kt[28-33]
- composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserSessionManager.kt[86-96]

## Suggested fix
- Inject a `CoroutineScope` into `BrowserExtensionSessionPasskeyCache` (from a lifecycle owner appropriate for the extension runtime), or
- Add a `close()`/`cancel()` method and ensure `BrowserSessionManager.clearPasskey()` or a higher-level owner calls it when the manager is disposed.
- If you keep fire-and-forget behavior, consider `CoroutineStart.UNDISPATCHED` and explicit exception/log handling so failures aren’t silently swallowed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +49 to +69
override fun write(passkey: String) {
inMemoryPasskey = passkey
if (!storageClient.isAvailable()) return

scope.launch {
runCatching {
storageClient.setItem(storageKey, passkey)
}
}
}

override fun clear() {
inMemoryPasskey = null
if (!storageClient.isAvailable()) return

scope.launch {
runCatching {
storageClient.removeItem(storageKey)
}
}
}

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.

Action required

1. Session cache write not durable 🐞 Bug ☼ Reliability

BrowserExtensionSessionPasskeyCache.write()/clear() launch async storage.session mutations and
return immediately, so retention across popup close/reopen is not guaranteed if the popup/runtime
tears down before the coroutine runs/completes. Since AccountsViewModel calls
SessionManager.savePasskey(passkey) after unlock, a missed write means the next popup will prompt
again even when RETAIN_FOR_CURRENT_SESSION is enabled.
Agent Prompt
## Issue description
`BrowserExtensionSessionPasskeyCache` persists the retained passkey using `scope.launch { storageClient.setItem/removeItem(...) }` and does not wait for completion. In an extension popup lifecycle, this can be torn down before the async write finishes, so the passkey is not actually retained across popup reopen (the main reason for using `storage.session`).

## Issue Context
- The call site that persists the passkey after unlock is synchronous (`SessionManager.savePasskey(...)`), and common code invokes it immediately after unlocking.
- The extension storage client APIs are suspendable (Promise-backed), but the cache API is not, so the implementation currently uses fire-and-forget background work.

## Fix Focus Areas
- composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserExtensionSessionPasskeyCache.kt[32-69]
- composeApp/src/commonMain/kotlin/tech/arnav/twofac/session/SessionPasskeyCache.kt[9-13]
- composeApp/src/commonMain/kotlin/tech/arnav/twofac/session/SessionManager.kt[12-29]
- composeApp/src/commonMain/kotlin/tech/arnav/twofac/viewmodels/AccountsViewModel.kt[103-114]

## Implementation direction (pick one)
1) **Preferred (correctness):** make `SessionPasskeyCache.write()`/`clear()` suspend, and update the call graph so the extension path can `await` `storage.session` writes (likely requiring `SessionManager.savePasskey` to become `suspend` and updating call sites).
2) **Alternative:** keep interfaces but move the *awaited* persistence into suspend call sites that already exist (e.g., a dedicated `suspend fun persistRetainedPasskey(...)` on the extension cache that `BrowserSessionManager` calls from its suspend unlock/enroll flows), and ensure the manual-passkey path (`savePasskey`) also performs a best-effort awaited write from a lifecycle-stable scope (background/page/service worker), not a popup-tied scope.

Include a test that simulates "write then new manager instance reads" (i.e., cross-reopen) and fails without awaiting persistence.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copilot AI left a comment

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.

Pull request overview

This PR introduces a shared, cross-platform “secure unlock retention” capability that can cache a decrypted vault passkey for the current session (app session on native/desktop; browser session for extensions), and exposes a settings toggle to control that behavior.

Changes:

  • Added common contracts for retention policy/scope and a session-scoped passkey cache, plus helper methods to gate cache reads/writes based on policy/support.
  • Wired Android, iOS, Desktop, and Browser session managers into the shared retention flow; browser extensions use storage.session via new WASM/TS interop.
  • Exposed the retention toggle in shared Settings UI and added multi-platform test coverage for the new behavior.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
composeApp/src/commonMain/kotlin/tech/arnav/twofac/session/SecureUnlockRetentionPolicy.kt Adds shared retention policy/scope enums + retention-capable session manager contract + helper functions.
composeApp/src/commonMain/kotlin/tech/arnav/twofac/session/SessionPasskeyCache.kt Introduces session passkey cache abstraction and in-memory implementation.
composeApp/src/wasmJsMain/typescript/src/storage.mts Adds browser-extension storage.session interop (Firefox browser.* + Chrome chrome.*).
composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/interop/WebStorageClient.kt Bridges new TS interop into Kotlin via an async extension session storage client.
composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserExtensionSessionPasskeyCache.kt Implements session cache backed by extension storage.session with in-memory fallback.
composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/session/BrowserSessionManager.kt Adds retention policy handling and cache-first unlock path for browser secure unlock.
composeApp/src/androidMain/kotlin/tech/arnav/twofac/session/AndroidBiometricSessionManager.kt Adds retention policy persistence + session cache usage to skip repeated biometric prompts.
composeApp/src/iosMain/kotlin/tech/arnav/twofac/session/IosBiometricSessionManager.kt Adds retention policy persistence + session cache usage to skip repeated biometric prompts.
composeApp/src/desktopMain/kotlin/tech/arnav/twofac/session/DesktopBiometricSessionManager.kt Adds retention policy persistence + session cache usage to skip repeated secure unlock prompts.
composeApp/src/commonMain/kotlin/tech/arnav/twofac/viewmodels/SettingsViewModel.kt Exposes retention support/policy/scope in UI state and handles toggle changes.
composeApp/src/commonMain/kotlin/tech/arnav/twofac/screens/SettingsScreen.kt Renders retention toggle card when secure unlock is enabled and retention is supported.
composeApp/src/commonMain/kotlin/tech/arnav/twofac/components/settings/SessionRetentionCard.kt Adds a dedicated settings card wrapper for the retention toggle.
composeApp/src/commonMain/composeResources/values/strings_settings.xml Adds user-facing copy for app-session vs browser-session retention.
composeApp/src/commonTest/kotlin/tech/arnav/twofac/session/SessionPasskeyCacheTest.kt Adds common tests validating retention helper behavior and cache semantics.
composeApp/src/commonTest/kotlin/tech/arnav/twofac/viewmodels/SettingsViewModelTest.kt Adds tests ensuring retention state is surfaced and toggling updates UI/manager behavior.
composeApp/src/wasmJsTest/kotlin/tech/arnav/twofac/session/BrowserSessionManagerTest.kt Adds browser tests for retained-cache hit vs unsupported-retention fallback behavior.
composeApp/src/iosTest/kotlin/tech/arnav/twofac/session/IosBiometricSessionManagerTest.kt Updates iOS tests to validate availability behavior and retention policy persistence.
composeApp/src/androidInstrumentedTest/kotlin/tech/arnav/twofac/session/AndroidBiometricSessionManagerInstrumentedTest.kt Extends Android instrumented tests to cover retention policy defaults + round-trip.
.agents/plans/42-cross-platform-session-auth-retention-plan.md Documents the design/rollout plan and acceptance criteria for the feature.

Comment on lines +5 to +10
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.await
import kotlinx.coroutines.launch
import tech.arnav.twofac.session.interop.BrowserExtensionSessionStorageClient
import tech.arnav.twofac.session.interop.ExtensionSessionStorageClient

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@championswimmer championswimmer merged commit 914cc46 into main May 31, 2026
4 checks passed
@championswimmer championswimmer deleted the arnav/fix-web-auth-extension branch May 31, 2026 16:44
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.

2 participants