high priority medium complexity backend pending backend specialist Tier 6

Acceptance Criteria

The enrollment flow is only triggered after a first-time BankID or Vipps login — it must not trigger on subsequent logins where biometrics are already enrolled.
The enrollment prompt is shown only when BiometricCapability.isAvailable is true (from task-010); if biometrics are unavailable, the enrollment step is silently skipped.
When the user confirms enrollment, a flag `biometric_enrolled_{userId}` is written to flutter_secure_storage with value 'true'.
When the user declines enrollment, a flag `biometric_enrollment_declined_{userId}` is written to flutter_secure_storage so the prompt is not shown again in the same session.
The Supabase refresh token (already in Secure Storage from task-009) is explicitly verified to be present before completing enrollment — enrollment without a valid session token must fail.
The enrollment confirmation is scoped to the userId: enrolling as User A does not affect User B's enrollment state on a shared device.
The biometric enrollment does not re-prompt the user with a live biometric scan — it records the user's intent to use biometrics; the actual biometric challenge occurs at login (task-012).
If Secure Storage write fails during enrollment, the error is caught, enrollment state is left as 'not enrolled', and the user is not shown a broken state.
The BLoC emits BiometricEnrollmentComplete or BiometricEnrollmentDeclined correctly, allowing the navigation layer to proceed to the home screen.
A `isBiometricEnrolled(userId)` helper method returns the correct boolean by reading Secure Storage, used by task-012 to decide the session resumption path.

Technical Requirements

frameworks
Flutter
Riverpod
BLoC
Dart
flutter_secure_storage
apis
flutter_secure_storage FlutterSecureStorage.write()
flutter_secure_storage FlutterSecureStorage.read()
Supabase Auth (verify session token is present)
local_auth (capability check from task-010)
data models
BiometricEnrollmentState (notEnrolled, enrolled, declined, enrollmentError)
BiometricEnrollmentRecord (userId: String, enrolledAt: DateTime, deviceId: String)
SupabaseSession (accessToken, refreshToken) — read-only access from Secure Storage
performance requirements
Enrollment flag write to Secure Storage must complete within 300ms
The enrollment prompt must appear within 500ms of the post-login navigation event
security requirements
Secure Storage keys must be namespaced per userId to prevent cross-user data leakage on shared devices
The refresh token stored in Secure Storage must NOT be copied or moved during enrollment — it stays under its original key
flutter_secure_storage must use AES encryption on Android and Keychain on iOS — verify default options are sufficient
Enrollment flag must not contain sensitive identity data — only a boolean intent flag
On Android, set accessibleWhenUnlocked: false via IOSOptions if applicable to restrict access when device is locked
ui components
BiometricEnrollmentPromptBottomSheet (confirm/decline actions, biometric type icon)
BiometricEnrollmentSuccessBanner (brief success feedback)

Execution Context

Execution Tier
Tier 6

Tier 6 - 158 tasks

Can start after Tier 5 completes

Implementation Notes

Implement enrollment as a dedicated `enrollBiometrics(String userId)` method on `BiometricAuthServiceImpl`. Before showing the prompt, call `isBiometricEnrolled(userId)` — if already enrolled, return `BiometricEnrollmentState.enrolled` immediately. The BLoC should handle `EnrollBiometricsEvent` after the post-login navigation guard triggers. Use a consistent key scheme: `'biometric_enrolled_'` and `'biometric_declined_'`.

Do not use `userId` directly as part of a plaintext key if it contains sensitive info — hash the userId portion of the key with a stable hash (MD5 or SHA-1 is sufficient here since this is not a security secret, just a namespacing mechanism). The enrollment prompt UI (bottom sheet) should be triggered from the router's post-login redirect logic, not from within the service itself — keep the service pure and side-effect-free with respect to UI.

Testing Requirements

Unit tests for BiometricAuthService enrollment: (1) first-time login with biometrics available → prompt shown, user confirms → Secure Storage write called with correct key/value; (2) first-time login with biometrics available → user declines → declined flag written, no enrollment flag; (3) biometrics unavailable → enrollment silently skipped, no Secure Storage write; (4) Secure Storage write throws → error caught, state set to enrollmentError, no crash; (5) second login with enrolled flag present → enrollment prompt not shown; (6) isBiometricEnrolled returns true when flag is present and false when absent. Mock flutter_secure_storage using an in-memory map. Widget tests for the enrollment bottom sheet: confirm and decline paths trigger correct BLoC events.

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

The PKCE OAuth flow requires the code verifier to survive an app backgrounding during the Vipps redirect, which can trigger OS memory pressure and clear in-memory state. If the verifier is lost between authorization request and callback, the token exchange fails and the user is stranded with a confusing error.

Mitigation & Contingency

Mitigation: Store the PKCE code verifier in AuthTokenStore (Flutter Secure Storage) immediately after generation, before launching the Vipps redirect. Clear it only after a successful or explicitly failed token exchange.

Contingency: If state loss occurs in production, implement a retry flow that generates a new PKCE pair and restarts the authorization URL request, with a user-visible 'Try again' prompt rather than a generic error.

medium impact medium prob technical

Resuming a Supabase session after biometric verification requires the session token to still be valid. If the session has expired in the background (e.g., after a long device offline period), biometric success will not produce a valid session, and the user will see a confusing 'Face ID worked but still logged out' experience.

Mitigation & Contingency

Mitigation: Before presenting the biometric prompt, check session token expiry. If expired, skip biometrics and route directly to full BankID/Vipps re-authentication. Only offer biometric re-auth if the stored refresh token is still within its validity window.

Contingency: If session expiry during biometric flow occurs in production, implement a graceful transition message ('Your session has expired — please log in again') that preserves the user's last-used authentication method preference.

high impact medium prob integration

BankID and Vipps may return different user identifiers (personnummer, phone number, sub claim) that must be correctly linked to an existing Supabase auth user. If the linking logic has edge cases (e.g., user previously registered via email/password), duplicate Supabase accounts may be created.

Mitigation & Contingency

Mitigation: Design the identity linking logic with explicit disambiguation: check for existing users by personnummer before creating a new Supabase identity. Implement the linking via Supabase Edge Function to keep the logic server-side and auditable.

Contingency: Implement an admin-facing account merge tool in the admin portal to resolve duplicate accounts if they occur. Add a Supabase unique constraint on the personnummer field to make duplicates fail loudly rather than silently.

medium impact high prob dependency

The Vipps nin (personnummer) scope requires explicit approval from Vipps as part of the merchant agreement. If this scope approval is not in place before the production release, the Vipps flow will succeed but return no personnummer, making the primary business value (membership data gap fill) non-functional without user-visible error.

Mitigation & Contingency

Mitigation: Apply for Vipps nin scope approval as part of the merchant onboarding process, well before Phase 2 launch. Implement the service to gracefully handle absent nin claims and show users a clear message if personnummer could not be retrieved.

Contingency: If nin scope is delayed, ship the Vipps login flow without personnummer write-back first (delivering login value immediately) and add personnummer sync as a post-approval update with no UI changes required.