Skip to content

Commit 6b1d680

Browse files
authored
Merge pull request #917 from synonymdev/codex-pubky-ring-callbacks-913
2 parents d63dfff + 2b8fcf8 commit 6b1d680

8 files changed

Lines changed: 470 additions & 11 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package to.bitkit.models
2+
3+
import android.net.Uri
4+
5+
private const val NONCE_PARAM = "nonce"
6+
7+
sealed interface PubkyRingAuthCallback {
8+
companion object {
9+
private const val BITKIT_SCHEME = "bitkit"
10+
private const val PUBKY_AUTH_HOST = "pubky-auth"
11+
private const val SUCCESS_PATH = "/success"
12+
private const val CANCEL_PATH = "/cancel"
13+
private const val ERROR_PATH = "/error"
14+
private const val ERROR_MESSAGE_PARAM = "errorMessage"
15+
16+
fun parse(uri: Uri): PubkyRingAuthCallback? {
17+
if (uri.scheme != BITKIT_SCHEME || uri.host != PUBKY_AUTH_HOST) return null
18+
19+
val nonce = uri.getQueryParameter(NONCE_PARAM)?.takeIf { it.isNotBlank() }
20+
return when (uri.path) {
21+
SUCCESS_PATH -> Success(nonce)
22+
CANCEL_PATH -> Cancel(nonce)
23+
ERROR_PATH -> Error(uri.getQueryParameter(ERROR_MESSAGE_PARAM), nonce)
24+
else -> null
25+
}
26+
}
27+
}
28+
29+
val nonce: String?
30+
31+
data class Success(override val nonce: String?) : PubkyRingAuthCallback
32+
data class Cancel(override val nonce: String?) : PubkyRingAuthCallback
33+
data class Error(val message: String?, override val nonce: String?) : PubkyRingAuthCallback
34+
}
35+
36+
sealed interface PubkyRingAuthCallbackHandlingResult {
37+
data object Ignored : PubkyRingAuthCallbackHandlingResult
38+
data object Handled : PubkyRingAuthCallbackHandlingResult
39+
data class TrustedError(val message: String?) : PubkyRingAuthCallbackHandlingResult
40+
}
41+
42+
object PubkyRingAuthUrlBuilder {
43+
const val SUCCESS_CALLBACK = "bitkit://pubky-auth/success"
44+
const val CANCEL_CALLBACK = "bitkit://pubky-auth/cancel"
45+
const val ERROR_CALLBACK = "bitkit://pubky-auth/error"
46+
const val SOURCE = "Bitkit"
47+
48+
fun addCallbacks(authUrl: String, nonce: String? = null): String? {
49+
val uri = Uri.parse(authUrl)
50+
if (uri.scheme.isNullOrBlank()) return null
51+
52+
return uri.buildUpon()
53+
.appendQueryParameter("x-success", callbackUrl(SUCCESS_CALLBACK, nonce))
54+
.appendQueryParameter("x-cancel", callbackUrl(CANCEL_CALLBACK, nonce))
55+
.appendQueryParameter("x-error", callbackUrl(ERROR_CALLBACK, nonce))
56+
.appendQueryParameter("x-source", SOURCE)
57+
.build()
58+
.toString()
59+
}
60+
61+
private fun callbackUrl(baseUrl: String, nonce: String?): String {
62+
if (nonce.isNullOrBlank()) return baseUrl
63+
64+
return Uri.parse(baseUrl)
65+
.buildUpon()
66+
.appendQueryParameter(NONCE_PARAM, nonce)
67+
.build()
68+
.toString()
69+
}
70+
}

