fix(trakt): invalidate rejected refresh tokens

This commit is contained in:
tapframe 2026-07-17 23:29:03 +05:30
parent c68d996881
commit 0cb0e20918
6 changed files with 102 additions and 208 deletions

View file

@ -1468,6 +1468,7 @@
<string name="stream_default_name">Stream</string>
<string name="source_embedded">Embedded</string>
<string name="trakt_authorization_denied">Authorization denied</string>
<string name="trakt_authorization_expired_reconnect">Your Trakt authorization is no longer valid. Reconnect your Trakt account.</string>
<string name="trakt_complete_sign_in_browser">Complete Trakt sign in in your browser</string>
<string name="trakt_connected">Connected to Trakt</string>
<string name="trakt_disconnected">Disconnected from Trakt</string>

View file

@ -12,7 +12,6 @@ import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCredentialSync
import com.nuvio.app.features.trakt.TraktPlatformClock
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode
@ -39,7 +38,6 @@ internal enum class ProfileSyncStep {
Addons,
Plugins,
ProfileSettings,
TraktCredentials,
Library,
ActiveWatchSource,
Collections,
@ -50,7 +48,6 @@ internal data class ProfileSyncOperations(
val pullAddons: suspend (Int) -> Unit,
val pullPlugins: suspend (Int) -> Unit,
val pullProfileSettings: suspend (Int) -> Unit,
val pullTraktCredentials: suspend (Int) -> Unit,
val pullLibrary: suspend (Int) -> Unit,
val refreshActiveWatchSource: suspend (Int) -> Unit,
val pullCollections: suspend (Int) -> Unit,
@ -94,16 +91,7 @@ internal suspend fun runOrderedProfileSync(
runStep(ProfileSyncStep.Plugins, operations.pullPlugins)
}
coroutineScope {
val settingsJob = launch {
runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings)
}
val credentialsJob = launch {
runStep(ProfileSyncStep.TraktCredentials, operations.pullTraktCredentials)
}
settingsJob.join()
credentialsJob.join()
}
runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings)
coroutineScope {
launch {
@ -229,7 +217,6 @@ object SyncManager {
pullAddons = { profileId -> AddonRepository.pullFromServer(profileId) },
pullPlugins = { profileId -> PluginRepository.pullFromServer(profileId) },
pullProfileSettings = { profileId -> ProfileSettingsSync.pull(profileId) },
pullTraktCredentials = { profileId -> TraktCredentialSync.pullFromRemoteOrThrow(profileId) },
pullLibrary = { profileId -> LibraryRepository.pullFromServer(profileId) },
refreshActiveWatchSource = { profileId ->
val result = WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true)

View file

@ -3,6 +3,8 @@ package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.profiles.ProfileRepository
import io.ktor.http.Url
import io.ktor.http.encodeURLParameter
import kotlinx.coroutines.CancellationException
@ -13,6 +15,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
@ -35,6 +39,7 @@ object TraktAuthRepository {
encodeDefaults = true
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val refreshMutex = Mutex()
private val _uiState = MutableStateFlow(TraktAuthUiState())
val uiState: StateFlow<TraktAuthUiState> = _uiState.asStateFlow()
@ -65,24 +70,6 @@ object TraktAuthRepository {
return _uiState.value
}
internal fun currentStateForSync(): TraktAuthState {
ensureLoaded()
return authState
}
internal fun replaceStateFromSync(state: TraktAuthState): Boolean {
ensureLoaded()
val syncedState = state.copy(
pendingAuthorizationState = null,
pendingAuthorizationStartedAtMillis = null,
)
if (authState.syncSignature() == syncedState.syncSignature()) return false
authState = syncedState
persist()
publish(statusMessage = null, errorMessage = null)
return true
}
fun hasRequiredCredentials(): Boolean =
TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank()
@ -299,7 +286,6 @@ object TraktAuthRepository {
)
persist()
refreshUserSettings()
TraktCredentialSync.pushCurrentToRemote()
publish(
isLoading = false,
statusMessage = localizedString(Res.string.trakt_connected_status),
@ -341,12 +327,14 @@ object TraktAuthRepository {
)
}
private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean {
if (!hasRequiredCredentials()) return false
val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() } ?: return false
private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean = refreshMutex.withLock {
if (!hasRequiredCredentials()) return@withLock false
val profileId = ProfileRepository.activeProfileId
val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() }
?: return@withLock false
if (!force && !isTokenExpiredOrExpiring(authState)) {
return true
return@withLock true
}
val body = json.encodeToString(
@ -359,29 +347,42 @@ object TraktAuthRepository {
)
val response = runCatching {
httpPostJsonWithHeaders(
httpRequestRaw(
method = "POST",
url = "$BASE_URL/oauth/token",
body = body,
headers = emptyMap(),
headers = mapOf(
"Accept" to "application/json",
"Content-Type" to "application/json",
),
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.w { "Trakt token refresh failed: ${error.message}" }
}.getOrNull()
log.w { "Trakt token refresh transport failure: ${error.message}" }
}.getOrNull() ?: return@withLock false
if (response == null) {
if (recoverFromRemoteCredentials(refreshToken)) return true
return false
if (ProfileRepository.activeProfileId != profileId || authState.refreshToken != refreshToken) {
return@withLock false
}
when (traktTokenRefreshResponseAction(response.status)) {
TraktTokenRefreshResponseAction.INVALIDATE -> {
log.w { "Trakt rejected the refresh token with HTTP 400; clearing local credentials" }
invalidateCredentials(profileId)
return@withLock false
}
TraktTokenRefreshResponseAction.TRANSIENT_FAILURE -> {
log.w { "Trakt token refresh failed with HTTP ${response.status}" }
return@withLock false
}
TraktTokenRefreshResponseAction.ACCEPT -> Unit
}
val parsed = runCatching {
json.decodeFromString<TraktTokenResponse>(response)
}.getOrNull()
if (parsed == null) {
if (recoverFromRemoteCredentials(refreshToken)) return true
return false
}
json.decodeFromString<TraktTokenResponse>(response.body)
}.getOrNull() ?: return@withLock false
authState = authState.copy(
accessToken = parsed.accessToken,
@ -391,9 +392,19 @@ object TraktAuthRepository {
expiresIn = parsed.expiresIn,
)
persist()
TraktCredentialSync.pushCurrentToRemote()
publish()
return true
true
}
private suspend fun invalidateCredentials(profileId: Int) {
authState = TraktAuthState()
persist()
publish(
isLoading = false,
statusMessage = null,
errorMessage = localizedString(Res.string.trakt_authorization_expired_reconnect),
)
TraktCredentialSync.deleteRemote(profileId)
}
private fun loadFromDisk() {
@ -475,22 +486,18 @@ object TraktAuthRepository {
return nowSeconds >= (expiresAtSeconds - 60)
}
private suspend fun recoverFromRemoteCredentials(staleRefreshToken: String): Boolean {
val pulled = TraktCredentialSync.pullFromRemote()
if (!pulled) return false
return authState.isAuthenticated && authState.refreshToken != staleRefreshToken
}
}
private fun TraktAuthState.syncSignature(): String =
listOf(
accessToken.orEmpty(),
refreshToken.orEmpty(),
tokenType.orEmpty(),
createdAt?.toString().orEmpty(),
expiresIn?.toString().orEmpty(),
username.orEmpty(),
userSlug.orEmpty(),
).joinToString("|")
internal enum class TraktTokenRefreshResponseAction {
ACCEPT,
INVALIDATE,
TRANSIENT_FAILURE,
}
internal fun traktTokenRefreshResponseAction(status: Int): TraktTokenRefreshResponseAction = when {
status == 400 -> TraktTokenRefreshResponseAction.INVALIDATE
status in 200..299 -> TraktTokenRefreshResponseAction.ACCEPT
else -> TraktTokenRefreshResponseAction.TRANSIENT_FAILURE
}
@Serializable

View file

@ -8,100 +8,17 @@ import com.nuvio.app.core.sync.putSyncOriginClientId
import com.nuvio.app.features.profiles.ProfileRepository
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
private const val TRAKT_PROVIDER = "trakt"
private const val TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS = 86_400
@Serializable
private data class ProviderCredentialRow(
val provider: String,
@SerialName("credential_json") val credentialJson: JsonObject,
@SerialName("updated_at") val updatedAt: String? = null,
)
object TraktCredentialSync {
private val log = Logger.withTag("TraktCredentialSync")
private val mutex = Mutex()
suspend fun pushCurrentToRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean =
mutex.withLock {
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false
val state = TraktAuthRepository.currentStateForSync()
val credentialJson = state.toCredentialJson() ?: return@withLock false
runCatching {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_credentials", buildJsonArray {
addJsonObject {
put("provider", TRAKT_PROVIDER)
put("credential_json", credentialJson)
}
})
putSyncOriginClientId()
}
SupabaseProvider.client.postgrest.rpc("sync_push_provider_credentials", params)
true
}.getOrElse { error ->
log.e(error) { "pushCurrentToRemote(profileId=$profileId) failed" }
false
}
}
suspend fun pullFromRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean =
try {
pullFromRemoteOrThrow(profileId)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "pullFromRemote(profileId=$profileId) failed" }
false
}
internal suspend fun pullFromRemoteOrThrow(
profileId: Int = ProfileRepository.activeProfileId,
): Boolean = mutex.withLock {
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false
val accountId = authState.userId
if (ProfileRepository.activeProfileId != profileId) return@withLock false
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_provider_credentials", params)
val currentAuthState = AuthRepository.state.value
if (
currentAuthState !is AuthState.Authenticated ||
currentAuthState.isAnonymous ||
currentAuthState.userId != accountId ||
ProfileRepository.activeProfileId != profileId
) {
throw CancellationException("Trakt credential pull target changed")
}
val rows = result.decodeList<ProviderCredentialRow>()
val row = rows.firstOrNull { it.provider.equals(TRAKT_PROVIDER, ignoreCase = true) }
?: return@withLock false
val remoteState = row.credentialJson.toTraktAuthState()
?: error("Remote Trakt credential payload is invalid")
TraktAuthRepository.replaceStateFromSync(remoteState)
}
suspend fun deleteRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean =
mutex.withLock {
val authState = AuthRepository.state.value
@ -121,45 +38,3 @@ object TraktCredentialSync {
}
}
}
private fun TraktAuthState.toCredentialJson(): JsonObject? {
val accessTokenValue = accessToken?.takeIf { it.isNotBlank() } ?: return null
val refreshTokenValue = refreshToken?.takeIf { it.isNotBlank() } ?: return null
return buildJsonObject {
put("access_token", accessTokenValue)
put("refresh_token", refreshTokenValue)
put("token_type", tokenType ?: "bearer")
put("created_at", createdAt ?: (TraktPlatformClock.nowEpochMs() / 1_000L))
put("expires_in", normalizeTraktTokenLifetime(expiresIn ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS))
username?.takeIf { it.isNotBlank() }?.let { put("username", it) }
userSlug?.takeIf { it.isNotBlank() }?.let { put("user_slug", it) }
}
}
private fun JsonObject.toTraktAuthState(): TraktAuthState? {
val accessTokenValue = stringValue("access_token")?.takeIf { it.isNotBlank() } ?: return null
val refreshTokenValue = stringValue("refresh_token")?.takeIf { it.isNotBlank() } ?: return null
return TraktAuthState(
accessToken = accessTokenValue,
refreshToken = refreshTokenValue,
tokenType = stringValue("token_type") ?: "bearer",
createdAt = longValue("created_at") ?: (TraktPlatformClock.nowEpochMs() / 1_000L),
expiresIn = normalizeTraktTokenLifetime(intValue("expires_in") ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS),
username = stringValue("username"),
userSlug = stringValue("user_slug"),
)
}
private fun normalizeTraktTokenLifetime(expiresIn: Int): Int {
if (expiresIn <= 0) return TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS
return expiresIn.coerceAtMost(TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS)
}
private fun JsonObject.stringValue(key: String): String? =
this[key]?.jsonPrimitive?.contentOrNull
private fun JsonObject.longValue(key: String): Long? =
this[key]?.jsonPrimitive?.longOrNull
private fun JsonObject.intValue(key: String): Int? =
this[key]?.jsonPrimitive?.intOrNull

View file

@ -14,7 +14,6 @@ class SyncManagerTest {
fun `source prerequisites finish before source dependent pulls`() = runBlocking {
val events = mutableListOf<String>()
var profileSettingsApplied = false
var traktCredentialsApplied = false
runOrderedProfileSync(
profileId = 7,
@ -28,20 +27,12 @@ class SyncManagerTest {
profileSettingsApplied = true
events += "settings:end"
},
pullTraktCredentials = {
events += "credentials:start"
yield()
traktCredentialsApplied = true
events += "credentials:end"
},
pullLibrary = {
assertTrue(profileSettingsApplied)
assertTrue(traktCredentialsApplied)
events += "library"
},
refreshActiveWatchSource = {
assertTrue(profileSettingsApplied)
assertTrue(traktCredentialsApplied)
events += "active-watch-source"
},
pullCollections = { events += "collections" },
@ -50,10 +41,7 @@ class SyncManagerTest {
onFailure = { _, error -> throw error },
)
val lastPrerequisite = maxOf(
events.indexOf("settings:end"),
events.indexOf("credentials:end"),
)
val lastPrerequisite = events.indexOf("settings:end")
assertTrue(events.indexOf("library") > lastPrerequisite)
assertTrue(events.indexOf("active-watch-source") > lastPrerequisite)
assertEquals(1, events.count { it == "active-watch-source" })
@ -72,7 +60,7 @@ class SyncManagerTest {
assertTrue("plugins" !in events)
assertTrue(events.indexOf("settings") < events.indexOf("library"))
assertTrue(events.indexOf("credentials") < events.indexOf("active-watch-source"))
assertTrue(events.indexOf("settings") < events.indexOf("active-watch-source"))
}
@Test
@ -175,7 +163,6 @@ class SyncManagerTest {
pullAddons = { events += "addons" },
pullPlugins = { events += "plugins" },
pullProfileSettings = { events += "settings" },
pullTraktCredentials = { events += "credentials" },
pullLibrary = { events += "library" },
refreshActiveWatchSource = { events += "active-watch-source" },
pullCollections = { events += "collections" },

View file

@ -0,0 +1,37 @@
package com.nuvio.app.features.trakt
import kotlin.test.Test
import kotlin.test.assertEquals
class TraktAuthRepositoryTest {
@Test
fun `HTTP 400 permanently invalidates a rejected refresh token`() {
assertEquals(
TraktTokenRefreshResponseAction.INVALIDATE,
traktTokenRefreshResponseAction(400),
)
}
@Test
fun `successful token responses are accepted`() {
assertEquals(
TraktTokenRefreshResponseAction.ACCEPT,
traktTokenRefreshResponseAction(200),
)
assertEquals(
TraktTokenRefreshResponseAction.ACCEPT,
traktTokenRefreshResponseAction(201),
)
}
@Test
fun `non-400 failures preserve credentials for a later attempt`() {
listOf(401, 429, 500, 503).forEach { status ->
assertEquals(
TraktTokenRefreshResponseAction.TRANSIENT_FAILURE,
traktTokenRefreshResponseAction(status),
)
}
}
}