high priority medium complexity frontend pending frontend specialist Tier 1

Acceptance Criteria

The screen is only reachable if BiometricAuthService.isEnrolledUser() returns true, indicating a prior successful BankID or Vipps login has been recorded
On screen load, the biometric type is detected (Face ID vs fingerprint) and the branded prompt copy adjusts accordingly (e.g., 'Sign in with Face ID' vs 'Sign in with fingerprint')
An explanatory message is displayed before the OS prompt is triggered, following WCAG 2.2 AA requirements for descriptive text
The OS-native biometric dialog is triggered automatically on screen entry (after a short delay for branded prompt display), without requiring a secondary button tap
Successful biometric authentication calls BiometricAuthService.authenticate() and navigates the user to the main app screen
If biometric authentication fails (wrong finger/face), the OS retry flow is used for the configured number of attempts before the screen shows a fallback option
After the OS-enforced maximum failed attempts, the screen shows a 'Use BankID or Vipps instead' fallback button that navigates to the method selector
User can proactively choose 'Use a different sign-in method' to skip biometric and return to the method selector
The screen handles the case where biometrics are removed from the OS after enrollment (BiometricAuthService detects unavailability) and redirects to the method selector with an explanatory message
All prompts and buttons meet WCAG 2.2 AA contrast and touch-target requirements
The screen does not cache or store any biometric data — authentication is delegated entirely to the OS via local_auth

Technical Requirements

frameworks
Flutter
Riverpod
local_auth (Flutter plugin)
apis
local_auth: authenticate(), getAvailableBiometrics(), isDeviceSupported()
BiometricAuthService (app-level service wrapping local_auth)
Supabase auth session refresh on successful biometric re-authentication
data models
BiometricAuthState (checkingAvailability | awaitingPrompt | authenticating | success | failed | unavailable | fallback)
BiometricType (faceId | fingerprint | none)
BiometricEnrollmentRecord (stored in flutter_secure_storage: userId, enrolledAt, lastUsedAt)
performance requirements
Biometric type detection must complete before the first frame renders to prevent copy flicker
OS biometric dialog should be triggered within 500 ms of screen entry to minimize wait
Navigation on success should be immediate (< 100 ms from authentication result)
security requirements
Biometric authentication must only unlock an existing session — it must NOT create a new session or bypass BankID/Vipps identity verification
The stored enrollment record in flutter_secure_storage must include the userId so that if the device user changes, the biometric session is invalidated
local_auth must use biometricOnly: false as the fallback strategy — allow OS fallback (PIN/passcode) rather than blocking the user entirely, then validate the result appropriately
Biometric re-authentication must trigger a Supabase session refresh call to extend the session server-side
If the device has been rebooted since last biometric use, local_auth will require device credentials — handle this case gracefully
ui components
BiometricIconWidget (Face ID or fingerprint icon sourced from platform assets or design system)
Explanatory text widget with biometric type-specific copy
Secondary text button: 'Use a different sign-in method'
Error/fallback state container with 'Use BankID or Vipps instead' button
Semantics labels on all elements with biometric type name included

Execution Context

Execution Tier
Tier 1

Tier 1 - 540 tasks

Can start after Tier 0 completes

Implementation Notes

Guard the route to this screen in the router configuration: use a redirect that checks BiometricAuthService.isEnrolledUser() and reroutes to the method selector if false. This prevents direct navigation to the biometric screen by non-enrolled users. The local_auth.authenticate() call must include localizedReason: 'Sign in to [App Name]' — this string appears in the OS prompt and must be concise and clear. Trigger the OS dialog in initState via a post-frame callback to ensure the branded screen renders first: WidgetsBinding.instance.addPostFrameCallback((_) => _triggerBiometric()).

Handle the case where the user is on a device that does not support biometrics by checking local_auth.isDeviceSupported() on screen entry and immediately navigating to the method selector. The BiometricEnrollmentRecord stored in flutter_secure_storage should be written after the first successful BankID or Vipps login (in those respective services), not in this screen — this screen only reads the record to gate access. When biometrics become unavailable after the screen is shown (race condition on older devices), catch the PlatformException from local_auth and transition to the unavailable state gracefully. Do not implement custom retry logic — rely on the OS-provided retry flow from local_auth, which handles the OS-enforced attempt limits.

Testing Requirements

Unit tests for BiometricAuthService: (1) isEnrolledUser() returns false when no enrollment record exists; (2) authenticate() calls local_auth.authenticate() with correct parameters; (3) detects Face ID vs fingerprint from getAvailableBiometrics() response; (4) handles LocalAuthException with correct BiometricAuthState transition. Widget tests using mocked local_auth: (1) assert biometric type-specific copy is rendered correctly for Face ID and fingerprint scenarios; (2) simulate successful authentication → assert navigation to main screen; (3) simulate failed authentication → assert fallback UI appears; (4) tap 'Use a different sign-in method' → assert navigation back to method selector; (5) simulate unavailability (biometrics removed) → assert explanatory message and redirect. Platform permission tests: assert NSFaceIDUsageDescription key is present in iOS Info.plist. Manual device testing: test on physical iPhone with Face ID, physical iPhone with Touch ID, and physical Android with fingerprint reader.

Component
Biometric Authentication Screen
ui medium
Epic Risks (3)
high impact medium prob technical

BankID on mobile uses a WebView or external app redirect that has known compatibility issues with Flutter's WebView package on certain Android versions. BankID's JavaScript-heavy broker pages may also trigger CSP or mixed-content errors in a Flutter WebView, preventing the authentication flow from completing.

Mitigation & Contingency

Mitigation: Use the flutter_inappwebview package (more mature than webview_flutter for complex OAuth pages) and validate BankID WebView rendering on the broker's test environment before integrating with the service layer. Prefer external browser redirect where the broker supports it.

Contingency: If WebView approach fails for certain BankID brokers, implement the full external browser redirect + deep link callback pattern as the primary flow and treat WebView as a fallback only.

medium impact medium prob technical

The OAuth redirect flows (both Vipps and BankID) temporarily move the user outside the Flutter app into an external browser or the Vipps/BankID app. Screen reader users may lose focus context during this transition and become disoriented when the app callback returns them to the loading state, failing the WCAG 2.2 AA mandate.

Mitigation & Contingency

Mitigation: Implement explicit accessibility announcements (live region announcements) at each transition point: when launching the external flow ('Opening Vipps'), during the loading wait state ('Waiting for Vipps confirmation'), and on return ('Login successful' or 'Login failed — please try again'). Test with VoiceOver on iOS and TalkBack on Android during development.

Contingency: If OAuth transition accessibility is unresolvable on a specific platform, add an explicit accessibility user guide in the onboarding flow explaining the external app redirect behavior to set user expectations.

low impact high prob technical

Biometric UI varies significantly across devices — Face ID (iPhone), fingerprint sensor (most Android), front-facing camera biometrics (some Android), and devices with no biometrics at all. Flutter's local_auth handles the OS dialog but the surrounding UI must gracefully handle all these cases, and testing coverage for all permutations is difficult.

Mitigation & Contingency

Mitigation: Use local_auth's getAvailableBiometrics() to detect the exact biometric type and render appropriate iconography (Face ID icon vs. fingerprint icon). For devices with no biometrics, skip the biometric screen entirely and route directly to full re-authentication.

Contingency: If a specific device configuration produces unexpected local_auth behavior in production, implement a user-accessible toggle in Settings to disable biometric login entirely, routing those users to the standard BankID/Vipps flow without biometrics.