Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ fun ActionRequiredCard(
}
}
is Account.Status.LoggedIn,
is Account.Status.NotConnected.Disconnected,
is Account.Status.NotConnected.AttemptingToConnect -> Unit
}
}
Expand Down
1 change: 1 addition & 0 deletions multiplatform-lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions multiplatform-lib/src/commonMain/kotlin/Account.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
33 changes: 26 additions & 7 deletions multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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.
}
Expand Down Expand Up @@ -233,6 +233,9 @@ internal class AuthenticatorFacadeImpl(
Status.LoggedIn, Status.PasswordChanged -> {
handledLoggedInState(entity)
}
Status.Disconnected -> {
handleDisconnectedState(entity)
}
null -> Unit // Should not happen in practice.
}
}
Expand Down Expand Up @@ -438,18 +441,25 @@ internal class AuthenticatorFacadeImpl(
updateUserProfileLoop(account)
}

private suspend fun FlowCollector<Account.Status.NotConnected.Disconnected>.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
}
Comment thread
LouisCAD marked this conversation as resolved.
val updatedAccount = account.copy(status = newStatus, securityScore = profileSecurity.score)
val updatedAccount = profile.toAccountEntity(status = newStatus)
Comment thread
LouisCAD marked this conversation as resolved.
if (updatedAccount != account) { // Avoid re-trigger loops when we're up to date.
dao.upsert(updatedAccount)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,7 @@ internal data class AccountEntity(
DeletingOldKeyAfterRestoration,

PasswordChanged,

Disconnected,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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 ->
Comment thread
LouisCAD marked this conversation as resolved.
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<SuccessfulApiResponse<SharedUserProfile>>().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.
)
}
Loading
Loading