app/src/main/java/to/bitkit/repositories/PubkyRepo.kt

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import kotlinx.coroutines.SupervisorJob
1313
import kotlinx.coroutines.async
1414
import kotlinx.coroutines.awaitAll
1515
import kotlinx.coroutines.coroutineScope
16+
import kotlinx.coroutines.flow.MutableSharedFlow
1617
import kotlinx.coroutines.flow.MutableStateFlow
1718
import kotlinx.coroutines.flow.SharingStarted
1819
import kotlinx.coroutines.flow.StateFlow
20+
import kotlinx.coroutines.flow.asSharedFlow
1921
import kotlinx.coroutines.flow.asStateFlow
2022
import kotlinx.coroutines.flow.combine
2123
import kotlinx.coroutines.flow.map
@@ -35,24 +37,34 @@ import to.bitkit.models.PubkyProfile
3537
import to.bitkit.models.PubkyProfileData
3638
import to.bitkit.models.PubkyProfileLink
3739
import to.bitkit.models.PubkyPublicKeyFormat
40+
import to.bitkit.models.PubkyRingAuthCallback
41+
import to.bitkit.models.PubkyRingAuthCallbackHandlingResult
3842
import to.bitkit.models.PubkySessionBackupKind
3943
import to.bitkit.models.PubkySessionBackupV1
4044
import to.bitkit.services.PubkyService
4145
import to.bitkit.utils.AppError
4246
import to.bitkit.utils.Logger
4347
import java.io.ByteArrayOutputStream
48+
import java.util.UUID
4449
import javax.inject.Inject
4550
import javax.inject.Singleton
4651
import kotlin.math.min
4752

4853
enum class PubkyAuthState { Idle, Authenticating, Authenticated }
4954

55+
data class PubkyRingAuthRequest(
56+
val authUrl: String,
57+
val callbackNonce: String,
58+
)
59+
5060
sealed class PubkyContactError(message: String) : AppError(message) {
5161
data object AlreadyExists : PubkyContactError("Contact already exists")
5262
data object CannotAddSelf : PubkyContactError("Cannot add your own pubky as a contact")
5363
data object InvalidFormat : PubkyContactError("Invalid pubky key format")
5464
}
5565

