fall back to password auth when the server rejects the token scheme

Navidrome refuses the Subsonic token scheme for LDAP users with error code 41. Catch it
once in SubsonicClient: flip that client to the password scheme, tell SourceRepository to
persist the change for the source and show a toast, then retry the request. Only 41 is
retried, the flip is idempotent, and a client never retries more than once.
This commit is contained in:
austinried
2026-10-04 00:29:59 +00:00
parent 90171468cc
commit 59706f28c1
5 changed files with 124 additions and 11 deletions
@@ -38,6 +38,9 @@ interface SourcesDao {
@Upsert
suspend fun upsertSubsonicSource(subsonic: SubsonicSource)
@Query("UPDATE subsonic_sources SET useTokenAuth = 0 WHERE sourceId = :sourceId")
suspend fun disableTokenAuth(sourceId: Long)
@Query("DELETE FROM sources WHERE id = :id")
suspend fun deleteSourceRow(id: Long)
@@ -25,6 +25,7 @@ class SourceRepository(
private val db: SubtracksDatabase,
private val http: OkHttpClient,
private val prefs: UserPreferences,
private val showMessage: (String) -> Unit = {},
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -126,22 +127,38 @@ class SourceRepository(
): SubsonicMusicSource =
SubsonicMusicSource(
id = id,
client = client(address, username, password, useTokenAuth),
client =
client(
address = address,
username = username,
password = password,
useTokenAuth = useTokenAuth,
onTokenAuthUnsupported = { disableTokenAuth(id) },
),
maxBitrate = maxBitrate,
streamFormat = streamFormat,
)
private fun disableTokenAuth(sourceId: Long) {
scope.launch {
db.sourcesDao().disableTokenAuth(sourceId)
showMessage("Server does not support token auth; using the password instead")
}
}
private fun client(
address: String,
username: String,
password: String,
useTokenAuth: Boolean,
onTokenAuthUnsupported: (() -> Unit)? = null,
) = SubsonicClient(
baseUrl = normalizeAddress(address).toHttpUrl(),
username = username,
password = password,
useTokenAuth = useTokenAuth,
http = http,
onTokenAuthUnsupported = onTokenAuthUnsupported,
)
private fun normalizeAddress(address: String): String {
@@ -15,10 +15,14 @@ class SubsonicClient(
private val baseUrl: HttpUrl,
private val username: String,
private val password: String,
private val useTokenAuth: Boolean,
useTokenAuth: Boolean,
private val http: OkHttpClient,
private val userAgent: String = "subtracks/android",
private val onTokenAuthUnsupported: (() -> Unit)? = null,
) {
@Volatile
private var useTokenAuth: Boolean = useTokenAuth
fun uri(
method: String,
params: Map<String, String> = emptyMap(),
@@ -49,6 +53,19 @@ class SubsonicClient(
method: String,
params: Map<String, String> = emptyMap(),
body: (InputStream) -> T,
): T =
try {
execute(method, params, body)
} catch (failure: SubsonicException) {
if (failure.code != TOKEN_AUTH_UNSUPPORTED || !useTokenAuth) throw failure
disableTokenAuth()
execute(method, params, body)
}
private fun <T> execute(
method: String,
params: Map<String, String>,
body: (InputStream) -> T,
): T {
val request =
Request
@@ -64,9 +81,17 @@ class SubsonicClient(
}
}
@Synchronized
private fun disableTokenAuth() {
if (!useTokenAuth) return
useTokenAuth = false
onTokenAuthUnsupported?.invoke()
}
companion object {
const val API_VERSION = "1.13.0"
const val CLIENT = "subtracks"
const val TOKEN_AUTH_UNSUPPORTED = 41
private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("")
@@ -1,6 +1,9 @@
package com.subtracks.di
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.prefs.createUserPreferences
import com.subtracks.data.repo.ArtworkSeedRepository
@@ -28,10 +31,20 @@ fun appModule(
context: Context,
http: OkHttpClient,
) = module {
val mainHandler = Handler(Looper.getMainLooper())
single { createAndroidDatabase(context) }
single { http }
single { createUserPreferences(context) }
single { SourceRepository(get(), get(), get()) }
single {
SourceRepository(
get(),
get(),
get(),
showMessage = { message ->
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
},
)
}
single { LibraryRepository(get(), get()) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get()) }
@@ -25,14 +25,22 @@ class SubsonicClientTest {
server.shutdown()
}
private fun client(tokenAuth: Boolean) =
SubsonicClient(
baseUrl = server.url("/"),
username = "guest",
password = "secret",
useTokenAuth = tokenAuth,
http = OkHttpClient(),
)
private fun client(
tokenAuth: Boolean,
onTokenAuthUnsupported: (() -> Unit)? = null,
) = SubsonicClient(
baseUrl = server.url("/"),
username = "guest",
password = "secret",
useTokenAuth = tokenAuth,
http = OkHttpClient(),
onTokenAuthUnsupported = onTokenAuthUnsupported,
)
private fun failed(
code: Int,
message: String,
) = "<subsonic-response status=\"failed\"><error code=\"$code\" message=\"$message\"/></subsonic-response>"
@Test
fun tokenAuthSendsSaltAndHash() {
@@ -90,4 +98,51 @@ class SubsonicClientTest {
assertThrows(SubsonicException::class.java) { client(tokenAuth = true).check("ping") }
}
@Test
fun tokenAuthUnsupportedRetriesWithPassword() {
server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users.")))
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
var notified = 0
client(tokenAuth = true, onTokenAuthUnsupported = { notified++ }).check("ping")
val first = server.takeRequest().requestUrl!!
assertNotNull(first.queryParameter("t"))
assertNull(first.queryParameter("p"))
val second = server.takeRequest().requestUrl!!
assertNull(second.queryParameter("t"))
assertEquals("secret", second.queryParameter("p"))
assertEquals(1, notified)
}
@Test
fun tokenAuthUnsupportedIsNotifiedOnceAndNotRetriedAgain() {
server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users.")))
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users.")))
var notified = 0
val client = client(tokenAuth = true, onTokenAuthUnsupported = { notified++ })
client.check("ping")
assertThrows(SubsonicException::class.java) { client.check("ping") }
assertEquals(1, notified)
assertEquals(3, server.requestCount)
}
@Test
fun otherErrorsAreNotRetried() {
server.enqueue(MockResponse().setBody(failed(40, "Wrong username")))
var notified = 0
val error =
assertThrows(SubsonicException::class.java) {
client(tokenAuth = true, onTokenAuthUnsupported = { notified++ }).check("ping")
}
assertEquals(40, error.code)
assertEquals(0, notified)
assertEquals(1, server.requestCount)
}
}