critical priority medium complexity integration pending integration specialist Tier 2

Acceptance Criteria

BiometricAuthService exposes a method Future<BiometricAuthResult> authenticate({required String localizedReason}) that invokes the native biometric dialog
The method first calls checkBiometricCapability() and returns BiometricAuthUnavailable immediately if capability is not available, without invoking the dialog
On iOS, the native Face ID / Touch ID prompt is shown with the provided localizedReason string in the system dialog
On Android, the native BiometricPrompt dialog is shown with localizedReason
A successful biometric challenge returns BiometricAuthSuccess
User cancellation of the native dialog returns BiometricAuthCancelled
Failed biometric attempts (wrong fingerprint, face not recognized) after exceeding platform retry limit return BiometricAuthFailure with a sanitized errorMessage
When the platform signals fallback required (e.g., user taps 'Use PIN' on Android or 'Enter Password' on iOS), the method returns BiometricAuthFallbackRequired
The method passes useErrorDialogs: true so the OS handles retry UX natively without custom Flutter UI
The method passes stickyAuth: true so the prompt persists if the app loses and regains focus (e.g., notification pull-down)
PlatformException from flutter_local_auth is caught and mapped to BiometricAuthFailure with a sanitized message
localizedReason string supports Norwegian ('Bekreft identiteten din') and English locales based on device locale at call time
The method is guarded against concurrent calls — if an authentication is already in progress, a second call returns BiometricAuthFailure immediately

Technical Requirements

frameworks
Flutter
flutter_local_auth (flutter_local_auth_android + flutter_local_auth_darwin)
BLoC (flutter_bloc)
apis
flutter_local_auth: LocalAuthentication.authenticate()
flutter_local_auth: AuthenticationOptions
performance requirements
Dialog invocation latency (from method call to native dialog appearing) under 300ms on iOS and Android
No network calls during biometric authentication — entirely on-device
security requirements
Biometric data never leaves the device — OS returns only a boolean pass/fail; app never receives raw biometric data
Authentication result (BiometricAuthSuccess) must be consumed immediately by the caller to gate a session token refresh — do not cache the result
Cannot be used as the sole authentication mechanism for initial login — must only re-authenticate an existing Supabase session (BankID/Vipps primary auth required first)
JWTs resulting from biometric re-authentication stored in flutter_secure_storage (iOS Keychain / Android Keystore), never in plain SharedPreferences
Concurrent call guard prevents race conditions that could bypass authentication check
localizedReason strings must not contain sensitive context that could inform a shoulder-surfing attacker

Execution Context

Execution Tier
Tier 2

Tier 2 - 518 tasks

Can start after Tier 1 completes

Implementation Notes

Use an _isAuthenticating boolean flag (or a Completer) to implement the concurrent call guard. Set it to true at the start of authenticate() and reset in a try/finally block. Pass AuthenticationOptions(stickyAuth: true, useErrorDialogs: true, biometricOnly: true) — biometricOnly: true ensures the OS does not offer a PIN/password fallback dialog itself (the Flutter app handles fallback via BiometricAuthFallbackRequired instead, giving full control of the UX). Map PlatformException error codes to BiometricAuthResult subtypes using flutter_local_auth's documented error codes: 'NotAvailable', 'NotEnrolled', 'LockedOut', 'PermanentlyLockedOut', 'UserCancel', 'UserFallback'.

Log unknown error codes to a diagnostic service (not analytics) with the sanitized message only. For localizedReason, accept it as a parameter rather than hardcoding — the caller (BLoC) retrieves it from the app's AppLocalizations. The biometric dialog invocation integrates with the Supabase session re-authentication flow: on BiometricAuthSuccess, the caller should call supabase.auth.refreshSession() to validate the session is still active before granting access.

Testing Requirements

Unit tests in test/services/biometric_auth_service_test.dart extending the test file from task-002. New test cases: (1) authenticate() when checkBiometricCapability returns unavailable → returns BiometricAuthUnavailable without calling authenticate on LocalAuthIntegration; (2) LocalAuthIntegration.authenticate returns true → BiometricAuthSuccess; (3) LocalAuthIntegration.authenticate returns false with cancellation error code → BiometricAuthCancelled; (4) LocalAuthIntegration.authenticate returns false with locked-out error code → BiometricAuthFailure; (5) LocalAuthIntegration.authenticate signals fallback (passcodeNotSet / notAvailable error codes) → BiometricAuthFallbackRequired; (6) concurrent call guard: second call while first is in-progress returns BiometricAuthFailure immediately without calling LocalAuthIntegration again. All tests use MockLocalAuthIntegration. Manual testing required on physical iOS device (Face ID and Touch ID) and physical Android device (fingerprint) — document results in test checklist.

Component
Biometric Authentication Service
service medium
Epic Risks (3)
high impact medium prob technical

Multiple concurrent callers (e.g., SessionResumeManager and a background sync service) could simultaneously detect a near-expired token and each invoke SupabaseSessionManager.refreshSession(), causing duplicate refresh API calls and potentially a token invalidation race condition on the Supabase Auth server. This can result in one caller receiving a valid refreshed token while another receives a 401, causing intermittent authentication failures.

Mitigation & Contingency

Mitigation: Implement a single-flight pattern inside SupabaseSessionManager so that concurrent refresh calls coalesce into one in-flight request. Use a Dart Completer or AsyncMemoizer to ensure all waiters receive the same refreshed token. Write a concurrent integration test to validate the single-flight behaviour.

Contingency: If the single-flight pattern introduces deadlocks or timeout complexity, fall back to a mutex-based lock with a 10-second timeout, logging a warning if the lock is held longer than expected, and triggering a full re-login if the refresh ultimately fails.

high impact low prob security

Supabase Row-Level Security policies evaluate the JWT claims (user_id, role, org_id) on every query. If the refreshed token contains stale or changed claims — for example if a coordinator's role was updated server-side — RLS may silently block data access even though the session appears valid from the client's perspective, causing confusing empty screens rather than an authentication error.

Mitigation & Contingency

Mitigation: After every token refresh, decode the new JWT and compare key claims (role, org_id) with the cached values. If claims have changed, emit a session-claims-changed event that triggers a role re-resolution and navigation reset. Document this behaviour in the SupabaseSessionManager API contract.

Contingency: If claims drift is detected in production and causes data visibility issues, provide a force-refresh mechanism in the UI (pull-to-refresh on home screen) that clears cached role state and re-fetches from Supabase, accompanied by a user-visible toast indicating the session was refreshed.

medium impact medium prob security

Allowing session resumption from cached local token when offline introduces a window where a revoked or invalidated session can still grant app access. For example, if a coordinator deactivates a peer mentor's account while the mentor is offline, the mentor continues to have access until connectivity is restored and the token is validated server-side.

Mitigation & Contingency

Mitigation: Set a maximum offline grace period (e.g., 24 hours) stored alongside the token in SecureSessionStorage. If the grace period is exceeded, force a full credential re-login regardless of connectivity status. Scope offline access to read-only operations only, requiring connectivity for any write that reaches Supabase.

Contingency: If the offline grace period logic is found to be insufficient for compliance, implement remote session invalidation via a lightweight push notification that clears SecureSessionStorage even when the app is backgrounded, using FCM with a data-only message.