critical priority medium complexity backend pending backend specialist Tier 2

Acceptance Criteria

Class UserIdentityRepository is defined in lib/core/auth/user_identity_repository.dart
upsertIdentity(UserIdentity identity) → Future<void> performs a Supabase upsert on user_identities using onConflict: 'user_id'
getIdentityByUserId(String userId) → Future<UserIdentity?> first checks local SecureStorageAdapter cache; on cache miss, fetches from Supabase and writes result to cache; returns null if no record exists
markBankIdVerified(String userId) → Future<void> calls a Supabase Edge Function or RPC to set bankid_verified = true and bankid_verified_at = now() server-side (not client-side UPDATE to restrict column access)
storePersonnummer(String userId, String personnummer) → Future<void> encrypts personnummer before writing to Supabase and stores an encrypted local copy in SecureStorageAdapter
purgeLocalIdentityData() → Future<void> deletes all user_identity-related keys from SecureStorageAdapter; must be called on logout
UserIdentity value/entity class is defined with all fields matching the Supabase schema: id, userId, personnummer (nullable), bankidVerified, bankidVerifiedAt (nullable DateTime), vippsSub (nullable), createdAt, updatedAt
SupabaseException is caught and re-thrown as a typed IdentityRepositoryException with a user-safe message
Unit tests cover: getIdentityByUserId returns cached value on second call without hitting Supabase; upsertIdentity writes to Supabase and updates cache; purgeLocalIdentityData clears all cached keys; network error is wrapped in IdentityRepositoryException
UserIdentityRepository is exposed as a Riverpod Provider<UserIdentityRepository>
dart analyze passes with zero issues

Technical Requirements

frameworks
Flutter
Riverpod
Supabase
flutter_test
apis
Supabase PostgREST (user_identities table)
Supabase Edge Functions or RPC (markBankIdVerified)
SecureStorageAdapter (internal cache)
data models
UserIdentity
user_identities (Supabase table)
IdentityRepositoryException
performance requirements
Cache-first reads: getIdentityByUserId must not make a network call if a valid cached record exists
Cache TTL: cached identity is considered stale after 15 minutes — re-fetch from Supabase after TTL expiry
Supabase queries must select only required columns — do not use select('*') for records containing personnummer
security requirements
personnummer must be encrypted at the application layer before any write to Supabase — use the public key from Supabase Vault or a project-level encryption key stored in app config
personnummer must not appear in application logs, error messages, or analytics events
The local SecureStorageAdapter cache of personnummer must use the same encryption — never cache personnummer as plaintext
bankid_verified and bankid_verified_at must only be set via server-side function (Edge Function or RPC) — the Flutter client must not directly UPDATE these columns
On logout, purgeLocalIdentityData must be called before clearing auth tokens to ensure identity data is not left in secure storage
GDPR compliance: if the user requests deletion, the backend must delete the user_identities row and purgeLocalIdentityData must be called on the device

Execution Context

Execution Tier
Tier 2

Tier 2 - 518 tasks

Can start after Tier 1 completes

Implementation Notes

Repository constructor takes SupabaseClient and SecureStorageAdapter as dependencies for testability. Cache key format: 'identity.{userId}' using SecureStorageKey enum extension. Serialize UserIdentity to/from JSON for cache storage (jsonEncode / jsonDecode with DateTime.toIso8601String()). For markBankIdVerified: call supabase.rpc('mark_bankid_verified', params: {'target_user_id': userId}) — the PostgreSQL function should do a server-side UPDATE with SECURITY DEFINER to bypass RLS for this specific column update while the Edge Function or RPC verifies the BankID token.

Cache TTL: store a cache timestamp alongside the identity JSON in secure storage (key: 'identity.{userId}.cached_at') and compare against DateTime.now() on read. For the Riverpod provider, use Provider.autoDispose only if identity data should not be retained across navigations — prefer a long-lived Provider since identity is app-global. Implement UserIdentity.fromJson / toJson using a private method, not a generated file, to keep the dependency graph minimal.

Testing Requirements

Unit tests using flutter_test, FakeSecureStorageAdapter, and a mocked Supabase client (mocktail). Test file at test/core/auth/user_identity_repository_test.dart. Required scenarios: (1) getIdentityByUserId with empty cache calls Supabase exactly once and caches result; (2) getIdentityByUserId called again within TTL returns cached value and does NOT call Supabase; (3) upsertIdentity calls Supabase upsert and updates cache; (4) purgeLocalIdentityData after getIdentityByUserId results in next getIdentityByUserId calling Supabase again; (5) SupabaseException during getIdentityByUserId is wrapped and thrown as IdentityRepositoryException; (6) storePersonnummer writes encrypted value to both Supabase and cache. Integration test (manual): verify RLS by attempting to read another user's identity via Flutter app — confirm 0 rows returned.

Component
User Identity Repository
data medium
Epic Risks (3)
high impact medium prob technical

Flutter Secure Storage behavior differs between iOS Keychain and Android Keystore — key accessibility attributes (kSecAttrAccessibleWhenUnlocked vs. WhenUnlockedThisDeviceOnly) may cause tokens to become inaccessible after device restart or OS upgrade, breaking session restoration for returning users.

Mitigation & Contingency

Mitigation: Define explicit Keychain accessibility attributes during implementation and write integration tests on both platforms. Follow flutter_secure_storage documentation for cross-platform accessibility configuration.

Contingency: Implement a recovery flow that detects secure storage read failures and falls back to full re-authentication rather than crashing. Add a migration utility to re-write tokens with corrected attributes if a misconfiguration is discovered post-release.

high impact medium prob security

Personnummer is a legally sensitive national identifier under Norwegian GDPR implementation. If encryption-at-rest or data minimization requirements are not met before launch, the feature could be blocked by legal/compliance review from any of the four partner organizations.

Mitigation & Contingency

Mitigation: Ensure personnummer is only persisted after explicit user consent via the personnummer confirmation widget. Use Supabase column-level encryption for the personnummer field. Document the data processing basis and retention policy before the first TestFlight release.

Contingency: If legal review blocks the personnummer write-back, implement the feature as opt-in only with a deferred sync model, allowing BankID/Vipps login to proceed without storing the personnummer until compliance is confirmed.

medium impact high prob dependency

If the VippsOrgCostConfig data is not populated in Supabase for all four partner organizations before the feature ships, users from unconfigured organizations will see no Vipps login option and may report it as broken, creating confusion and support load.

Mitigation & Contingency

Mitigation: Create a seed migration script for Vipps org configuration and include it in the deployment checklist. Implement a clear admin UI warning when an organization is missing Vipps configuration.

Contingency: Add a feature flag in VippsOrgCostConfig so individual organizations can be enabled/disabled without a code deploy, allowing rapid remediation.