diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt index 11c318fc6..169f2c6f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt @@ -7,6 +7,7 @@ import com.nuvio.app.core.storage.LocalAccountDataCleaner import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.providers.builtin.Email import io.github.jan.supabase.auth.status.SessionStatus +import io.github.jan.supabase.exceptions.RestException import io.github.jan.supabase.functions.functions import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -32,6 +33,7 @@ object AuthRepository { val error: StateFlow = _error.asStateFlow() private var initialized = false + private var validatedRemoteUserId: String? = null fun initialize() { if (initialized) return @@ -40,6 +42,7 @@ object AuthRepository { scope.launch { SyncBackendRepository.state.collectLatest { backendState -> if (!backendState.isLoaded) return@collectLatest + validatedRemoteUserId = null AuthStorage.loadAnonymousUserId()?.let { savedAnonId -> _state.value = AuthState.Authenticated( @@ -56,8 +59,10 @@ object AuthRepository { when (status) { is SessionStatus.Authenticated -> { val user = status.session.user + val userId = user?.id.orEmpty() + if (!validateRemoteSession(userId)) return@collect _state.value = AuthState.Authenticated( - userId = user?.id ?: "", + userId = userId, email = user?.email, isAnonymous = false, ) @@ -79,6 +84,25 @@ object AuthRepository { } } + private suspend fun validateRemoteSession(userId: String): Boolean { + if (userId.isBlank() || validatedRemoteUserId == userId) return true + + return runCatching { + SupabaseProvider.client.auth.retrieveUserForCurrentSession(false) + validatedRemoteUserId = userId + true + }.getOrElse { e -> + if (isInvalidRemoteSessionError(e)) { + log.w(e) { "Stored Supabase session no longer belongs to an active account; clearing local auth" } + clearLocalSessionAfterRemoteInvalidation() + false + } else { + log.w(e) { "Unable to validate stored Supabase session; keeping cached auth state" } + true + } + } + } + @OptIn(ExperimentalUuidApi::class) fun signInAnonymously() { _error.value = null @@ -118,6 +142,7 @@ object AuthRepository { _error.value = null val wasAnonymous = AuthStorage.loadAnonymousUserId() != null AuthStorage.clearAnonymousUserId() + validatedRemoteUserId = null if (!wasAnonymous) { SupabaseProvider.client.auth.signOut() } @@ -128,10 +153,32 @@ object AuthRepository { _error.value = e.message ?: getString(Res.string.auth_sign_out_failed) } + suspend fun signOutIfSessionInvalid(error: Throwable, source: String): Boolean { + if (!isInvalidRemoteSessionError(error)) return false + + log.w(error) { "$source failed because the current Supabase account/session is no longer valid; clearing local auth" } + clearLocalSessionAfterRemoteInvalidation() + return true + } + + private suspend fun clearLocalSessionAfterRemoteInvalidation() { + _error.value = null + AuthStorage.clearAnonymousUserId() + validatedRemoteUserId = null + runCatching { + SupabaseProvider.client.auth.clearSession() + }.onFailure { e -> + log.w(e) { "Failed to clear Supabase session after remote invalidation; continuing local reset" } + } + _state.value = AuthState.Unauthenticated + LocalAccountDataCleaner.wipe() + } + suspend fun resetForSyncBackendChange(): Result = runCatching { _error.value = null val wasAnonymous = AuthStorage.loadAnonymousUserId() != null AuthStorage.clearAnonymousUserId() + validatedRemoteUserId = null if (!wasAnonymous) { runCatching { @@ -152,6 +199,8 @@ object AuthRepository { _error.value = null SupabaseProvider.client.functions.invoke("delete-account") SupabaseProvider.client.auth.signOut() + validatedRemoteUserId = null + _state.value = AuthState.Unauthenticated LocalAccountDataCleaner.wipe() }.onFailure { e -> log.e(e) { "Account deletion failed" } @@ -161,4 +210,39 @@ object AuthRepository { fun clearError() { _error.value = null } + + private fun isInvalidRemoteSessionError(error: Throwable): Boolean { + val restError = error.findCause() + if (restError?.statusCode == 401 || restError?.statusCode == 403) return true + + val message = buildString { + append(error.message.orEmpty()) + if (restError != null) { + append(' ') + append(restError.error) + append(' ') + append(restError.description) + } + }.lowercase() + + return ( + "jwt" in message && + ("invalid" in message || "expired" in message || "malformed" in message) + ) || ( + "user" in message && + ("does not exist" in message || "not found" in message || "deleted" in message) + ) || ( + "foreign key" in message && + ("auth.users" in message || "user_id" in message) + ) + } + + private inline fun Throwable.findCause(): T? { + var current: Throwable? = this + while (current != null) { + if (current is T) return current + current = current.cause + } + return null + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 7644ec63d..cbc1c5545 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -120,7 +120,7 @@ object ProfileRepository { } return } - runCatching { + try { val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profiles") val profiles = result.decodeList() _state.value = _state.value.copy( @@ -133,7 +133,8 @@ object ProfileRepository { activeProfileIndex = _state.value.activeProfile!!.profileIndex } persist() - }.onFailure { e -> + } catch (e: Throwable) { + if (AuthRepository.signOutIfSessionInvalid(e, "Profile pull")) return log.e(e) { "Failed to pull profiles" } if (!_state.value.isLoaded) { _state.value = _state.value.copy(isLoaded = true) @@ -182,13 +183,14 @@ object ProfileRepository { applyPayloadsLocally(profiles) return } - runCatching { + try { val params = buildJsonObject { put("p_profiles", json.encodeToJsonElement(profiles)) } SupabaseProvider.client.postgrest.rpc("sync_push_profiles", params) pullProfiles() - }.onFailure { e -> + } catch (e: Throwable) { + if (AuthRepository.signOutIfSessionInvalid(e, "Profile push")) return log.e(e) { "Failed to push profiles" } } } @@ -273,11 +275,12 @@ object ProfileRepository { persist() return } - runCatching { + try { val params = buildJsonObject { put("p_profile_id", profileIndex) } SupabaseProvider.client.postgrest.rpc("sync_delete_profile_data", params) pullProfiles() - }.onFailure { e -> + } catch (e: Throwable) { + if (AuthRepository.signOutIfSessionInvalid(e, "Profile delete")) return log.e(e) { "Failed to delete profile $profileIndex" } } }