fix: derive JWT iat from server clock

Shaarli rejects tokens whose iat falls outside its clock
tolerance, so device/server skew caused HTTP 401 logins
even with a correct API secret. Estimate server time from
the Date response header, retry a 401 once with a
recalibrated token, trim credentials on login, and explain
the API-secret vs password mismatch in French error
messages.

Bump version to 2.14.1 (code 42).
This commit is contained in:
2026-10-07 16:33:24 -04:00
parent 40944a9a55
commit 8f90d5b3b8
6 changed files with 146 additions and 18 deletions
@@ -4,32 +4,58 @@ import com.shaarit.core.storage.TokenManager
import com.shaarit.core.util.JwtGenerator
import javax.inject.Inject
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager,
private val sessionManager: SessionManager
private val sessionManager: SessionManager,
private val serverClock: ServerClock
) : Interceptor {
companion object {
// Shaarli tokens carry an expiry; staying a minute "old" is safer than risking a
// future-dated iat if our clock estimate is slightly off.
private const val IAT_GRACE_SECONDS = 60L
}
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val builder = original.newBuilder()
// Shaarli requires a fresh JWT token generated from the API secret for each request
// The token is valid for 9 minutes, so we generate a new one for each request
// Shaarli requires a fresh JWT token generated from the API secret for each request.
val apiSecret = tokenManager.getApiSecret()
if (!apiSecret.isNullOrBlank()) {
val jwtToken = JwtGenerator.generateToken(apiSecret)
builder.header("Authorization", "Bearer $jwtToken")
if (apiSecret.isNullOrBlank()) {
return chain.proceed(original)
}
val response = chain.proceed(builder.build())
// Remember whether the first token was generated before we knew the server time. If
// so, a 401 is ambiguous: bad secret, or device clock skew pushed `iat` outside
// Shaarli's tolerance. The Date header of the failed response calibrates our clock,
// so retry exactly once with a regenerated token.
val clockWasUnknown = !serverClock.isSynced
// A 401 means our API secret no longer matches the one on the Shaarli server.
// Broadcast the event so the UI can clear credentials and return to the login screen.
var response = chain.proceed(original.withAuth(apiSecret))
serverClock.syncFromResponse(response)
if (response.code == 401 && clockWasUnknown) {
response.close()
response = chain.proceed(original.withAuth(apiSecret))
serverClock.syncFromResponse(response)
}
// Still 401 after a clock-corrected retry: the secret is genuinely wrong or was
// regenerated server-side. Broadcast so the UI can clear credentials and return
// to the login screen.
if (response.code == 401) {
sessionManager.onUnauthorized()
}
return response
}
private fun Request.withAuth(apiSecret: String): Request {
val jwtToken =
JwtGenerator.generateToken(apiSecret, serverClock.nowSeconds() - IAT_GRACE_SECONDS)
return newBuilder().header("Authorization", "Bearer $jwtToken").build()
}
}
@@ -0,0 +1,60 @@
package com.shaarit.core.network
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import javax.inject.Inject
import javax.inject.Singleton
import okhttp3.Response
/**
* Estimates the server clock so JWT timestamps survive device/server clock skew.
*
* Shaarli rejects tokens whose `iat` is in the future or older than its lifetime. The app
* previously used the raw device clock, so a phone running a few seconds fast (or a server
* running slow) produced "HTTP 401 Not authorized" even with the correct API secret. We derive
* the offset from the `Date` header present in every HTTP response and extrapolate with
* SystemClock.elapsedRealtime(), which keeps ticking correctly through doze/suspend.
*/
@Singleton
class ServerClock @Inject constructor() {
@Volatile
private var serverBaseMs: Long = 0L
@Volatile
private var localBaseElapsed: Long = 0L
@Volatile
var isSynced: Boolean = false
private set
/** Update the estimate from a response's `Date` header (server epoch millis). */
fun syncFromResponse(response: Response) {
val serverDate = parseHttpDate(response.header("Date")) ?: return
serverBaseMs = serverDate
localBaseElapsed = android.os.SystemClock.elapsedRealtime()
isSynced = true
}
/** Current time as the server sees it, in epoch seconds (device clock if never synced). */
fun nowSeconds(): Long {
val base = serverBaseMs
if (base == 0L || !isSynced) {
return System.currentTimeMillis() / 1000
}
val elapsedSinceSync = android.os.SystemClock.elapsedRealtime() - localBaseElapsed
return (base + elapsedSinceSync) / 1000
}
private fun parseHttpDate(value: String?): Long? {
if (value.isNullOrBlank()) return null
return try {
val format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
format.timeZone = TimeZone.getTimeZone("GMT")
format.parse(value)?.time
} catch (e: Exception) {
null
}
}
}
@@ -17,15 +17,18 @@ object JwtGenerator {
/**
* Generates a JWT token for Shaarli API.
* @param apiSecret The API secret from Shaarli settings
* @param issuedAt The `iat` claim (epoch seconds). Callers should pass the *server*
* clock (see ServerClock) so device clock skew never pushes the token outside
* Shaarli's tolerance window.
* @return The JWT token string
*/
fun generateToken(apiSecret: String): String {
@JvmOverloads
fun generateToken(apiSecret: String, issuedAt: Long = System.currentTimeMillis() / 1000): String {
// Header: {"typ":"JWT","alg":"HS512"}
val header = """{"typ":"JWT","alg":"HS512"}"""
// Payload: {"iat": unix_timestamp}
// Use timestamp 60 seconds in the past to avoid clock skew issues
val iat = (System.currentTimeMillis() / 1000) - 60
val iat = issuedAt
val payload = """{"iat":$iat}"""
// Base64URL encode header and payload
@@ -4,7 +4,9 @@ import com.shaarit.core.storage.TokenManager
import com.shaarit.data.api.ShaarliApi
import com.shaarit.domain.model.Credentials
import com.shaarit.domain.repository.AuthRepository
import java.io.IOException
import javax.inject.Inject
import retrofit2.HttpException
class AuthRepositoryImpl
@Inject
@@ -24,14 +26,47 @@ constructor(private val api: ShaarliApi, private val tokenManager: TokenManager)
// If we get here, authentication worked
// The info contains Shaarli version and settings
Result.success(true)
} catch (e: Exception) {
} catch (e: HttpException) {
// Authentication failed - clear the secret
tokenManager.clearApiSecret()
e.printStackTrace()
Result.failure(describeHttpFailure(e))
} catch (e: IOException) {
// Network/DNS/TLS error: keep the stored credentials untouched by the new URL,
// just report clearly. Base URL was already overwritten above.
e.printStackTrace()
Result.failure(
Exception(
"Serveur injoignable (${e.message}). Verifie l'URL et la connexion réseau."
)
)
} catch (e: Exception) {
tokenManager.clearApiSecret()
e.printStackTrace()
Result.failure(e)
}
}
private fun describeHttpFailure(e: HttpException): Exception {
return when (e.code()) {
401,
403 ->
Exception(
"HTTP ${e.code()} - Secret API refuse. Copie le champ « Secret API » de " +
"Shaarli (Configuration -> Securite) : ce n'est PAS ton mot de passe " +
"de connexion. Si tu as utilise « Deconnexion de tous les appareils », " +
"le secret a ete regenere et il faut ressaisir le nouveau."
)
404 ->
Exception(
"HTTP 404 - API introuvable a cette URL. Verifie le chemin d'installation " +
"(ex: https://serveur.com/shaarli) et que l'API est activee dans " +
"Shaarli (Configuration -> Securite -> Activer l'API)."
)
else -> Exception("HTTP ${e.code()} - ${e.message() ?: "erreur serveur"}")
}
}
override fun isLoggedIn(): Boolean {
// We're logged in if we have both base URL and API secret
return !tokenManager.getApiSecret().isNullOrBlank() &&
@@ -6,9 +6,13 @@ import javax.inject.Inject
class LoginUseCase @Inject constructor(private val repository: AuthRepository) {
suspend operator fun invoke(url: String, secret: String): Result<Boolean> {
if (url.isBlank() || secret.isBlank()) {
// Trim so a trailing space from copy/paste or autocomplete does not silently
// corrupt the URL or the HMAC key (which would surface as an obscure HTTP 401).
val cleanUrl = url.trim()
val cleanSecret = secret.trim()
if (cleanUrl.isBlank() || cleanSecret.isBlank()) {
return Result.failure(IllegalArgumentException("URL and Secret cannot be empty"))
}
return repository.login(Credentials(url, secret), url)
return repository.login(Credentials(cleanUrl, cleanSecret), cleanUrl)
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
#Thu Apr 23 19:46:44 2026
VERSION_NAME=2.14.0
VERSION_CODE=41
VERSION_NAME=2.14.1
VERSION_CODE=42