high priority medium complexity infrastructure pending infrastructure specialist Tier 0

Acceptance Criteria

The BiometricAuthService exposes an async `checkBiometricCapability()` method returning a BiometricCapability model within 200ms on first call.
BiometricCapability.isAvailable is true only when: the device has enrolled biometrics, the device OS supports it, and no security policy blocks it.
BiometricCapability.supportedTypes lists all available types from [BiometricType.face, BiometricType.fingerprint, BiometricType.iris] as detected by local_auth.
BiometricCapability.canCheckBiometrics reflects local_auth's canCheckBiometrics result and is false when the device has no biometric hardware.
On iOS, Face ID usage description must be present in Info.plist (NSFaceIDUsageDescription); failure to declare it must be caught during build, not runtime.
On Android, the service correctly detects strong biometrics (Class 3) and does not fall back to weak biometrics (Class 1/2) for authentication decisions.
When biometrics are unavailable (no hardware, not enrolled, policy block), the BiometricCapability.unavailableReason field is populated with a human-readable enum value.
The service is injectable via Riverpod as a singleton provider and is mockable in tests via an abstract interface.
Platform exceptions from local_auth (PlatformException) are caught and mapped to BiometricCapabilityError — they must never propagate as unhandled exceptions to the UI.
The capability check result is cached for the session; subsequent calls within the same app session return the cached value unless explicitly refreshed.

Technical Requirements

frameworks
Flutter
Riverpod
Dart
local_auth (Flutter plugin)
apis
local_auth LocalAuthentication.canCheckBiometrics
local_auth LocalAuthentication.getAvailableBiometrics()
local_auth LocalAuthentication.isDeviceSupported()
data models
BiometricCapability (isAvailable: bool, canCheckBiometrics: bool, supportedTypes: List<BiometricType>, unavailableReason: BiometricUnavailableReason?)
BiometricUnavailableReason (enum: notEnrolled, noHardware, lockedOut, passcodeNotSet, policyBlock, unknown)
performance requirements
Capability check must complete within 200ms; it is called on the enrollment screen load path
Cache the result in a Riverpod StateProvider to avoid repeated platform channel calls
security requirements
Do not use biometricOnly: false in authentication calls — device PIN fallback is controlled by the enrollment policy, not this service
NSFaceIDUsageDescription must be declared in Info.plist before App Store submission
Biometric availability check must not trigger any biometric prompt — it is a passive capability query only
Ensure Android uses BiometricManager.BIOMETRIC_STRONG for capability checks, not weak authenticators

Execution Context

Execution Tier
Tier 0

Tier 0 - 440 tasks

Implementation Notes

Create an abstract `IBiometricAuthService` interface first to enable clean mocking in tests and higher layers. Implement `BiometricAuthServiceImpl` that wraps `LocalAuthentication`. In `pubspec.yaml`, add `local_auth: ^2.x.x`. Add `NSFaceIDUsageDescription` to `ios/Runner/Info.plist` during this task — do not defer.

For Android, add `` to AndroidManifest.xml and ensure `android:minSdkVersion` is 23+. Register as a Riverpod `Provider` with an override for tests. Use `getAvailableBiometrics()` to populate `supportedTypes` and `canCheckBiometrics()` for the isAvailable flag. Map all `PlatformException` codes to the `BiometricUnavailableReason` enum using a switch expression.

Cache the result in a `StateProvider` invalidated on app resume.

Testing Requirements

Write unit tests using a mock LocalAuthentication interface: (1) device with Face ID enrolled → BiometricCapability.isAvailable true, supportedTypes contains BiometricType.face; (2) device with no biometric hardware → isAvailable false, unavailableReason == noHardware; (3) device with hardware but no enrolled biometrics → isAvailable false, unavailableReason == notEnrolled; (4) PlatformException thrown by local_auth → mapped to BiometricCapabilityError, no crash; (5) cached result is returned on second call without re-invoking platform channel. Write widget integration tests on a real iOS simulator to verify Face ID permission flow and Info.plist presence. Target 85% coverage on the service class.

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.