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,
+ ),
+ )
+ }
+}