diff --git a/Core b/Core index ad73bfdc..76e4cc18 160000 --- a/Core +++ b/Core @@ -1 +1 @@ -Subproject commit ad73bfdcca0864839e7f17a879336e29fc15c77a +Subproject commit 76e4cc183acc4e76513b5eb2ea075aa9a2ab5cef diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt index 727c0622..91a9f96c 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt @@ -135,6 +135,7 @@ fun ActionRequiredCard( } } is Account.Status.LoggedIn, + is Account.Status.NotConnected.Disconnected, is Account.Status.NotConnected.AttemptingToConnect -> Unit } } diff --git a/multiplatform-lib/build.gradle.kts b/multiplatform-lib/build.gradle.kts index 9c56a7b9..e4b57d09 100644 --- a/multiplatform-lib/build.gradle.kts +++ b/multiplatform-lib/build.gradle.kts @@ -63,6 +63,7 @@ kotlin { implementation(core.kotlinx.serialization.json) implementation(core.kotlinx.serialization.cbor) implementation(core.ktor.client.core) + implementation(core.ktor.client.auth) implementation(core.ktor.client.content.negociation) implementation(core.ktor.client.json) implementation(core.ktor.client.encoding) diff --git a/multiplatform-lib/src/commonMain/kotlin/Account.kt b/multiplatform-lib/src/commonMain/kotlin/Account.kt index e0b3cedc..05e88fd5 100644 --- a/multiplatform-lib/src/commonMain/kotlin/Account.kt +++ b/multiplatform-lib/src/commonMain/kotlin/Account.kt @@ -60,6 +60,12 @@ data class Account( val isSendingCredentials: Boolean get() = sendCredentials == null } + /** + * **IMPORTANT:** Make sure to remove the user from the app's user db BEFORE calling [removeAccount] from here, + * so the operation is recoverable in all possible edge cases (like the app process dying). + */ + data class Disconnected(val removeAccount: () -> Unit) : NotConnected + data class LoginFailed(val issue: Issue) : NotConnected } } diff --git a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt index 66cd67eb..be09cab2 100644 --- a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt +++ b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt @@ -18,12 +18,15 @@ package com.infomaniak.auth.lib import com.infomaniak.auth.lib.internal.AuthenticatorFacadeImpl +import com.infomaniak.auth.lib.internal.db.AccountEntity import com.infomaniak.auth.lib.internal.db.getAccountsRoomDatabase +import com.infomaniak.auth.lib.internal.extensions.firstOrElse import com.infomaniak.auth.lib.internal.managers.AuthenticatorManager import com.infomaniak.auth.lib.internal.managers.MigrationManager import com.infomaniak.auth.lib.internal.network.ApiClientProvider import com.infomaniak.auth.lib.internal.network.ApiRoutes import com.infomaniak.auth.lib.internal.repositories.AccountsRepository +import com.infomaniak.auth.lib.internal.requests.AuthenticatorRequests import com.infomaniak.auth.lib.internal.requests.WebAuthnRequests import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge @@ -74,15 +77,14 @@ abstract class AuthenticatorFacade internal constructor() { scope: CoroutineScope = CoroutineScope(Dispatchers.Default), ): AuthenticatorFacade { val routes = ApiRoutes(apiHost) - val webAuthnRequests = WebAuthnRequests( - httpClient = ApiClientProvider( - scope = scope, - userAgent = userAgent, - routes = routes, - crashReport = crashReport, - ).httpClient, + val apiClientProvider = ApiClientProvider( + scope = scope, + userAgent = userAgent, routes = routes, + crashReport = crashReport, ) + val httpClient = apiClientProvider.httpClient + val webAuthnRequests = WebAuthnRequests(httpClient = httpClient, routes = routes) val accountsDatabase = getAccountsRoomDatabase(databaseNameOrPath) val accountsRepository = AccountsRepository(accountsDatabase) val authenticatorManager = AuthenticatorManager( @@ -95,9 +97,26 @@ abstract class AuthenticatorFacade internal constructor() { webAuthnRequests = webAuthnRequests, clientId = clientId, ) + val authenticatorRequests: AuthenticatorRequests by lazy { + AuthenticatorRequests( + createHttpClient = apiClientProvider::createHttpClient, + getTokenForUser = authenticatorBridge::getTokenFromDatabase, + refreshToken = { userId -> authenticatorManager.getToken(clientId, userId).firstOrElse { error(it) } }, + disconnectAccount = { userId -> + accountsDatabase.getDao().updateStatusForUser( + userId = userId, + newStatus = AccountEntity.Status.Disconnected + ) + }, + routes = routes, + accountsDao = accountsDatabase.getDao(), + coroutineScope = scope + ) + } return AuthenticatorFacadeImpl( accountsDatabase = accountsDatabase, clientId = clientId, + authenticatorRequests = authenticatorRequests, authenticatorManager = authenticatorManager, migrationManager = migrationManager, authenticatorBridge = authenticatorBridge, diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index d2fd8bb5..21ac492e 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -35,12 +35,12 @@ import com.infomaniak.auth.lib.internal.extensions.toAccount import com.infomaniak.auth.lib.internal.extensions.toAccountEntity import com.infomaniak.auth.lib.internal.managers.AuthenticatorManager import com.infomaniak.auth.lib.internal.managers.MigrationManager -import com.infomaniak.auth.lib.internal.utils.DynamicLazyMap +import com.infomaniak.auth.lib.internal.requests.AuthenticatorRequests import com.infomaniak.auth.lib.internal.utils.buildFlowWithElements +import com.infomaniak.auth.lib.internal.utils.dynamicLazyMapOfSharedFlow import com.infomaniak.auth.lib.internal.utils.launchRacer import com.infomaniak.auth.lib.internal.utils.race import com.infomaniak.auth.lib.internal.utils.raceOf -import com.infomaniak.auth.lib.internal.utils.sharedFlow import com.infomaniak.auth.lib.internal.utils.waitForComplete import com.infomaniak.auth.lib.internal.utils.withTimeoutOrNull import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile @@ -80,11 +80,12 @@ import kotlin.time.Duration.Companion.seconds internal class AuthenticatorFacadeImpl( accountsDatabase: AccountsDatabase, private val clientId: String, + private val authenticatorRequests: AuthenticatorRequests, private val authenticatorManager: AuthenticatorManager, private val migrationManager: MigrationManager, private val authenticatorBridge: AuthenticatorBridge, private val crashReport: CrashReportInterface, - private val coroutineScope: CoroutineScope, + coroutineScope: CoroutineScope, ) : AuthenticatorFacade() { private val dao = accountsDatabase.getDao() @@ -99,8 +100,7 @@ internal class AuthenticatorFacadeImpl( entities.any { entity -> entity.isLoggedIn } }.distinctUntilChanged().shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 1) - private val userIdsToStatusFlows = DynamicLazyMap.sharedFlow( - coroutineScope = coroutineScope, + private val userIdsToStatusFlows = coroutineScope.dynamicLazyMapOfSharedFlow( cacheManager = { _, _ -> delay(5.seconds) // Should be more than enough to keep the state between re-uses. } @@ -233,6 +233,9 @@ internal class AuthenticatorFacadeImpl( Status.LoggedIn, Status.PasswordChanged -> { handledLoggedInState(entity) } + Status.Disconnected -> { + handleDisconnectedState(entity) + } null -> Unit // Should not happen in practice. } } @@ -438,18 +441,25 @@ internal class AuthenticatorFacadeImpl( updateUserProfileLoop(account) } + private suspend fun FlowCollector.handleDisconnectedState(account: AccountEntity) { + waitForComplete { disconnectionRequest -> + val status = Account.Status.NotConnected.Disconnected(disconnectionRequest::complete) + emit(status) + } + authenticatorManager.removeAccount(token = null, userId = account.id) + } + private suspend fun updateUserProfileLoop(account: AccountEntity) { require(account.isLoggedIn) - val token = authenticatorBridge.getTokenFromDatabase(account.id) ?: return while (true) { runCatching { - val profile = authenticatorManager.getUserProfile(token.accessToken) - val profileSecurity = profile.preferences.security ?: return + val profile = authenticatorRequests.getUserProfile(account.id) + val profileSecurity = requireNotNull(profile.preferences.security) val newStatus = when (account.lastPasswordUpdate) { profileSecurity.dateLastChangedPassword -> account.status else -> Status.PasswordChanged } - val updatedAccount = account.copy(status = newStatus, securityScore = profileSecurity.score) + val updatedAccount = profile.toAccountEntity(status = newStatus) if (updatedAccount != account) { // Avoid re-trigger loops when we're up to date. dao.upsert(updatedAccount) } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt index 291af3ce..c6bdf859 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt @@ -62,5 +62,7 @@ internal data class AccountEntity( DeletingOldKeyAfterRestoration, PasswordChanged, + + Disconnected, } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt index b30ea05a..e27752f8 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt @@ -44,6 +44,9 @@ internal interface AccountsDao { @Query("UPDATE AccountEntity SET status = :newStatus WHERE status = :currentStatus") suspend fun updateStatus(currentStatus: AccountEntity.Status, newStatus: AccountEntity.Status) + @Query("UPDATE AccountEntity SET status = :newStatus WHERE id = :userId") + suspend fun updateStatusForUser(userId: Long, newStatus: AccountEntity.Status) + @Insert suspend fun insert(account: AccountEntity) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt index 98f44cf8..996494ad 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt @@ -85,6 +85,7 @@ internal class AccountRestorer( AccountEntity.Status.ToBeMigrated, AccountEntity.Status.PasskeyRegistrationPending, AccountEntity.Status.FirstPasskeyAuthenticationPending, + AccountEntity.Status.Disconnected, AccountEntity.Status.LoggedIn -> error("Unexpected status for restoration: $status") } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index 4fdc3916..fafd7f7d 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -124,12 +124,13 @@ internal class AuthenticatorManager( return Xor.First(apiToken) } - suspend fun removeAccount(token: String, userId: Long) { + suspend fun removeAccount(token: String?, userId: Long) { val passkeyId = keyPairManager.findKeyIdFor(MatchOn.UserId(userId)) if (passkeyId != null) { + val needsToRevokePasskey = token != null // If we have a passkey for this account, revoke it against the backend and delete it - webAuthnRequests.deletePasskeyIfExists(token, passkeyId) + if (needsToRevokePasskey) webAuthnRequests.deletePasskeyIfExists(token, passkeyId) val _ = keyPairManager.deleteKeysMatching(MatchOn.PasskeyId(passkeyId)) } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt index 51995cb9..3fe1b5e2 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt @@ -26,6 +26,7 @@ import com.infomaniak.auth.lib.network.interfaces.BreadcrumbType import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface import com.infomaniak.auth.lib.network.interfaces.CrashReportLevel import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.HttpResponseValidator import io.ktor.client.plugins.HttpTimeout @@ -79,7 +80,10 @@ internal class ApiClientProvider( val httpClient: suspend () -> HttpClient = { httpClientAsync.await() } private val httpClientAsync = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { createHttpClient() } - private fun createHttpClient() = HttpClient(getHttpClientEngine()) { + fun createHttpClient( + authenticationConfig: (HttpClientConfig<*>.() -> Unit)? = null + ) = HttpClient(getHttpClientEngine()) { + if (authenticationConfig != null) authenticationConfig() install(UserAgent) { agent = userAgent } @@ -109,17 +113,18 @@ internal class ApiClientProvider( } HttpResponseValidator { - validateResponse(::validateResponse) + validateResponse { validateResponse(it, skipUnauthorizedConversion = authenticationConfig != null) } handleResponseExceptionWithRequest(::handleResponseExceptionWithRequest) } } - private suspend fun validateResponse(response: HttpResponse) { + private suspend fun validateResponse(response: HttpResponse, skipUnauthorizedConversion: Boolean) { val requestContextId = response.getRequestContextId() val statusCode = response.status.value addSentryUrlBreadcrumb(response, statusCode, requestContextId) + if (skipUnauthorizedConversion && statusCode == 401) return // Let 401 bubble up to ktor auth unchanged. if (statusCode >= 300) { val bodyResponse = response.bodyAsText() val apiError = runCatching { diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/requests/AuthenticatorRequests.kt b/multiplatform-lib/src/commonMain/kotlin/internal/requests/AuthenticatorRequests.kt new file mode 100644 index 00000000..5b753189 --- /dev/null +++ b/multiplatform-lib/src/commonMain/kotlin/internal/requests/AuthenticatorRequests.kt @@ -0,0 +1,107 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.auth.lib.internal.requests + +import com.infomaniak.auth.lib.internal.db.AccountsDao +import com.infomaniak.auth.lib.internal.models.SuccessfulApiResponse +import com.infomaniak.auth.lib.internal.network.ApiRoutes +import com.infomaniak.auth.lib.internal.network.utils.decode +import com.infomaniak.auth.lib.internal.utils.dynamicLazyMap +import com.infomaniak.auth.lib.models.migration.SharedApiToken +import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile +import com.infomaniak.auth.lib.network.exceptions.ApiException +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.request.get +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job + +internal class AuthenticatorRequests( + private val createHttpClient: (userConfiguration: HttpClientConfig<*>.() -> Unit) -> HttpClient, + private val getTokenForUser: suspend (userId: Long) -> SharedApiToken?, + private val refreshToken: suspend (userId: Long) -> SharedApiToken, + private val disconnectAccount: suspend (userId: Long) -> Unit, + private val routes: ApiRoutes, + private val accountsDao: AccountsDao, + coroutineScope: CoroutineScope, +) { + + private val perUserHttpClient = coroutineScope.dynamicLazyMap( + cacheManager = { userId: Long, _ -> + accountsDao.getAccountAsFlow(userId).first { it == null } + } + ) { userId: Long -> + val parentJob = this.coroutineContext.job + async(Dispatchers.IO) { + createHttpClient { configureHttpClientForUser(userId) }.also { httpClient -> + parentJob.invokeOnCompletion { + httpClient.close() + httpClient.engine.close() + } + } + } + } + + suspend fun getUserProfile( + userId: Long, + ): SharedUserProfile { + val url = "${routes.userProfile()}&with=security" + + return httpClientForUser(userId).get(url).decode>().data + } + + private suspend fun httpClientForUser(userId: Long): HttpClient { + return perUserHttpClient.useElement(userId) { it.await() } + } + + private fun HttpClientConfig<*>.configureHttpClientForUser(userId: Long) { + install(Auth) { + bearer { + sendWithoutRequest { true } + refreshTokens { refreshTokenOrDisconnectAccount(userId) } + loadTokens { getTokenForUser(userId)?.toBearerTokens() } + } + } + } + + private suspend fun refreshTokenOrDisconnectAccount(userId: Long): BearerTokens? = try { + refreshToken(userId).toBearerTokens() + } catch (e: ApiException) { + if (e.statusCode == 401 || e.isBrokenInvalidPasskeyResponse()) { + disconnectAccount(userId) + null + } else throw e + } + + private fun ApiException.isBrokenInvalidPasskeyResponse(): Boolean { + //TODO[Authenticator-DONT-SHIP]: Remove this and its usage before public release. + return this is ApiException.ApiErrorException && statusCode == 422 && errorCode == "invalid_passkey" + } + + private fun SharedApiToken.toBearerTokens() = BearerTokens( + accessToken = accessToken, + refreshToken = null // Not needed, we're doing it with the passkey. + ) +} diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/utils/DynamicLazyMapOfSharedFlow.kt b/multiplatform-lib/src/commonMain/kotlin/internal/utils/DynamicLazyMapHelpers.kt similarity index 61% rename from multiplatform-lib/src/commonMain/kotlin/internal/utils/DynamicLazyMapOfSharedFlow.kt rename to multiplatform-lib/src/commonMain/kotlin/internal/utils/DynamicLazyMapHelpers.kt index 357b29dd..c1b0bc76 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/utils/DynamicLazyMapOfSharedFlow.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/utils/DynamicLazyMapHelpers.kt @@ -1,5 +1,5 @@ /* - * Infomaniak Authenticator - Android + * Infomaniak Core - Android * Copyright (C) 2025-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify @@ -22,6 +22,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.shareIn @@ -29,6 +30,8 @@ import kotlinx.coroutines.flow.shareIn /** * Helper to create a [DynamicLazyMap] of [SharedFlow]s with a [Flow] factory. * + * It's equivalent to [dynamicLazyMapOfSharedFlow]. + * * @see flowForKey */ internal fun DynamicLazyMap.Companion.sharedFlow( @@ -51,3 +54,44 @@ internal fun DynamicLazyMap>.flowForKey(key: K): Flow emitAll(sharedFlow) } } + +/** + * Creates a [DynamicLazyMap]. + * + * @see DynamicLazyMap + */ +internal fun CoroutineScope.dynamicLazyMap( + cacheManager: DynamicLazyMap.CacheManager? = null, + createElement: CoroutineScope.(K) -> E +): DynamicLazyMap { + return DynamicLazyMap( + cacheManager = cacheManager, + coroutineScope = this, + createElement = createElement + ) +} + +/** + * Helper to create a [DynamicLazyMap] of [SharedFlow]s with a [Flow] factory. + * + * It's equivalent to [sharedFlow]. + * + * @see flowForKey + */ +internal fun CoroutineScope.dynamicLazyMapOfSharedFlow( + cacheManager: DynamicLazyMap.CacheManager>? = null, + createFlow: CoroutineScope.(K) -> Flow, +): DynamicLazyMap> { + return DynamicLazyMap.sharedFlow( + cacheManager = cacheManager, + coroutineScope = this, + createFlow = createFlow + ) +} + +internal inline fun DynamicLazyMap>.combineFor( + keys: Set, + crossinline transform: suspend (Array) -> R +): Flow = flow { + useElements(keys) { emitAll(combine(it.values, transform)) } +}