Fix unhelpful generic 401 error on AniList expiry (#3888)

A leftover of what I assume was the v1 API behaviour handling manually
multiplied the `expires` values by 1,000, supposedly to make it a
millisecond value. However, AL tokens have been saved as milliseconds
since at least the v2 API implementation in 2018, so this has made
every expiry comparison ludicrously impossible.

Example:
millisecond timestamp:
1788529931115 (2026-09-04T13:52:11.115Z)
*1,000
1788529931115000 (+58646-04-04T21:45:15Z)

So any expiry comparisons would say "yes this token is still totally
valid", causing Mihon to send expired credentials and AL to respond
with a 401.

With these changes (which include some loosely associated clean-up),
we now correctly identify expired credentials before we attempt any AL
requests & actually show a toast with the "Token expired." error
message for the first time in 8 years or so?

I was able to keep the new ALOAuth shape compatible with
already-serialized data, though whenever a user (re-)links AL from now
on, both the `token_type` and `expiresIn` fields won't exist any more.
Since they had no use in the current code anyway, I don't consider
this a problem.

I took the liberty of adding basic instructions to the error message
but these can't be i18n'd properly so I kept it short and sweet.

---

Side effect of expired credentials is Mihon erasing the token in
storage, meaning AL disappears from the tracker sheet until re-linked.
I would say this is acceptable, but we might see panicked users in
support saying "Mihon deleted my AL links" or something.
This commit is contained in:
MajorTanya
2026-09-04 17:07:05 +02:00
committed by GitHub
parent 056d9baeeb
commit 21af65b100
5 changed files with 13 additions and 29 deletions
+1
View File
@@ -20,6 +20,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Show updates and upcoming filter icon as active for categories ([@Secozzi](https://github.com/Secozzi)) ([#3772](https://github.com/mihonapp/mihon/pull/3772))
- Show scores in MangaUpdates search results (and authors for `id:` prefix searches) ([@MajorTanya](https://github.com/MajorTanya)) ([#3795](https://github.com/mihonapp/mihon/pull/3795))
- Remove whitespace from MAL and MB `id:` prefix search inputs before searching ([@MajorTanya](https://github.com/MajorTanya)) ([#3793](https://github.com/mihonapp/mihon/pull/3793))
- Show a helpful error message for expired AniList credentials ([@MajorTanya](https://github.com/MajorTanya)) ([#3888](https://github.com/mihonapp/mihon/pull/3888))
### Fixed
- Fixed app and extension update check running again on configuration change ([@AntsyLich](https://github.com/AntsyLich)) ([#3708](https://github.com/mihonapp/mihon/pull/3708))
@@ -218,7 +218,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
suspend fun login(token: String) {
try {
val oauth = api.createOAuth(token)
val oauth = ALOAuth(token)
interceptor.setAuth(oauth)
val currentUser = api.getCurrentUser()
scorePreference.set(currentUser.mediaListOptions.scoreFormat)
@@ -5,7 +5,6 @@ import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
import eu.kanade.tachiyomi.data.track.anilist.dto.ALSearchResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserListMangaQueryResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserViewerData
@@ -283,10 +282,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
return findLibManga(track, userId) ?: throw Exception("Could not find manga")
}
fun createOAuth(token: String): ALOAuth {
return ALOAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
}
suspend fun getCurrentUser(): ALUserViewerData {
return withIOContext {
val query = """
@@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.data.track.anilist
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
import eu.kanade.tachiyomi.data.track.anilist.dto.isExpired
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
@@ -11,14 +10,8 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
/**
* OAuth object used for authenticated requests.
*
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date.
*/
private var oauth: ALOAuth? = null
set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
@@ -27,17 +20,11 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
throw Exception("Not authenticated with Anilist")
}
if (oauth == null) {
oauth = anilist.loadOAuth()
oauth = anilist.loadOAuth() ?: throw IOException("No authentication token")
}
// Refresh access token if null or expired.
if (oauth!!.isExpired()) {
anilist.logout()
throw IOException("Token expired")
}
// Throw on null auth.
if (oauth == null) {
throw IOException("No authentication token")
throw IOException("Token expired. Reconnect AniList in Settings.")
}
// Add the authorization header to the original request.
@@ -1,17 +1,18 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
@Serializable
data class ALOAuth(
@SerialName("access_token")
val accessToken: String,
@SerialName("token_type")
val tokenType: String,
val expires: Long,
@SerialName("expires_in")
val expiresIn: Long,
)
fun ALOAuth.isExpired() = System.currentTimeMillis() > expires
@EncodeDefault
val expires: Long = Clock.System.now().plus(365.days).toEpochMilliseconds(),
) {
fun isExpired() = Clock.System.now().plus(1.minutes).toEpochMilliseconds() > expires
}