diff --git a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt index dbeb4bf2..66cd67eb 100644 --- a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt +++ b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt @@ -24,8 +24,7 @@ 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.repositories.WebAuthnRepository -import com.infomaniak.auth.lib.internal.requests.AuthenticatorRequest +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 import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface @@ -75,27 +74,25 @@ abstract class AuthenticatorFacade internal constructor() { scope: CoroutineScope = CoroutineScope(Dispatchers.Default), ): AuthenticatorFacade { val routes = ApiRoutes(apiHost) - val webAuthnRepository = WebAuthnRepository( - authenticatorRequest = AuthenticatorRequest( - httpClient = ApiClientProvider( - scope = scope, - userAgent = userAgent, - routes = routes, - crashReport = crashReport, - ).httpClient, + val webAuthnRequests = WebAuthnRequests( + httpClient = ApiClientProvider( + scope = scope, + userAgent = userAgent, routes = routes, - ) + crashReport = crashReport, + ).httpClient, + routes = routes, ) val accountsDatabase = getAccountsRoomDatabase(databaseNameOrPath) val accountsRepository = AccountsRepository(accountsDatabase) val authenticatorManager = AuthenticatorManager( - webAuthnRepository = webAuthnRepository, + webAuthnRequests = webAuthnRequests, accountsRepository = accountsRepository ).also { it.keyPairManager.ensureKeyPairsAreMoved() } val migrationManager = MigrationManager( accountsDatabase = accountsDatabase, authenticatorManager = authenticatorManager, - webAuthnRepository = webAuthnRepository, + webAuthnRequests = webAuthnRequests, clientId = clientId, ) return AuthenticatorFacadeImpl( diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt index b2067d4f..98f44cf8 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt @@ -21,13 +21,13 @@ import com.infomaniak.auth.lib.internal.KeyPairManager.MatchOn import com.infomaniak.auth.lib.internal.db.AccountEntity import com.infomaniak.auth.lib.internal.db.AccountsDatabase import com.infomaniak.auth.lib.internal.extensions.firstOrElse -import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository +import com.infomaniak.auth.lib.internal.requests.WebAuthnRequests import com.infomaniak.auth.lib.models.migration.SharedApiToken internal class AccountRestorer( accountsDatabase: AccountsDatabase, private val authenticatorManager: AuthenticatorManager, - private val webAuthnRepository: WebAuthnRepository, + private val webAuthnRequests: WebAuthnRequests, private val clientId: String, ) { @@ -51,7 +51,7 @@ internal class AccountRestorer( val previousRestorationAborted = existingKeyIds.size == 2 if (previousRestorationAborted) { val newKeyIdToDrop = existingKeyIds.last() - webAuthnRepository.deletePasskeyIfExists(tokenFromOldPasskey.accessToken, newKeyIdToDrop) + webAuthnRequests.deletePasskeyIfExists(tokenFromOldPasskey.accessToken, newKeyIdToDrop) val _ = keyPairManager.deleteKeysMatching(MatchOn.PasskeyId(newKeyIdToDrop)) } // Register a new passkey @@ -72,7 +72,7 @@ internal class AccountRestorer( persistToken(account.id, tokenWithNewPassKey) // We can safely delete the old passkey, as the new one is working and the old token won't be valid anymore oldKeyId?.let { keyId -> - webAuthnRepository.deletePasskeyIfExists(tokenWithNewPassKey.accessToken, keyId) + webAuthnRequests.deletePasskeyIfExists(tokenWithNewPassKey.accessToken, keyId) val _ = keyPairManager.deleteKeysMatching(MatchOn.PasskeyId(keyId)) } dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt index d5250990..4fdc3916 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AuthenticatorManager.kt @@ -26,7 +26,7 @@ import com.infomaniak.auth.lib.internal.models.ClientExtensionResults import com.infomaniak.auth.lib.internal.models.VerifyAuthenticationData import com.infomaniak.auth.lib.internal.models.VerifyResponse import com.infomaniak.auth.lib.internal.repositories.AccountsRepository -import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository +import com.infomaniak.auth.lib.internal.requests.WebAuthnRequests import com.infomaniak.auth.lib.internal.utils.SignUtils import com.infomaniak.auth.lib.internal.utils.Xor import com.infomaniak.auth.lib.models.migration.SharedApiToken @@ -35,7 +35,7 @@ import kotlinx.serialization.json.Json import okio.ByteString.Companion.toByteString internal class AuthenticatorManager( - private val webAuthnRepository: WebAuthnRepository, + private val webAuthnRequests: WebAuthnRequests, private val accountsRepository: AccountsRepository, ) { @@ -44,10 +44,10 @@ internal class AuthenticatorManager( private val base64NoPadding get() = cryptoObjectsBuilder.base64UrlSafeNoPadding - suspend fun getUserProfile(token: String) = webAuthnRepository.getUserProfile(token) + suspend fun getUserProfile(token: String) = webAuthnRequests.getUserProfile(token) suspend fun registerPasskey(token: String, userId: Long): String { - val passkeysOptions = webAuthnRepository.getPasskeysOptions(token).data + val passkeysOptions = webAuthnRequests.getPasskeysOptions(token) val keyIds = cryptoObjectsBuilder.getKeyIds() val keyIdAsByteArray = keyIds.first val keyIdAsString = keyIds.second @@ -65,7 +65,7 @@ internal class AuthenticatorManager( id = keyIdAsString, ) - webAuthnRepository.registerPasskey(token, registerPasskey) + webAuthnRequests.registerPasskey(token, registerPasskey) return keyIdAsString } @@ -78,7 +78,7 @@ internal class AuthenticatorManager( val keyId = keyIdOrDefault ?: keyPairManager.findKeyIdFor(MatchOn.UserId(userId)) ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No key found for user $userId")) - val authenticationOptions = webAuthnRepository.challenge(clientId) + val authenticationOptions = webAuthnRequests.challenge(clientId) val publicKey = keyPairManager.retrievePublicKey(userId, keyId).firstOrNull() ?: return Xor.Second(Failure.KeyManagement.KeyNotFound("No public key found for $userId")) val rawAuthenticatorData = cryptoObjectsBuilder.generateAuthenticatorData( @@ -114,7 +114,7 @@ internal class AuthenticatorManager( clientExtensionResults = ClientExtensionResults, authenticatorAttachment = "platform", ) - val verifyAuthData = webAuthnRepository.verify(verifyAuthenticationData) + val verifyAuthData = webAuthnRequests.verify(verifyAuthenticationData) val apiToken = SharedApiToken( accessToken = verifyAuthData.accessToken, tokenType = verifyAuthData.tokenType, @@ -129,7 +129,7 @@ internal class AuthenticatorManager( if (passkeyId != null) { // If we have a passkey for this account, revoke it against the backend and delete it - webAuthnRepository.deletePasskeyIfExists(token, passkeyId) + webAuthnRequests.deletePasskeyIfExists(token, passkeyId) val _ = keyPairManager.deleteKeysMatching(MatchOn.PasskeyId(passkeyId)) } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt index a7a604fa..f3c1a255 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/MigrationManager.kt @@ -32,7 +32,7 @@ import com.infomaniak.auth.lib.internal.otp.deleteLegacyDB import com.infomaniak.auth.lib.internal.otp.getLegacyAccounts import com.infomaniak.auth.lib.internal.otp.getSecretFor import com.infomaniak.auth.lib.internal.otp.needMigration -import com.infomaniak.auth.lib.internal.repositories.WebAuthnRepository +import com.infomaniak.auth.lib.internal.requests.WebAuthnRequests 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 @@ -46,7 +46,7 @@ import kotlin.uuid.Uuid internal class MigrationManager( private val accountsDatabase: AccountsDatabase, private val authenticatorManager: AuthenticatorManager, - private val webAuthnRepository: WebAuthnRepository, + private val webAuthnRequests: WebAuthnRequests, private val clientId: String, ) { @@ -65,7 +65,7 @@ internal class MigrationManager( val restorer = AccountRestorer( accountsDatabase = accountsDatabase, authenticatorManager = authenticatorManager, - webAuthnRepository = webAuthnRepository, + webAuthnRequests = webAuthnRequests, clientId = clientId ) restorer.restore(account, persistToken) @@ -93,7 +93,7 @@ internal class MigrationManager( @OptIn(ExperimentalUuidApi::class) val deviceId = Uuid.random().toHexDashString() val secret = checkNotNull(getSecretFor(userId)) { "Couldn't find the secret for user $userId" } - val migrationOptions = webAuthnRepository.getMigrationOptions( + val migrationOptions = webAuthnRequests.getMigrationOptions( deviceId = deviceId, userId = userId, ) @@ -110,7 +110,7 @@ internal class MigrationManager( } runCatching { - webAuthnRepository.getTokenForMigration( + webAuthnRequests.getTokenForMigration( sessionId = migrationOptions.session, otpPayload = OtpPayload( deviceId = deviceId, @@ -145,7 +145,7 @@ internal class MigrationManager( } persistUser(userProfile) - webAuthnRepository.completeMigration( + webAuthnRequests.completeMigration( token = apiTokenFromPasskey.accessToken, sessionId = migrationOptions.session, deviceId = deviceId diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/repositories/WebAuthnRepository.kt b/multiplatform-lib/src/commonMain/kotlin/internal/repositories/WebAuthnRepository.kt deleted file mode 100644 index f2d2779f..00000000 --- a/multiplatform-lib/src/commonMain/kotlin/internal/repositories/WebAuthnRepository.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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.repositories - -import com.infomaniak.auth.lib.internal.models.AuthResult -import com.infomaniak.auth.lib.internal.models.AuthenticationOptions -import com.infomaniak.auth.lib.internal.models.MigrationOptions -import com.infomaniak.auth.lib.internal.models.OtpPayload -import com.infomaniak.auth.lib.internal.models.PasskeysOptions -import com.infomaniak.auth.lib.internal.models.RegisterPasskey -import com.infomaniak.auth.lib.internal.models.SuccessfulApiResponse -import com.infomaniak.auth.lib.internal.models.VerifyAuthenticationData -import com.infomaniak.auth.lib.internal.requests.AuthenticatorRequest -import com.infomaniak.auth.lib.network.exceptions.ApiException - -internal class WebAuthnRepository( - private val authenticatorRequest: AuthenticatorRequest, -) { - - //region Passkey - - // Generate WebAuthn registration options (authentified) - suspend fun getPasskeysOptions(token: String): SuccessfulApiResponse { - return authenticatorRequest.getPasskeysOptions(token) - } - - // Validate WebAuthn registration and save public key (authentified) - suspend fun registerPasskey(token: String, registerPasskey: RegisterPasskey) { - authenticatorRequest.registerPasskey(token, registerPasskey) - } - - // Deletion of existing passkey (authentified) - suspend fun deletePasskeyIfExists(token: String, passkeyId: String) { - try { - authenticatorRequest.deletePasskey(token, passkeyId) - } catch (e: ApiException) { - if (e.statusCode == 404) return - throw e - } - } - - // Authentification challenge (not authentified) - suspend fun challenge(clientId: String): AuthenticationOptions { - return authenticatorRequest.challenge(clientId).data - } - - // Authentification verification (not authentified) - suspend fun verify(verifyAuthenticationData: VerifyAuthenticationData): AuthResult { - return authenticatorRequest.verify(verifyAuthenticationData).data - } - - //endregion - - //region Migration - - suspend fun getMigrationOptions(deviceId: String, userId: Long): MigrationOptions { - return authenticatorRequest.getMigrationOptions(deviceId, userId).data - } - - suspend fun getTokenForMigration( - sessionId: String, - otpPayload: OtpPayload, - ): AuthResult { - return authenticatorRequest.getTokenForMigration(sessionId, otpPayload).data - } - - suspend fun completeMigration(token: String, sessionId: String, deviceId: String) { - return authenticatorRequest.completeMigration(token, sessionId, deviceId) - } - - suspend fun getUserProfile(token: String) = authenticatorRequest.getUserProfile(token = token).data - - //endregion -} diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/requests/AuthenticatorRequest.kt b/multiplatform-lib/src/commonMain/kotlin/internal/requests/WebAuthnRequests.kt similarity index 82% rename from multiplatform-lib/src/commonMain/kotlin/internal/requests/AuthenticatorRequest.kt rename to multiplatform-lib/src/commonMain/kotlin/internal/requests/WebAuthnRequests.kt index 55a17cb7..540eca4f 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/requests/AuthenticatorRequest.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/requests/WebAuthnRequests.kt @@ -28,6 +28,7 @@ import com.infomaniak.auth.lib.internal.models.VerifyAuthenticationData import com.infomaniak.auth.lib.internal.network.ApiRoutes import com.infomaniak.auth.lib.internal.network.utils.decode 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.request.HttpRequestBuilder import io.ktor.client.request.delete @@ -36,18 +37,20 @@ import io.ktor.client.request.headers import io.ktor.client.request.post import io.ktor.client.request.setBody -internal class AuthenticatorRequest( +internal class WebAuthnRequests( private val httpClient: suspend () -> HttpClient, private val routes: ApiRoutes, ) { + //region Passkey + /** * Retrieves options (including a challenge) prior to registering a public key credential with [registerPasskey]. */ - suspend fun getPasskeysOptions(token: String): SuccessfulApiResponse { + suspend fun getPasskeysOptions(token: String): PasskeysOptions { return httpClient().get(routes.passkeysOptions()) { addAuthenticationHeader(token) - }.decode() + }.decode>().data } /** @@ -64,10 +67,10 @@ internal class AuthenticatorRequest( /** * Retrieves the backend-generated challenge, prior to authenticating with [verify]. */ - suspend fun challenge(clientId: String): SuccessfulApiResponse { + suspend fun challenge(clientId: String): AuthenticationOptions { return httpClient().post(routes.challenge()) { setBody(mapOf("client_id" to clientId)) - }.decode() + }.decode>().data } /** @@ -77,10 +80,10 @@ internal class AuthenticatorRequest( * * @return An [AuthResult] that includes an access token. */ - suspend fun verify(verifyAuthenticationData: VerifyAuthenticationData): SuccessfulApiResponse { + suspend fun verify(verifyAuthenticationData: VerifyAuthenticationData): AuthResult { return httpClient().post(routes.verify()) { setBody(verifyAuthenticationData) - }.decode() + }.decode>().data } /** @@ -89,22 +92,35 @@ internal class AuthenticatorRequest( * @param token The access token of the user. * @param passkeyId The id of the passkey to delete. */ - suspend fun deletePasskey(token: String, passkeyId: String) { + suspend fun deletePasskeyIfExists(token: String, passkeyId: String) { + try { + deletePasskey(token, passkeyId) + } catch (e: ApiException) { + if (e.statusCode == 404) return + throw e + } + } + + private suspend fun deletePasskey(token: String, passkeyId: String) { httpClient().delete(routes.delete(passkeyId)) { addAuthenticationHeader(token) } } + //endregion + + //region Migration + /** * Get migration options (see [MigrationOptions]), prior to migration from kAuth. * * @param deviceId The id of the device. * @param userId The id of the user. */ - suspend fun getMigrationOptions(deviceId: String, userId: Long): SuccessfulApiResponse { + suspend fun getMigrationOptions(deviceId: String, userId: Long): MigrationOptions { return httpClient().post(routes.migrationsOptions()) { setBody(mapOf("device" to deviceId, "id" to userId.toString())) - }.decode() + }.decode>().data } /** @@ -121,10 +137,10 @@ internal class AuthenticatorRequest( suspend fun getTokenForMigration( sessionId: String, otpPayload: OtpPayload, - ): SuccessfulApiResponse { + ): AuthResult { return httpClient().post(routes.verifyMigration(sessionId)) { setBody(otpPayload) - }.decode() + }.decode>().data } /** @@ -140,14 +156,16 @@ internal class AuthenticatorRequest( } } + //endregion + suspend fun getUserProfile( token: String, - ): SuccessfulApiResponse { + ): SharedUserProfile { val url = "${routes.userProfile()}&with=security" return httpClient().get(url) { addAuthenticationHeader(token) - }.decode() + }.decode>().data } private fun HttpRequestBuilder.addAuthenticationHeader(token: String) {