diff --git a/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt b/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt new file mode 100644 index 000000000..7f57732ac --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt @@ -0,0 +1,70 @@ +package to.bitkit.models + +import android.net.Uri + +private const val NONCE_PARAM = "nonce" + +sealed interface PubkyRingAuthCallback { + companion object { + private const val BITKIT_SCHEME = "bitkit" + private const val PUBKY_AUTH_HOST = "pubky-auth" + private const val SUCCESS_PATH = "/success" + private const val CANCEL_PATH = "/cancel" + private const val ERROR_PATH = "/error" + private const val ERROR_MESSAGE_PARAM = "errorMessage" + + fun parse(uri: Uri): PubkyRingAuthCallback? { + if (uri.scheme != BITKIT_SCHEME || uri.host != PUBKY_AUTH_HOST) return null + + val nonce = uri.getQueryParameter(NONCE_PARAM)?.takeIf { it.isNotBlank() } + return when (uri.path) { + SUCCESS_PATH -> Success(nonce) + CANCEL_PATH -> Cancel(nonce) + ERROR_PATH -> Error(uri.getQueryParameter(ERROR_MESSAGE_PARAM), nonce) + else -> null + } + } + } + + val nonce: String? + + data class Success(override val nonce: String?) : PubkyRingAuthCallback + data class Cancel(override val nonce: String?) : PubkyRingAuthCallback + data class Error(val message: String?, override val nonce: String?) : PubkyRingAuthCallback +} + +sealed interface PubkyRingAuthCallbackHandlingResult { + data object Ignored : PubkyRingAuthCallbackHandlingResult + data object Handled : PubkyRingAuthCallbackHandlingResult + data class TrustedError(val message: String?) : PubkyRingAuthCallbackHandlingResult +} + +object PubkyRingAuthUrlBuilder { + const val SUCCESS_CALLBACK = "bitkit://pubky-auth/success" + const val CANCEL_CALLBACK = "bitkit://pubky-auth/cancel" + const val ERROR_CALLBACK = "bitkit://pubky-auth/error" + const val SOURCE = "Bitkit" + + fun addCallbacks(authUrl: String, nonce: String? = null): String? { + val uri = Uri.parse(authUrl) + if (uri.scheme.isNullOrBlank()) return null + + return uri.buildUpon() + .appendQueryParameter("x-success", callbackUrl(SUCCESS_CALLBACK, nonce)) + .appendQueryParameter("x-cancel", callbackUrl(CANCEL_CALLBACK, nonce)) + .appendQueryParameter("x-error", callbackUrl(ERROR_CALLBACK, nonce)) + .appendQueryParameter("x-source", SOURCE) + .build() + .toString() + } + + private fun callbackUrl(baseUrl: String, nonce: String?): String { + if (nonce.isNullOrBlank()) return baseUrl + + return Uri.parse(baseUrl) + .buildUpon() + .appendQueryParameter(NONCE_PARAM, nonce) + .build() + .toString() + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index e48d6e64d..f2ae26db5 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -13,9 +13,11 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map @@ -35,24 +37,34 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.PubkyRingAuthCallback +import to.bitkit.models.PubkyRingAuthCallbackHandlingResult import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 import to.bitkit.services.PubkyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.ByteArrayOutputStream +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.math.min enum class PubkyAuthState { Idle, Authenticating, Authenticated } +data class PubkyRingAuthRequest( + val authUrl: String, + val callbackNonce: String, +) + sealed class PubkyContactError(message: String) : AppError(message) { data object AlreadyExists : PubkyContactError("Contact already exists") data object CannotAddSelf : PubkyContactError("Cannot add your own pubky as a contact") data object InvalidFormat : PubkyContactError("Invalid pubky key format") } +private class PubkyAuthAttemptInactive : AppError("Auth attempt is no longer active") + @Suppress("TooManyFunctions", "LargeClass", "LongParameterList") @Singleton class PubkyRepo @Inject constructor( @@ -80,6 +92,9 @@ class PubkyRepo @Inject constructor( private var isServiceInitialized = false private val _authState = MutableStateFlow(PubkyAuthState.Idle) + private val _activeAuthAttemptId = MutableStateFlow(null) + private val _authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1) + val authCancelEvents = _authCancelEvents.asSharedFlow() private val _profile = MutableStateFlow(null) val profile: StateFlow = _profile.asStateFlow() @@ -108,7 +123,7 @@ class PubkyRepo @Inject constructor( private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() - val isAuthenticated: StateFlow = _authState.map { it == PubkyAuthState.Authenticated } + val isAuthenticated: StateFlow = _publicKey.map { it != null } .stateIn(scope, SharingStarted.Eagerly, false) val displayName: StateFlow = combine(_profile, pubkyStore.data) { profile, cached -> @@ -236,20 +251,27 @@ class PubkyRepo @Inject constructor( // region Ring auth flow - suspend fun startAuthentication(): Result { + suspend fun startAuthentication(): Result { + val attemptId = UUID.randomUUID().toString() + _activeAuthAttemptId.update { attemptId } _authState.update { PubkyAuthState.Authenticating } return runCatching { - withContext(ioDispatcher) { pubkyService.startAuth() } + val authUrl = withContext(ioDispatcher) { pubkyService.startAuth() } + PubkyRingAuthRequest(authUrl = authUrl, callbackNonce = attemptId) }.onFailure { - _authState.update { PubkyAuthState.Idle } + _activeAuthAttemptId.update { null } + restoreAuthStateAfterAuthFlow() } } suspend fun completeAuthentication(): Result { + val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive()) return runCatching { withContext(ioDispatcher) { val sessionSecret = pubkyService.completeAuth() + ensureAuthAttemptActive(attemptId) val pk = pubkyService.importSession(sessionSecret).ensurePubkyPrefix() + ensureAuthAttemptActive(attemptId) runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret) @@ -258,8 +280,14 @@ class PubkyRepo @Inject constructor( pk } }.onFailure { - _authState.update { PubkyAuthState.Idle } + if (_activeAuthAttemptId.value == attemptId) { + _activeAuthAttemptId.update { null } + } + restoreAuthStateAfterAuthFlow() }.onSuccess { pk -> + if (_activeAuthAttemptId.value == attemptId) { + _activeAuthAttemptId.update { null } + } _publicKey.update { pk } _authState.update { PubkyAuthState.Authenticated } Logger.info("Completed pubky auth for '$pk'", context = TAG) @@ -272,13 +300,82 @@ class PubkyRepo @Inject constructor( runCatching { withContext(ioDispatcher) { pubkyService.cancelAuth() } }.onFailure { Logger.warn("Failed to cancel auth", it, context = TAG) } - _authState.update { PubkyAuthState.Idle } + endAuthAttempt() } fun cancelAuthenticationSync() { scope.launch { cancelAuthentication() } } + suspend fun handleAuthCallback(callback: PubkyRingAuthCallback): PubkyRingAuthCallbackHandlingResult { + if (!isCurrentAuthCallback(callback)) { + return handleInvalidAuthCallback(callback) + } + + return when (callback) { + is PubkyRingAuthCallback.Success -> { + Logger.info("Received Pubky Ring auth success callback", context = TAG) + PubkyRingAuthCallbackHandlingResult.Handled + } + is PubkyRingAuthCallback.Cancel -> { + Logger.info("Received Pubky Ring auth cancel callback", context = TAG) + cancelAuthentication() + PubkyRingAuthCallbackHandlingResult.Handled + } + is PubkyRingAuthCallback.Error -> { + Logger.warn("Received Pubky Ring auth error callback", context = TAG) + cancelAuthentication() + PubkyRingAuthCallbackHandlingResult.TrustedError(callback.message) + } + } + } + + private fun handleInvalidAuthCallback( + callback: PubkyRingAuthCallback, + ): PubkyRingAuthCallbackHandlingResult { + if (_activeAuthAttemptId.value == null) { + Logger.warn("Ignoring Pubky Ring auth callback with missing or invalid nonce", context = TAG) + return PubkyRingAuthCallbackHandlingResult.Ignored + } + + return when (callback) { + is PubkyRingAuthCallback.Success -> { + Logger.warn("Ignoring Pubky Ring auth success callback with missing or invalid nonce", context = TAG) + PubkyRingAuthCallbackHandlingResult.Ignored + } + is PubkyRingAuthCallback.Cancel -> { + Logger.warn("Ignoring Pubky Ring auth cancel callback with missing or invalid nonce", context = TAG) + PubkyRingAuthCallbackHandlingResult.Ignored + } + is PubkyRingAuthCallback.Error -> { + Logger.warn("Ignoring Pubky Ring auth error callback with missing or invalid nonce", context = TAG) + PubkyRingAuthCallbackHandlingResult.Ignored + } + } + } + + private fun isCurrentAuthCallback(callback: PubkyRingAuthCallback): Boolean { + val activeAuthAttemptId = _activeAuthAttemptId.value ?: return false + return callback.nonce == activeAuthAttemptId + } + + private fun ensureAuthAttemptActive(attemptId: String?) { + if (attemptId == null) return + if (_activeAuthAttemptId.value == attemptId) return + + throw PubkyAuthAttemptInactive() + } + + private fun endAuthAttempt() { + _activeAuthAttemptId.update { null } + _authCancelEvents.tryEmit(Unit) + restoreAuthStateAfterAuthFlow() + } + + private fun restoreAuthStateAfterAuthFlow() { + _authState.update { if (_publicKey.value == null) PubkyAuthState.Idle else PubkyAuthState.Authenticated } + } + // endregion // region Payment endpoints diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt index 4c352d2fe..1328d2205 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.models.PubkyRingAuthUrlBuilder import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.shared.toast.ToastEventBus @@ -41,6 +42,16 @@ class PubkyChoiceViewModel @Inject constructor( private var approvalJob: Job? = null + init { + viewModelScope.launch { + pubkyRepo.authCancelEvents.collect { + approvalJob?.cancel() + approvalJob = null + _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) } + } + } + } + override fun onCleared() { super.onCleared() if (_uiState.value.isWaitingForRing) { @@ -63,8 +74,12 @@ class PubkyChoiceViewModel @Inject constructor( } pubkyRepo.startAuthentication() - .onSuccess { authUrl -> - val ringIntent = createRingAuthIntent(authUrl) + .onSuccess { authRequest -> + val callbackAuthUrl = PubkyRingAuthUrlBuilder.addCallbacks( + authUrl = authRequest.authUrl, + nonce = authRequest.callbackNonce, + ) ?: authRequest.authUrl + val ringIntent = createRingAuthIntent(callbackAuthUrl) if (!canOpenWithRing(ringIntent)) { cancelAuthAndShowRingDialog() return@launch diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 97099a267..fd2926b1b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -101,6 +101,8 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.PubkyRingAuthCallback +import to.bitkit.models.PubkyRingAuthCallbackHandlingResult import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -2677,6 +2679,11 @@ class AppViewModel @Inject constructor( return@launch } + PubkyRingAuthCallback.parse(uri)?.let { + handlePubkyRingAuthCallback(it) + return@launch + } + if (uri.scheme == PUBKYAUTH_SCHEME) { handlePubkyAuth(uri.toString()) return@launch @@ -2698,6 +2705,21 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PubkyAuth(authUrl)) } + private suspend fun handlePubkyRingAuthCallback(callback: PubkyRingAuthCallback) { + when (val result = pubkyRepo.handleAuthCallback(callback)) { + is PubkyRingAuthCallbackHandlingResult.TrustedError -> { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__auth_error_title), + description = result.message ?: context.getString(R.string.other__qr_error_text), + ) + } + PubkyRingAuthCallbackHandlingResult.Handled, + PubkyRingAuthCallbackHandlingResult.Ignored, + -> Unit + } + } + // TODO Temporary fix while these schemes can't be decoded https://github.com/synonymdev/bitkit-core/issues/70 private fun String.removeLightningSchemes(): String = LIGHTNING_SCHEME_PATTERNS.fold(this) { acc, regex -> acc.replace(regex, "") diff --git a/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt b/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt new file mode 100644 index 000000000..8e753f7b4 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt @@ -0,0 +1,77 @@ +package to.bitkit.models + +import androidx.core.net.toUri +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PubkyRingAuthCallbackTest { + @Test + fun `addCallbacks adds Ring x-callback params`() { + val url = checkNotNull( + PubkyRingAuthUrlBuilder.addCallbacks( + authUrl = "pubkyauth://auth?relay=https%3A%2F%2Frelay.example", + nonce = "12345678-1234-1234-1234-123456789ABC", + ), + ) { "Auth URL should be valid" } + val uri = url.toUri() + + assertEquals("https://relay.example", uri.getQueryParameter("relay")) + assertEquals( + "bitkit://pubky-auth/success?nonce=12345678-1234-1234-1234-123456789ABC", + uri.getQueryParameter("x-success"), + ) + assertEquals( + "bitkit://pubky-auth/cancel?nonce=12345678-1234-1234-1234-123456789ABC", + uri.getQueryParameter("x-cancel"), + ) + assertEquals( + "bitkit://pubky-auth/error?nonce=12345678-1234-1234-1234-123456789ABC", + uri.getQueryParameter("x-error"), + ) + assertEquals(PubkyRingAuthUrlBuilder.SOURCE, uri.getQueryParameter("x-source")) + } + + @Test + fun `parse returns success cancel and error callbacks`() { + assertEquals( + PubkyRingAuthCallback.Success(nonce = null), + PubkyRingAuthCallback.parse("bitkit://pubky-auth/success".toUri()), + ) + assertEquals( + PubkyRingAuthCallback.Cancel(nonce = null), + PubkyRingAuthCallback.parse("bitkit://pubky-auth/cancel".toUri()), + ) + assertEquals( + PubkyRingAuthCallback.Error(message = "Denied", nonce = null), + PubkyRingAuthCallback.parse("bitkit://pubky-auth/error?errorMessage=Denied".toUri()), + ) + } + + @Test + fun `parse returns nonce when callback includes value`() { + assertEquals( + PubkyRingAuthCallback.Error(message = "Denied", nonce = "abc"), + PubkyRingAuthCallback.parse("bitkit://pubky-auth/error?nonce=abc&errorMessage=Denied".toUri()), + ) + } + + @Test + fun `parse treats bare nonce as missing`() { + assertEquals( + PubkyRingAuthCallback.Cancel(nonce = null), + PubkyRingAuthCallback.parse("bitkit://pubky-auth/cancel?nonce".toUri()), + ) + } + + @Test + fun `parse rejects other deeplinks`() { + assertNull(PubkyRingAuthCallback.parse("bitkit://wallet/success".toUri())) + assertNull(PubkyRingAuthCallback.parse("https://pubky-auth/success".toUri())) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index c377f15a2..4ee31dd65 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -26,6 +26,8 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyRingAuthCallback +import to.bitkit.models.PubkyRingAuthCallbackHandlingResult import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 import to.bitkit.services.PubkyService @@ -94,7 +96,8 @@ class PubkyRepoTest : BaseUnitTest() { val result = sut.startAuthentication() assertTrue(result.isSuccess) - assertEquals(authUri, result.getOrNull()) + assertEquals(authUri, result.getOrNull()?.authUrl) + assertNotNull(result.getOrNull()?.callbackNonce) } @Test @@ -113,6 +116,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `completeAuthentication should save session and update state`() = test { val testSecret = "session_secret" val testPk = VALID_SELF_KEY.removePrefix("pubky") + whenever(pubkyService.startAuth()).thenReturn("auth_uri") whenever(pubkyService.completeAuth()).thenReturn(testSecret) whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) @@ -122,6 +126,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) + sut.startAuthentication() val result = sut.completeAuthentication() assertTrue(result.isSuccess) @@ -134,6 +139,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `completeAuthentication should clear managed secret key`() = test { val testSecret = "session_secret" val testPk = VALID_SELF_KEY.removePrefix("pubky") + whenever(pubkyService.startAuth()).thenReturn("auth_uri") whenever(pubkyService.completeAuth()).thenReturn(testSecret) whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) val ffiProfile = createFfiProfile(name = "User") @@ -141,6 +147,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) + sut.startAuthentication() val result = sut.completeAuthentication() assertTrue(result.isSuccess) @@ -151,6 +158,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `completeAuthentication should load contacts automatically`() = test { val testSecret = "session_secret" val testPk = VALID_SELF_KEY.removePrefix("pubky") + whenever(pubkyService.startAuth()).thenReturn("auth_uri") whenever(pubkyService.completeAuth()).thenReturn(testSecret) whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) val ffiProfile = createFfiProfile(name = "User") @@ -158,6 +166,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) whenever(pubkyService.sessionList(testSecret, Env.contactsBasePath)).thenReturn(emptyList()) + sut.startAuthentication() val result = sut.completeAuthentication() assertTrue(result.isSuccess) @@ -166,8 +175,10 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `completeAuthentication should reset state on failure`() = test { + whenever(pubkyService.startAuth()).thenReturn("auth_uri") whenever(pubkyService.completeAuth()).thenAnswer { throw TestAppError("Failed") } + sut.startAuthentication() val result = sut.completeAuthentication() assertTrue(result.isFailure) @@ -175,6 +186,14 @@ class PubkyRepoTest : BaseUnitTest() { assertNull(sut.publicKey.value) } + @Test + fun `completeAuthentication should fail when auth attempt inactive`() = test { + val result = sut.completeAuthentication() + + assertTrue(result.isFailure) + verifyBlocking(pubkyService, never()) { completeAuth() } + } + @Test fun `cancelAuthentication should reset state to idle`() = test { whenever(pubkyService.startAuth()).thenReturn("auth_uri") @@ -185,6 +204,149 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isAuthenticated.value) } + @Test + fun `cancelAuthentication should keep restored profile authenticated`() = test { + authenticateForTesting() + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + sut.startAuthentication() + + sut.cancelAuthentication() + + assertTrue(sut.isAuthenticated.value) + assertNotNull(sut.publicKey.value) + } + + @Test + fun `handleAuthCallback should reject invalid success nonce`() = test { + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + sut.startAuthentication() + + val result = sut.handleAuthCallback(PubkyRingAuthCallback.Success(nonce = "invalid")) + + assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result) + verifyBlocking(pubkyService, never()) { cancelAuth() } + } + + @Test + fun `handleAuthCallback should ignore invalid cancel nonce`() = test { + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + sut.startAuthentication() + + val result = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = "invalid")) + + assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result) + assertFalse(sut.isAuthenticated.value) + verifyBlocking(pubkyService, never()) { cancelAuth() } + } + + @Test + fun `handleAuthCallback should ignore invalid error nonce`() = test { + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + sut.startAuthentication() + + val result = sut.handleAuthCallback( + PubkyRingAuthCallback.Error(message = "Forged error", nonce = "invalid"), + ) + + assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result) + verifyBlocking(pubkyService, never()) { cancelAuth() } + } + + @Test + fun `handleAuthCallback should keep active auth after missing cancel nonce`() = test { + val testSecret = "session_secret" + val testPk = VALID_SELF_KEY.removePrefix("pubky") + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + whenever(pubkyService.completeAuth()).thenReturn(testSecret) + whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(mock()) + sut.startAuthentication() + + val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = null)) + val result = sut.completeAuthentication() + + assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult) + assertTrue(result.isSuccess) + assertTrue(sut.isAuthenticated.value) + verifyBlocking(pubkyService, never()) { cancelAuth() } + } + + @Test + fun `handleAuthCallback should keep active auth after missing error nonce`() = test { + val testSecret = "session_secret" + val testPk = VALID_SELF_KEY.removePrefix("pubky") + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + whenever(pubkyService.completeAuth()).thenReturn(testSecret) + whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(mock()) + sut.startAuthentication() + + val callbackResult = sut.handleAuthCallback( + PubkyRingAuthCallback.Error(message = "Forged error", nonce = null), + ) + val result = sut.completeAuthentication() + + assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult) + assertTrue(result.isSuccess) + assertTrue(sut.isAuthenticated.value) + verifyBlocking(pubkyService, never()) { cancelAuth() } + } + + @Test + fun `handleAuthCallback should keep active auth after invalid cancel nonce`() = test { + val testSecret = "session_secret" + val testPk = VALID_SELF_KEY.removePrefix("pubky") + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + whenever(pubkyService.completeAuth()).thenReturn(testSecret) + whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(mock()) + sut.startAuthentication() + + val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = "invalid")) + val result = sut.completeAuthentication() + + assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult) + assertTrue(result.isSuccess) + assertTrue(sut.isAuthenticated.value) + verifyBlocking(pubkyService, never()) { cancelAuth() } + } + + @Test + fun `handleAuthCallback should keep active auth after invalid error nonce`() = test { + val testSecret = "session_secret" + val testPk = VALID_SELF_KEY.removePrefix("pubky") + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + whenever(pubkyService.completeAuth()).thenReturn(testSecret) + whenever(pubkyService.importSession(testSecret)).thenReturn(testPk) + whenever(pubkyService.getProfile(VALID_SELF_KEY)).thenReturn(mock()) + sut.startAuthentication() + + val callbackResult = sut.handleAuthCallback( + PubkyRingAuthCallback.Error(message = "Forged error", nonce = "invalid"), + ) + val result = sut.completeAuthentication() + + assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult) + assertTrue(result.isSuccess) + assertTrue(sut.isAuthenticated.value) + verifyBlocking(pubkyService, never()) { cancelAuth() } + } + + @Test + fun `handleAuthCallback should trust matching error nonce`() = test { + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + val authRequest = checkNotNull(sut.startAuthentication().getOrNull()) { + "Auth request should be returned" + } + + val result = sut.handleAuthCallback( + PubkyRingAuthCallback.Error(message = "Ring failed", nonce = authRequest.callbackNonce), + ) + + assertEquals(PubkyRingAuthCallbackHandlingResult.TrustedError("Ring failed"), result) + verifyBlocking(pubkyService) { cancelAuth() } + } + @Test fun `loadProfile should update profile on success`() = test { authenticateForTesting() @@ -688,7 +850,10 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.sessionList(newSecret, Env.contactsBasePath)).thenReturn(emptyList()) val staleProfile = createFfiProfile(name = "Stale Old") whenever(pubkyService.getProfile(oldPublicKey.ensurePubkyPrefixForTest())).thenAnswer { - runBlocking { sut.completeAuthentication() } + runBlocking { + startAuthForTesting() + sut.completeAuthentication() + } staleProfile } @@ -731,7 +896,10 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(oldSecret) whenever(pubkyService.sessionList(oldSecret, Env.contactsBasePath)).thenReturn(listOf(staleContactPath)) whenever(pubkyService.fetchFileString(staleContactUri)).thenAnswer { - runBlocking { sut.completeAuthentication() } + runBlocking { + startAuthForTesting() + sut.completeAuthentication() + } """{"name":"Stale Contact","bio":""}""" } @@ -992,6 +1160,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever { pubkyService.sessionList(secret, Env.contactsBasePath) }.thenReturn(emptyList()) whenever { pubkyService.getPaymentList(prefixedPublicKey) }.thenReturn(emptyList()) + startAuthForTesting() sut.completeAuthentication() } @@ -1000,6 +1169,11 @@ class PubkyRepoTest : BaseUnitTest() { endpointData = """{"value":"value"}""", ) + private suspend fun startAuthForTesting(authUri: String = "auth_uri") { + whenever { pubkyService.startAuth() }.thenReturn(authUri) + sut.startAuthentication() + } + private fun createFfiProfile(name: String): CorePubkyProfile { val ffiProfile = mock() whenever(ffiProfile.name).thenReturn(name) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt index 3678e1fb3..afb09beae 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.profile import android.content.Context import android.content.pm.PackageManager import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -28,6 +29,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { private val packageManager: PackageManager = mock() private val pubkyRepo: PubkyRepo = mock() private val pendingImportContacts = MutableStateFlow>(emptyList()) + private val authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1) private lateinit var sut: PubkyChoiceViewModel @@ -37,6 +39,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.common__error)).thenReturn("Error") whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization Failed") whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts) + whenever(pubkyRepo.authCancelEvents).thenReturn(authCancelEvents) sut = PubkyChoiceViewModel( context = context, pubkyRepo = pubkyRepo, diff --git a/changelog.d/next/917.added.md b/changelog.d/next/917.added.md new file mode 100644 index 000000000..92a1a28f0 --- /dev/null +++ b/changelog.d/next/917.added.md @@ -0,0 +1 @@ +Return to Bitkit after Pubky Ring approval, cancellation, or error callbacks