From 8f90d5b3b8fe32f99a093736fc0587466080e644 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 7 Oct 2026 16:33:24 -0400 Subject: [PATCH] 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). --- .../shaarit/core/network/AuthInterceptor.kt | 46 ++++++++++---- .../com/shaarit/core/network/ServerClock.kt | 60 +++++++++++++++++++ .../com/shaarit/core/util/JwtGenerator.kt | 9 ++- .../data/repository/AuthRepositoryImpl.kt | 37 +++++++++++- .../shaarit/domain/usecase/LoginUseCase.kt | 8 ++- version.properties | 4 +- 6 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/shaarit/core/network/ServerClock.kt diff --git a/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt b/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt index 492e674..675b0e5 100644 --- a/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt +++ b/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt @@ -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() + } } diff --git a/app/src/main/java/com/shaarit/core/network/ServerClock.kt b/app/src/main/java/com/shaarit/core/network/ServerClock.kt new file mode 100644 index 0000000..32cf78e --- /dev/null +++ b/app/src/main/java/com/shaarit/core/network/ServerClock.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/shaarit/core/util/JwtGenerator.kt b/app/src/main/java/com/shaarit/core/util/JwtGenerator.kt index de3e188..3691746 100644 --- a/app/src/main/java/com/shaarit/core/util/JwtGenerator.kt +++ b/app/src/main/java/com/shaarit/core/util/JwtGenerator.kt @@ -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 diff --git a/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt index d95970c..64f19a1 100644 --- a/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt @@ -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() && diff --git a/app/src/main/java/com/shaarit/domain/usecase/LoginUseCase.kt b/app/src/main/java/com/shaarit/domain/usecase/LoginUseCase.kt index 55cdd9e..aaa88c5 100644 --- a/app/src/main/java/com/shaarit/domain/usecase/LoginUseCase.kt +++ b/app/src/main/java/com/shaarit/domain/usecase/LoginUseCase.kt @@ -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 { - 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) } } diff --git a/version.properties b/version.properties index 37d5f8d..b2bc58b 100644 --- a/version.properties +++ b/version.properties @@ -1,3 +1,3 @@ #Thu Apr 23 19:46:44 2026 -VERSION_NAME=2.14.0 -VERSION_CODE=41 \ No newline at end of file +VERSION_NAME=2.14.1 +VERSION_CODE=42 \ No newline at end of file