Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@
<string name="bluesky_login_password_hint">密码</string>
<string name="bluesky_login_auth_factor_token_hint">2FA 验证码</string>
<string name="bluesky_login_oauth_button">使用 OAuth 登录</string>
<string name="bluesky_login_fix_delegation_scopes_button">修复 Tranquil 权限</string>
<string name="bluesky_login_use_password_button">使用密码</string>
<string name="bluesky_login_oauth_hint">Bluesky OAuth 可能仍不稳定。如果您遇到问题,请使用密码登录。</string>
<string name="report_title">举报</string>
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@
<string name="bluesky_login_password_hint">Password</string>
<string name="bluesky_login_auth_factor_token_hint">2FA token</string>
<string name="bluesky_login_oauth_button">Log in with OAuth</string>
<string name="bluesky_login_fix_delegation_scopes_button">Fix Tranquil permissions</string>
<string name="bluesky_login_use_password_button">Use password</string>
<string name="bluesky_login_oauth_hint">Bluesky OAuth may still be unstable. If you run into issues, use password login instead.</string>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@
<string name="bluesky_login_password_hint">密码</string>
<string name="bluesky_login_auth_factor_token_hint">2FA 令牌</string>
<string name="bluesky_login_oauth_button">使用 OAuth 登录</string>
<string name="bluesky_login_fix_delegation_scopes_button">修复 Tranquil 权限</string>
<string name="bluesky_login_use_password_button">使用密码</string>
<string name="bluesky_login_oauth_hint">Bluesky OAuth 可能仍不稳定。如果遇到问题,请改用密码登录。</string>
<string name="login_button">登录</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@
<string name="bluesky_login_password_hint">Password</string>
<string name="bluesky_login_auth_factor_token_hint">2FA token</string>
<string name="bluesky_login_oauth_button">Log in with OAuth</string>
<string name="bluesky_login_fix_delegation_scopes_button">Fix Tranquil permissions</string>
<string name="bluesky_login_use_password_button">Use password</string>
<string name="bluesky_login_oauth_hint">Bluesky OAuth may still be unstable. If you run into issues, use password login instead.</string>
<string name="login_button">Log in</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
<string name="bluesky_login_password">密码</string>
<string name="bluesky_login_2fa">2FA 验证码</string>
<string name="bluesky_login_oauth_button">使用 OAuth 登录</string>
<string name="bluesky_login_fix_delegation_scopes_button">修复 Tranquil 权限</string>
<string name="bluesky_login_use_password_button">使用密码</string>
<string name="bluesky_login_oauth_hint">Bluesky OAuth 可能仍不稳定。如果您遇到问题,请改用密码登录。</string>
<string name="login_expired">登录会话已过期</string>
Expand Down
1 change: 1 addition & 0 deletions desktopApp/src/main/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
<string name="bluesky_login_password">Password</string>
<string name="bluesky_login_2fa">2FA token</string>
<string name="bluesky_login_oauth_button">Log in with OAuth</string>
<string name="bluesky_login_fix_delegation_scopes_button">Fix Tranquil permissions</string>
<string name="bluesky_login_use_password_button">Use password</string>
<string name="bluesky_login_oauth_hint">Bluesky OAuth may still be unstable. If you run into issues, use password login instead.</string>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public enum class UiStrings {
FanboxRecommendedCreators,
PixivPrivateFollowing,
PixivPrivateBookmarks,
BlueskyFixDelegationScopes,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the new UiStrings case to desktop mapping

Adding this enum value makes every exhaustive when over UiStrings cover it. I checked desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/EditTabDialog.kt:219-272, and that desktop mapping still ends at PixivPrivateBookmarks without an else, so the desktop target won't compile once it sees BlueskyFixDelegationScopes. Please add the desktop resource/mapping alongside this enum.

Useful? React with 👍 / 👎.

}

public fun UiStrings.asText(): UiText = UiText.Localized(this)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> {
val grantedScopes = scopes.mapTo(mutableSetOf()) { it.value }
return FLARE_BLUESKY_DELEGATION_SCOPES.filterNot(grantedScopes::contains)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the atproto OAuth scope too

When an OAuth server grants transition:generic transition:chat.bsky but omits atproto, this helper reports no missing scopes because it only checks the delegation subset. The ATProto OAuth profile says clients should reject token responses whose scope does not contain atproto (https://atproto.com/specs/oauth), so resumeOAuth can store a session that is not a valid atproto OAuth session and later PDS calls may fail; keep the repair list separate but validate against all required OAuth scopes.

Useful? React with 👍 / 👎.

}

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<BlueskyCredential>(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<String>,
): List<String> {
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()
}
Loading
Loading