66+
private class PubkyAuthAttemptInactive : AppError("Auth attempt is no longer active")
67+
5668
@Suppress("TooManyFunctions", "LargeClass", "LongParameterList")
5769
@Singleton
5870
class PubkyRepo @Inject constructor(
@@ -80,6 +92,9 @@ class PubkyRepo @Inject constructor(
8092
private var isServiceInitialized = false
8193

8294
private val _authState = MutableStateFlow(PubkyAuthState.Idle)
95+
private val _activeAuthAttemptId = MutableStateFlow<String?>(null)
96+
private val _authCancelEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
97+
val authCancelEvents = _authCancelEvents.asSharedFlow()
8398

8499
private val _profile = MutableStateFlow<PubkyProfile?>(null)
85100
val profile: StateFlow<PubkyProfile?> = _profile.asStateFlow()
@@ -108,7 +123,7 @@ class PubkyRepo @Inject constructor(
108123
private val _backupStateVersion = MutableStateFlow(0L)
109124
val backupStateVersion: StateFlow<Long> = _backupStateVersion.asStateFlow()
110125

111-
val isAuthenticated: StateFlow<Boolean> = _authState.map { it == PubkyAuthState.Authenticated }
126+
val isAuthenticated: StateFlow<Boolean> = _publicKey.map { it != null }
112127
.stateIn(scope, SharingStarted.Eagerly, false)
113128

114129
val displayName: StateFlow<String?> = combine(_profile, pubkyStore.data) { profile, cached ->
@@ -236,20 +251,27 @@ class PubkyRepo @Inject constructor(
236251

237252
// region Ring auth flow
238253

239-
suspend fun startAuthentication(): Result<String> {
254+
suspend fun startAuthentication(): Result<PubkyRingAuthRequest> {
255+
val attemptId = UUID.randomUUID().toString()
256+
_activeAuthAttemptId.update { attemptId }
240257
_authState.update { PubkyAuthState.Authenticating }
241258
return runCatching {
242-
withContext(ioDispatcher) { pubkyService.startAuth() }
259+
val authUrl = withContext(ioDispatcher) { pubkyService.startAuth() }
260+
PubkyRingAuthRequest(authUrl = authUrl, callbackNonce = attemptId)
243261
}.onFailure {
244-
_authState.update { PubkyAuthState.Idle }
262+
_activeAuthAttemptId.update { null }
263+
restoreAuthStateAfterAuthFlow()
245264
}
246265
}
247266

248267
suspend fun completeAuthentication(): Result<Unit> {
268+
val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive())
249269
return runCatching {
250270
withContext(ioDispatcher) {
251271
val sessionSecret = pubkyService.completeAuth()
272+
ensureAuthAttemptActive(attemptId)
252273
val pk = pubkyService.importSession(sessionSecret).ensurePubkyPrefix()
274+
ensureAuthAttemptActive(attemptId)
253275

254276
runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
255277
keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret)
@@ -258,8 +280,14 @@ class PubkyRepo @Inject constructor(
258280
pk
259281
}
260282
}.onFailure {
261-
_authState.update { PubkyAuthState.Idle }
283+
if (_activeAuthAttemptId.value == attemptId) {
284+
_activeAuthAttemptId.update { null }
285+
}
286+
restoreAuthStateAfterAuthFlow()
262287
}.onSuccess { pk ->
288+
if (_activeAuthAttemptId.value == attemptId) {
289+
_activeAuthAttemptId.update { null }
290+
}
263291
_publicKey.update { pk }
264292
_authState.update { PubkyAuthState.Authenticated }
265293
Logger.info("Completed pubky auth for '$pk'", context = TAG)
@@ -272,13 +300,82 @@ class PubkyRepo @Inject constructor(
272300
runCatching {
273301
withContext(ioDispatcher) { pubkyService.cancelAuth() }
274302
}.onFailure { Logger.warn("Failed to cancel auth", it, context = TAG) }
275-
_authState.update { PubkyAuthState.Idle }
303+
endAuthAttempt()
276304
}
277305

278306
fun cancelAuthenticationSync() {
279307
scope.launch { cancelAuthentication() }
280308
}
281309

310+
suspend fun handleAuthCallback(callback: PubkyRingAuthCallback): PubkyRingAuthCallbackHandlingResult {
311+
if (!isCurrentAuthCallback(callback)) {
312+
return handleInvalidAuthCallback(callback)
313+
}
314+
315+
return when (callback) {
316+
is PubkyRingAuthCallback.Success -> {
317+
Logger.info("Received Pubky Ring auth success callback", context = TAG)
318+
PubkyRingAuthCallbackHandlingResult.Handled
319+
}
320+
is PubkyRingAuthCallback.Cancel -> {
321+
Logger.info("Received Pubky Ring auth cancel callback", context = TAG)
322+
cancelAuthentication()
323+
PubkyRingAuthCallbackHandlingResult.Handled
324+
}
325+
is PubkyRingAuthCallback.Error -> {
326+
Logger.warn("Received Pubky Ring auth error callback", context = TAG)
327+
cancelAuthentication()
328+
PubkyRingAuthCallbackHandlingResult.TrustedError(callback.message)
329+
}
330+
}
331+
}
332+
333+
private fun handleInvalidAuthCallback(
334+
callback: PubkyRingAuthCallback,
335+
): PubkyRingAuthCallbackHandlingResult {
336+
if (_activeAuthAttemptId.value == null) {
337+
Logger.warn("Ignoring Pubky Ring auth callback with missing or invalid nonce", context = TAG)
338+
return PubkyRingAuthCallbackHandlingResult.Ignored
339+
}
340+
341+
return when (callback) {
342+
is PubkyRingAuthCallback.Success -> {
343+
Logger.warn("Ignoring Pubky Ring auth success callback with missing or invalid nonce", context = TAG)
344+
PubkyRingAuthCallbackHandlingResult.Ignored
345+
}
346+
is PubkyRingAuthCallback.Cancel -> {
347+
Logger.warn("Ignoring Pubky Ring auth cancel callback with missing or invalid nonce", context = TAG)
348+
PubkyRingAuthCallbackHandlingResult.Ignored
349+
}
350+
is PubkyRingAuthCallback.Error -> {
351+
Logger.warn("Ignoring Pubky Ring auth error callback with missing or invalid nonce", context = TAG)
352+
PubkyRingAuthCallbackHandlingResult.Ignored
353+
}
354+
}
355+
}
356+
357+
private fun isCurrentAuthCallback(callback: PubkyRingAuthCallback): Boolean {
358+
val activeAuthAttemptId = _activeAuthAttemptId.value ?: return false
359+
return callback.nonce == activeAuthAttemptId
360+
}
361+
362+
private fun ensureAuthAttemptActive(attemptId: String?) {
363+
if (attemptId == null) return
364+
if (_activeAuthAttemptId.value == attemptId) return
365+
366+
throw PubkyAuthAttemptInactive()
367+
}
368+
369+
private fun endAuthAttempt() {
370+
_activeAuthAttemptId.update { null }
371+
_authCancelEvents.tryEmit(Unit)
372+
restoreAuthStateAfterAuthFlow()
373+
}
374+
375+
private fun restoreAuthStateAfterAuthFlow() {
376+
_authState.update { if (_publicKey.value == null) PubkyAuthState.Idle else PubkyAuthState.Authenticated }
377+
}
378+
282379
// endregion
283380

284381
// region Payment endpoints

app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow
1717
import kotlinx.coroutines.flow.update
1818
import kotlinx.coroutines.launch
1919
import to.bitkit.R
20+
import to.bitkit.models.PubkyRingAuthUrlBuilder
2021
import to.bitkit.models.Toast
2122
import to.bitkit.repositories.PubkyRepo
2223
import to.bitkit.ui.shared.toast.ToastEventBus
@@ -41,6 +42,16 @@ class PubkyChoiceViewModel @Inject constructor(
4142

4243
private var approvalJob: Job? = null
4344

45+
init {
46+
viewModelScope.launch {
47+
pubkyRepo.authCancelEvents.collect {
48+
approvalJob?.cancel()
49+
approvalJob = null
50+
_uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) }
51+
}
52+
}
53+
}
54+
4455
override fun onCleared() {
4556
super.onCleared()
4657
if (_uiState.value.isWaitingForRing) {
@@ -63,8 +74,12 @@ class PubkyChoiceViewModel @Inject constructor(
6374
}
6475

6576
pubkyRepo.startAuthentication()
66-
.onSuccess { authUrl ->
67-
val ringIntent = createRingAuthIntent(authUrl)
77+
.onSuccess { authRequest ->
78+
val callbackAuthUrl = PubkyRingAuthUrlBuilder.addCallbacks(
79+
authUrl = authRequest.authUrl,
80+
nonce = authRequest.callbackNonce,
81+
) ?: authRequest.authUrl
82+
val ringIntent = createRingAuthIntent(callbackAuthUrl)
6883
if (!canOpenWithRing(ringIntent)) {
6984
cancelAuthAndShowRingDialog()
7085
return@launch

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ import to.bitkit.models.NewTransactionSheetType
101101
import to.bitkit.models.NodeLifecycleState
102102
import to.bitkit.models.PubkyProfile
103103
import to.bitkit.models.PubkyPublicKeyFormat
104+
import to.bitkit.models.PubkyRingAuthCallback
105+
import to.bitkit.models.PubkyRingAuthCallbackHandlingResult
104106
import to.bitkit.models.Suggestion
105107
import to.bitkit.models.Toast
106108
import to.bitkit.models.TransactionSpeed
@@ -2677,6 +2679,11 @@ class AppViewModel @Inject constructor(
26772679
return@launch
26782680
}
26792681

2682+
PubkyRingAuthCallback.parse(uri)?.let {
2683+
handlePubkyRingAuthCallback(it)
2684+
return@launch
2685+
}
2686+
26802687
if (uri.scheme == PUBKYAUTH_SCHEME) {
26812688
handlePubkyAuth(uri.toString())
26822689
return@launch
@@ -2698,6 +2705,21 @@ class AppViewModel @Inject constructor(
26982705
showSheet(Sheet.PubkyAuth(authUrl))
26992706
}
27002707

2708+
private suspend fun handlePubkyRingAuthCallback(callback: PubkyRingAuthCallback) {
2709+
when (val result = pubkyRepo.handleAuthCallback(callback)) {
2710+
is PubkyRingAuthCallbackHandlingResult.TrustedError -> {
2711+
ToastEventBus.send(
2712+
type = Toast.ToastType.ERROR,
2713+
title = context.getString(R.string.profile__auth_error_title),
2714+
description = result.message ?: context.getString(R.string.other__qr_error_text),
2715+
)
2716+
}
2717+
PubkyRingAuthCallbackHandlingResult.Handled,
2718+
PubkyRingAuthCallbackHandlingResult.Ignored,
2719+
-> Unit
2720+
}
2721+
}
2722+
27012723
// TODO Temporary fix while these schemes can't be decoded https://github.com/synonymdev/bitkit-core/issues/70
27022724
private fun String.removeLightningSchemes(): String = LIGHTNING_SCHEME_PATTERNS.fold(this) { acc, regex ->
27032725
acc.replace(regex, "")

0 commit comments

Comments
 (0)