diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt index 4e294ad35..f19f487f4 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/EditTabDialog.kt @@ -201,6 +201,7 @@ private val UiStrings.androidStringRes: Int UiStrings.Password -> R.string.bluesky_login_password_hint UiStrings.Otp -> R.string.bluesky_login_auth_factor_token_hint UiStrings.OAuthLogin -> R.string.bluesky_login_oauth_button + UiStrings.BlueskyFixDelegationScopes -> R.string.bluesky_login_fix_delegation_scopes_button UiStrings.PasswordLogin -> R.string.bluesky_login_use_password_button UiStrings.QrConnect -> R.string.login_button UiStrings.CredentialImport -> R.string.login_button diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7fe85820c..e8908dc83 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -216,6 +216,7 @@ 密码 2FA 验证码 使用 OAuth 登录 + 修复 Tranquil 权限 使用密码 Bluesky OAuth 可能仍不稳定。如果您遇到问题,请使用密码登录。 举报 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 531c803f7..f16350051 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -244,6 +244,7 @@ Password 2FA token Log in with OAuth + Fix Tranquil permissions Use password Bluesky OAuth may still be unstable. If you run into issues, use password login instead. diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift index c6554b2d4..c3be26821 100644 --- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleCore/PresentationMappings.swift @@ -84,6 +84,11 @@ public extension UiStrings { localizedPresentationString("bluesky_login_auth_factor_token_hint", fallback: "One-time Password") case .oauthLogin: localizedPresentationString("bluesky_login_oauth_button", fallback: "OAuth Login") + case .blueskyFixDelegationScopes: + localizedPresentationString( + "bluesky_login_fix_delegation_scopes_button", + fallback: "Fix Tranquil permissions" + ) case .passwordLogin: localizedPresentationString("bluesky_login_use_password_button", fallback: "Password Login") case .qrConnect: diff --git a/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml b/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml index 6f9d97103..ed1b8c8bd 100644 --- a/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -363,6 +363,7 @@ 密码 2FA 令牌 使用 OAuth 登录 + 修复 Tranquil 权限 使用密码 Bluesky OAuth 可能仍不稳定。如果遇到问题,请改用密码登录。 登录 diff --git a/compose-ui/src/commonMain/composeResources/values/strings.xml b/compose-ui/src/commonMain/composeResources/values/strings.xml index d3d3bfb34..506ff46c6 100644 --- a/compose-ui/src/commonMain/composeResources/values/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values/strings.xml @@ -473,6 +473,7 @@ Password 2FA token Log in with OAuth + Fix Tranquil permissions Use password Bluesky OAuth may still be unstable. If you run into issues, use password login instead. Log in diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt index cfad17319..3e4899970 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/TabIcon.kt @@ -20,6 +20,7 @@ import dev.dimension.flare.compose.ui.Res import dev.dimension.flare.compose.ui.all_rss_feeds_title import dev.dimension.flare.compose.ui.antenna_title import dev.dimension.flare.compose.ui.bluesky_login_auth_factor_token_hint +import dev.dimension.flare.compose.ui.bluesky_login_fix_delegation_scopes_button import dev.dimension.flare.compose.ui.bluesky_login_oauth_button import dev.dimension.flare.compose.ui.bluesky_login_password_hint import dev.dimension.flare.compose.ui.bluesky_login_use_password_button @@ -326,4 +327,5 @@ internal val UiStrings.res: StringResource UiStrings.FanboxRecommendedCreators -> Res.string.fanbox_recommended_creators_title UiStrings.PixivPrivateFollowing -> Res.string.pixiv_private_following_title UiStrings.PixivPrivateBookmarks -> Res.string.pixiv_private_bookmarks_title + UiStrings.BlueskyFixDelegationScopes -> Res.string.bluesky_login_fix_delegation_scopes_button } diff --git a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml index e56a05c5a..9c12bb77f 100644 --- a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml +++ b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml @@ -165,6 +165,7 @@ 密码 2FA 验证码 使用 OAuth 登录 + 修复 Tranquil 权限 使用密码 Bluesky OAuth 可能仍不稳定。如果您遇到问题,请改用密码登录。 登录会话已过期 diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index bb1ade9ca..dc70a678f 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -195,6 +195,7 @@ Password 2FA token Log in with OAuth + Fix Tranquil permissions Use password Bluesky OAuth may still be unstable. If you run into issues, use password login instead. diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt index ddada102c..d5470b842 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt @@ -17,6 +17,7 @@ import dev.dimension.flare.Res import dev.dimension.flare.all_rss_feeds_title import dev.dimension.flare.antenna_title import dev.dimension.flare.bluesky_login_2fa +import dev.dimension.flare.bluesky_login_fix_delegation_scopes_button import dev.dimension.flare.bluesky_login_oauth_button import dev.dimension.flare.bluesky_login_password import dev.dimension.flare.bluesky_login_use_password_button @@ -249,6 +250,7 @@ private val UiStrings.desktopStringResource: StringResource UiStrings.Password -> Res.string.bluesky_login_password UiStrings.Otp -> Res.string.bluesky_login_2fa UiStrings.OAuthLogin -> Res.string.bluesky_login_oauth_button + UiStrings.BlueskyFixDelegationScopes -> Res.string.bluesky_login_fix_delegation_scopes_button UiStrings.PasswordLogin -> Res.string.bluesky_login_use_password_button UiStrings.QrConnect -> Res.string.ok UiStrings.CredentialImport -> Res.string.ok diff --git a/feature/login/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/WebLoginFlowPresenter.kt b/feature/login/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/WebLoginFlowPresenter.kt index 768adf0df..d26cc110c 100644 --- a/feature/login/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/WebLoginFlowPresenter.kt +++ b/feature/login/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/WebLoginFlowPresenter.kt @@ -160,5 +160,6 @@ private fun UiStrings.webLabel(): String = UiStrings.CredentialImport -> "Import key" UiStrings.ExternalSigner -> "External signer" UiStrings.WebCookieLogin -> "Cookie login" + UiStrings.BlueskyFixDelegationScopes -> "Fix Tranquil permissions" else -> name } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStrings.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStrings.kt index f19725872..8050dc9ff 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStrings.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiStrings.kt @@ -58,6 +58,7 @@ public enum class UiStrings { FanboxRecommendedCreators, PixivPrivateFollowing, PixivPrivateBookmarks, + BlueskyFixDelegationScopes, } public fun UiStrings.asText(): UiText = UiText.Localized(this) diff --git a/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/data/network/bluesky/TranquilDelegationRepair.kt b/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/data/network/bluesky/TranquilDelegationRepair.kt new file mode 100644 index 000000000..817327a27 --- /dev/null +++ b/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/data/network/bluesky/TranquilDelegationRepair.kt @@ -0,0 +1,206 @@ +package dev.dimension.flare.data.network.bluesky + +import dev.dimension.flare.data.network.ktorClient +import dev.dimension.flare.data.platform.BlueskyCredential +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.http.takeFrom +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import sh.christian.ozone.BlueskyJson +import sh.christian.ozone.oauth.OAuthScope +import sh.christian.ozone.oauth.OAuthToken +import kotlin.io.encoding.Base64 + +internal const val BLUESKY_SCOPE_ATPROTO = "atproto" +internal const val BLUESKY_SCOPE_TRANSITION_CHAT = "transition:chat.bsky" +internal const val BLUESKY_SCOPE_TRANSITION_GENERIC = "transition:generic" + +internal val FLARE_BLUESKY_OAUTH_SCOPES = + listOf( + OAuthScope(BLUESKY_SCOPE_ATPROTO), + OAuthScope(BLUESKY_SCOPE_TRANSITION_CHAT), + OAuthScope(BLUESKY_SCOPE_TRANSITION_GENERIC), + ) + +internal val FLARE_BLUESKY_DELEGATION_SCOPES = + listOf( + BLUESKY_SCOPE_TRANSITION_GENERIC, + BLUESKY_SCOPE_TRANSITION_CHAT, + ) + +internal fun OAuthToken.missingFlareOAuthScopes(): List { + val grantedScopes = scopes.mapTo(mutableSetOf()) { it.value } + return FLARE_BLUESKY_DELEGATION_SCOPES.filterNot(grantedScopes::contains) +} + +internal fun OAuthToken.requireFlareOAuthScopes() { + val missingScopes = missingFlareOAuthScopes() + require(missingScopes.isEmpty()) { + "OAuth token is missing required scope(s): ${missingScopes.joinToString(" ")}" + } +} + +internal fun OAuthToken.delegationControllerDidOrNull(): String? = + runCatching { + val payload = + accessToken + .split('.') + .getOrNull(1) + ?: return null + BlueskyJson + .parseToJsonElement(payload.decodeJwtPayload()) + .jsonObject["act"] + ?.jsonObject + ?.get("sub") + ?.jsonPrimitive + ?.contentOrNull + }.getOrNull() + +internal suspend fun repairTranquilDelegationScopes( + credential: BlueskyCredential.OAuthCredential, + controllerDid: String, +) { + val credentialFlow = MutableStateFlow(credential) + val oauthApi = + sh.christian.ozone.oauth.OAuthApi( + httpClient = + ktorClient { + install(DefaultRequest) { + url.takeFrom(credential.baseUrl) + } + }, + challengeSelector = { OAuthCodeChallengeMethodS256 }, + ) + val client = + ktorClient { + install(DefaultRequest) { + url.takeFrom(credential.baseUrl) + } + install(BlueskyAuthPlugin) { + baseUrlFlow = credentialFlow.map { it.baseUrl } + authTokenFlow = credentialFlow + onAuthTokensChanged = { credentialFlow.value = it } + this.oauthApi = oauthApi + } + expectSuccess = false + } + + try { + val controllersBody = + client + .get("/xrpc/_delegation.listControllers") + .requireTranquilSuccess("list Tranquil delegation controllers") + val grantedScopes = controllersBody.findDelegationControllerScopes(controllerDid) + val updatedScopes = + mergeDelegationScopes( + grantedScopes = grantedScopes, + scopesToAdd = FLARE_BLUESKY_DELEGATION_SCOPES, + ).joinToString(" ") + client + .post("/xrpc/_delegation.updateControllerScopes") { + contentType(ContentType.Application.Json) + setBody( + buildJsonObject { + put("controller_did", JsonPrimitive(controllerDid)) + put("granted_scopes", JsonPrimitive(updatedScopes)) + }.toString(), + ) + }.requireTranquilSuccess("update Tranquil delegation scopes") + } finally { + client.close() + } +} + +internal fun mergeDelegationScopes( + grantedScopes: String, + scopesToAdd: List, +): List { + val merged = + grantedScopes + .split(Regex("\\s+")) + .filterTo(mutableListOf()) { it.isNotBlank() } + val seen = merged.toMutableSet() + scopesToAdd.forEach { scope -> + if (seen.add(scope)) { + merged += scope + } + } + return merged +} + +private suspend fun HttpResponse.requireTranquilSuccess(action: String): String { + val body = bodyAsText() + if (status.isSuccess()) { + return body + } + + val message = + runCatching { + val json = BlueskyJson.parseToJsonElement(body).jsonObject + json.stringValue("message") + ?: json.stringValue("error_description") + ?: json.stringValue("error") + }.getOrNull() + throw IllegalStateException( + buildString { + append("Failed to ") + append(action) + append(": HTTP ") + append(status.value) + if (!message.isNullOrBlank()) { + append(" ") + append(message) + } + }, + ) +} + +private fun String.findDelegationControllerScopes(controllerDid: String): String { + val controllers = + BlueskyJson + .parseToJsonElement(this) + .jsonObject["controllers"] + ?.jsonArray + ?: JsonArray(emptyList()) + val controller = + controllers + .firstOrNull { element -> + element.jsonObject.stringValue("did") == controllerDid + }?.jsonObject + ?: error("Delegation controller $controllerDid was not found on this PDS.") + + return controller.stringValue("grantedScopes") + ?: controller.stringValue("granted_scopes") + ?: "" +} + +private fun JsonObject.stringValue(key: String): String? = + this[key] + ?.jsonPrimitive + ?.contentOrNull + +private fun String.decodeJwtPayload(): String { + val padded = + replace('-', '+') + .replace('_', '/') + .let { value -> + value + "=".repeat((4 - value.length % 4) % 4) + } + return Base64.decode(padded).decodeToString() +} diff --git a/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyLoginProvider.kt b/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyLoginProvider.kt index ea7ddc588..6afd80beb 100644 --- a/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyLoginProvider.kt +++ b/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyLoginProvider.kt @@ -6,7 +6,11 @@ import dev.dimension.flare.data.datastore.PlatformOAuthPending import dev.dimension.flare.data.datastore.PlatformOAuthPendingRepository import dev.dimension.flare.data.network.bluesky.BlueskyPlatformDetector import dev.dimension.flare.data.network.bluesky.BlueskyService +import dev.dimension.flare.data.network.bluesky.FLARE_BLUESKY_OAUTH_SCOPES import dev.dimension.flare.data.network.bluesky.OAuthCodeChallengeMethodS256 +import dev.dimension.flare.data.network.bluesky.delegationControllerDidOrNull +import dev.dimension.flare.data.network.bluesky.missingFlareOAuthScopes +import dev.dimension.flare.data.network.bluesky.repairTranquilDelegationScopes import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.data.network.nodeinfo.PlatformDetector import dev.dimension.flare.data.platform.BlueskyCredential @@ -33,13 +37,13 @@ import sh.christian.ozone.api.response.AtpResponse import sh.christian.ozone.oauth.OAuthApi import sh.christian.ozone.oauth.OAuthAuthorizationRequest import sh.christian.ozone.oauth.OAuthClient -import sh.christian.ozone.oauth.OAuthScope import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds private const val CLIENT_METADATA = "https://flareapp.moe/client-metadata.json" private const val REDIRECT_URI = "https://flareapp.moe/callback" private const val LOGIN_ACTION = "login" +private const val FIX_TRANQUIL_DELEGATION_ACTION = "fix_tranquil_delegation" private const val USERNAME_FIELD = "username" private const val PASSWORD_FIELD = "password" private const val OTP_FIELD = "otp" @@ -277,6 +281,7 @@ private class BlueskyOAuthLoginHandler( private val pendingRepository: PlatformOAuthPendingRepository by koinInject() private val values = mutableMapOf() private var request: OAuthAuthorizationRequest? = null + private var pendingRepair: PendingTranquilDelegationRepair? = null private fun oauthClient(redirectUri: String) = OAuthClient( @@ -299,9 +304,16 @@ private class BlueskyOAuthLoginHandler( } override suspend fun perform(actionId: String) { - if (actionId != LOGIN_ACTION) return + when (actionId) { + LOGIN_ACTION -> startOAuthLogin() + FIX_TRANQUIL_DELEGATION_ACTION -> repairDelegationAndRestartOAuth() + } + } + + private suspend fun startOAuthLogin() { _state.value = oauthState(loading = true) request = null + pendingRepair = null runCatching { val redirectUri = context.redirectUri ?: REDIRECT_URI request = @@ -321,7 +333,39 @@ private class BlueskyOAuthLoginHandler( require(authorizeUrl.isNotEmpty()) { "Invalid authorization request URL" } _effects.emit(LoginEffect.OpenUrl(authorizeUrl)) }.onFailure { - _state.value = oauthState(error = it.message) + _state.value = oauthState(error = errorMessage(it)) + } + } + + private suspend fun repairDelegationAndRestartOAuth() { + val repair = pendingRepair ?: return + _state.value = oauthState(loading = true) + runCatching { + repairTranquilDelegationScopes( + credential = repair.credential, + controllerDid = repair.controllerDid, + ) + pendingRepair = null + request = + login( + host = repair.host, + userName = repair.userName, + redirectUri = repair.redirectUri, + ) + val request = request ?: error("No authorization request") + pendingRepository.save( + request.toPlatformOAuthPending( + host = repair.host, + redirectUri = repair.redirectUri, + ), + ) + val authorizeUrl = request.authorizeRequestUrl + require(authorizeUrl.isNotEmpty()) { "Invalid authorization request URL" } + _effects.emit(LoginEffect.OpenUrl(authorizeUrl)) + }.onSuccess { + _state.value = oauthState() + }.onFailure { + _state.value = oauthState(error = errorMessage(it)) } } @@ -338,17 +382,28 @@ private class BlueskyOAuthLoginHandler( url = value, request = pending.toBlueskyAuthorizationRequest(), redirectUri = pending.attributes["redirect_uri"] ?: REDIRECT_URI, - ) - pendingRepository.clear(pending) - context.onSuccess() + ).let { result -> + pendingRepository.clear(pending) + when (result) { + OAuthResumeResult.Success -> { + context.onSuccess() + } + + is OAuthResumeResult.NeedsTranquilDelegationRepair -> { + pendingRepair = result.repair + _state.value = oauthState(error = result.message) + } + } + } }.onFailure { - _state.value = oauthState(error = it.message) + _state.value = oauthState(error = errorMessage(it)) } } override fun clear() { values.clear() request = null + pendingRepair = null _state.value = oauthState() } @@ -368,13 +423,24 @@ private class BlueskyOAuthLoginHandler( ), ), actions = - listOf( - LoginAction( - id = LOGIN_ACTION, - label = UiStrings.Login, - enabled = !loading && username.isNotBlank(), - ), - ), + buildList { + if (pendingRepair != null) { + add( + LoginAction( + id = FIX_TRANQUIL_DELEGATION_ACTION, + label = UiStrings.BlueskyFixDelegationScopes, + enabled = !loading, + ), + ) + } + add( + LoginAction( + id = LOGIN_ACTION, + label = UiStrings.Login, + enabled = !loading && username.isNotBlank(), + ), + ) + }, loading = loading, error = error, ) @@ -387,12 +453,7 @@ private class BlueskyOAuthLoginHandler( ): OAuthAuthorizationRequest = createOAuthApi(host).buildAuthorizationRequest( oauthClient = oauthClient(redirectUri), - scopes = - listOf( - OAuthScope("atproto"), - OAuthScope("transition:chat.bsky"), - OAuthScope("transition:generic"), - ), + scopes = FLARE_BLUESKY_OAUTH_SCOPES, loginHandleHint = userName.takeIf { !it.contains('@') && it.contains('.') }, ) @@ -400,7 +461,7 @@ private class BlueskyOAuthLoginHandler( url: String, request: OAuthAuthorizationRequest, redirectUri: String, - ) { + ): OAuthResumeResult { val parsedUrl = Url(url) val code = parsedUrl.parameters["code"] val state = parsedUrl.parameters["state"] @@ -419,11 +480,29 @@ private class BlueskyOAuthLoginHandler( nonce = request.nonce, codeVerifier = request.codeVerifier, ) - val credential: BlueskyCredential = + val credential = BlueskyCredential.OAuthCredential( baseUrl = iss, oAuthToken = token, ) + val missingScopes = token.missingFlareOAuthScopes() + if (missingScopes.isNotEmpty()) { + val controllerDid = token.delegationControllerDidOrNull() + if (controllerDid != null) { + return OAuthResumeResult.NeedsTranquilDelegationRepair( + repair = + PendingTranquilDelegationRepair( + credential = credential, + controllerDid = controllerDid, + host = context.host, + userName = values[USERNAME_FIELD].orEmpty(), + redirectUri = redirectUri, + ), + message = tranquilDelegationRepairMessage(missingScopes), + ) + } + error("OAuth token is missing required scope(s): ${missingScopes.joinToString(" ")}") + } accountService.addAccount( account = UiAccount( @@ -437,9 +516,32 @@ private class BlueskyOAuthLoginHandler( credential = credential, serializer = BlueskyCredential.serializer(), ) + return OAuthResumeResult.Success } } +private sealed interface OAuthResumeResult { + data object Success : OAuthResumeResult + + data class NeedsTranquilDelegationRepair( + val repair: PendingTranquilDelegationRepair, + val message: String, + ) : OAuthResumeResult +} + +private data class PendingTranquilDelegationRepair( + val credential: BlueskyCredential.OAuthCredential, + val controllerDid: String, + val host: String, + val userName: String, + val redirectUri: String, +) + +private fun tranquilDelegationRepairMessage(missingScopes: List): String = + "This Tranquil delegated account did not grant Flare the required Bluesky API scope(s): " + + missingScopes.joinToString(" ") + + ". Tap Fix Tranquil permissions, then authorize Flare again." + private fun createOAuthApi(host: String): OAuthApi = OAuthApi( ktorClient { diff --git a/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyOAuthLoginPresenter.kt b/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyOAuthLoginPresenter.kt index 587db8b4f..0a9e099ae 100644 --- a/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyOAuthLoginPresenter.kt +++ b/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyOAuthLoginPresenter.kt @@ -8,7 +8,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import dev.dimension.flare.data.datastore.PlatformOAuthPending import dev.dimension.flare.data.datastore.PlatformOAuthPendingRepository +import dev.dimension.flare.data.network.bluesky.FLARE_BLUESKY_OAUTH_SCOPES import dev.dimension.flare.data.network.bluesky.OAuthCodeChallengeMethodS256 +import dev.dimension.flare.data.network.bluesky.requireFlareOAuthScopes import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.data.platform.BlueskyCredential import dev.dimension.flare.data.repository.AccountService @@ -26,7 +28,6 @@ import kotlinx.coroutines.withContext import sh.christian.ozone.oauth.OAuthApi import sh.christian.ozone.oauth.OAuthAuthorizationRequest import sh.christian.ozone.oauth.OAuthClient -import sh.christian.ozone.oauth.OAuthScope import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds @@ -134,12 +135,7 @@ internal class BlueskyOAuthLoginPresenter( ): OAuthAuthorizationRequest = createOAuthApi(host).buildAuthorizationRequest( oauthClient = oauthClient, - scopes = - listOf( - OAuthScope("atproto"), - OAuthScope("transition:chat.bsky"), - OAuthScope("transition:generic"), - ), + scopes = FLARE_BLUESKY_OAUTH_SCOPES, loginHandleHint = userName.takeIf { !it.contains('@') && it.contains('.') }, ) @@ -165,6 +161,7 @@ internal class BlueskyOAuthLoginPresenter( nonce = request.nonce, codeVerifier = request.codeVerifier, ) + token.requireFlareOAuthScopes() val credential: BlueskyCredential = BlueskyCredential.OAuthCredential( baseUrl = iss, diff --git a/social/bluesky/src/commonTest/kotlin/dev/dimension/flare/data/network/bluesky/TranquilDelegationRepairTest.kt b/social/bluesky/src/commonTest/kotlin/dev/dimension/flare/data/network/bluesky/TranquilDelegationRepairTest.kt new file mode 100644 index 000000000..14dcee4d5 --- /dev/null +++ b/social/bluesky/src/commonTest/kotlin/dev/dimension/flare/data/network/bluesky/TranquilDelegationRepairTest.kt @@ -0,0 +1,37 @@ +package dev.dimension.flare.data.network.bluesky + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TranquilDelegationRepairTest { + @Test + fun mergeDelegationScopesAppendsMissingScopes() { + assertEquals( + listOf( + "atproto", + "repo:*?action=create", + "transition:generic", + "transition:chat.bsky", + ), + mergeDelegationScopes( + grantedScopes = "atproto repo:*?action=create", + scopesToAdd = FLARE_BLUESKY_DELEGATION_SCOPES, + ), + ) + } + + @Test + fun mergeDelegationScopesDoesNotDuplicateScopes() { + assertEquals( + listOf( + "atproto", + "transition:generic", + "transition:chat.bsky", + ), + mergeDelegationScopes( + grantedScopes = "atproto transition:generic transition:chat.bsky", + scopesToAdd = FLARE_BLUESKY_DELEGATION_SCOPES, + ), + ) + } +}