diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt deleted file mode 100644 index 18d938b3bb..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/response/ProblemDetailResponse.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.tangem.datasource.api.auth.models.response - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -/** - * RFC 9457 / RFC 7807 Problem Details response. Returned by Tangem Auth Service with - * `Content-Type: application/problem+json` on every 4xx / 5xx response. - */ -@JsonClass(generateAdapter = true) -data class ProblemDetailResponse( - /** URI identifying the problem type. */ - @Json(name = "type") val type: String, - /** Short human-readable summary (e.g. `"Too Many Requests"`). */ - @Json(name = "title") val title: String, - /** HTTP status code. */ - @Json(name = "status") val status: Int, - /** Human-readable explanation. */ - @Json(name = "detail") val detail: String?, - /** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */ - @Json(name = "instance") val instance: String?, - /** Application-specific error code. */ - @Json(name = "code") val code: String?, - /** Retry delay for rate limiting (`429`). */ - @Json(name = "retryAfterSeconds") val retryAfterSeconds: Int?, -) \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt index ba1d0742c7..b52f532ab4 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt @@ -4,6 +4,7 @@ import android.content.Context import com.google.firebase.crashlytics.FirebaseCrashlytics import com.squareup.moshi.Moshi import com.tangem.common.services.secure.SecureStorage +import com.tangem.datasource.api.auth.AuthApi import com.tangem.datasource.di.NetworkMoshi import com.tangem.lib.auth.AuthFeatureToggles import com.tangem.lib.auth.devicekey.DeviceKeyManager @@ -16,11 +17,16 @@ import com.tangem.lib.auth.http.DpopAuthorizationInterceptor import com.tangem.lib.auth.nonce.AuthNonceDecryptor import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor +import com.tangem.lib.auth.session.SessionTokenRefresher import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.lib.auth.session.internal.AuthErrorConverter +import com.tangem.lib.auth.session.internal.DefaultSessionTokenRefresher import com.tangem.lib.auth.session.internal.DefaultSessionTokensStore +import com.tangem.lib.auth.session.internal.DisabledSessionTokenRefresher import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.info.AppInfoProvider import com.tangem.utils.logging.TangemLogger import dagger.Module import dagger.Provides @@ -115,6 +121,33 @@ internal object AuthModule { ) } + @Suppress("LongParameterList") + @Provides + @Singleton + fun provideSessionTokenRefresher( + authFeatureToggles: AuthFeatureToggles, + authApi: AuthApi, + store: SessionTokensStore, + deviceKeyManager: DeviceKeyManager, + nonceDecryptor: AuthNonceDecryptor, + appInfoProvider: AppInfoProvider, + errorConverter: AuthErrorConverter, + dispatchers: CoroutineDispatcherProvider, + ): SessionTokenRefresher { + if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledSessionTokenRefresher + + return DefaultSessionTokenRefresher( + authApi = authApi, + store = store, + deviceKeyManager = deviceKeyManager, + nonceDecryptor = nonceDecryptor, + appInfoProvider = appInfoProvider, + errorConverter = errorConverter, + clock = Clock.System, + dispatchers = dispatchers, + ) + } + @Provides @Singleton fun provideDpopAuthorizationInterceptor( diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt new file mode 100644 index 0000000000..2d01b7e578 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt @@ -0,0 +1,36 @@ +package com.tangem.lib.auth.session + +/** + * Typed Tangem Auth Service failure produced by `AuthErrorConverter` out of the raw + * `ApiResponseError`. Carries the parsed [AuthErrorResponse] when the server returned + * an `application/problem+json` body (RFC 9457). + */ +sealed class AuthError(open val problem: AuthErrorResponse?) { + + /** `400` — invalid nonce / signature / wallet already registered. */ + data class BadRequest(override val problem: AuthErrorResponse?) : AuthError(problem) + + /** `401` — invalid / expired / revoked / replayed access or refresh token. */ + data class Unauthorized(override val problem: AuthErrorResponse?) : AuthError(problem) + + /** `403` — device blocked (RED tier), max wallets exceeded, or token-device mismatch. */ + data class Forbidden(override val problem: AuthErrorResponse?) : AuthError(problem) + + /** `404` — token / resource not found. */ + data class NotFound(override val problem: AuthErrorResponse?) : AuthError(problem) + + /** `429` — server-side rate limit; honour [retryAfterSeconds] before retrying. */ + data class RateLimited( + val retryAfterSeconds: Int?, + override val problem: AuthErrorResponse?, + ) : AuthError(problem) + + /** `5xx` — server-side outage. */ + data class ServerUnavailable(override val problem: AuthErrorResponse?) : AuthError(problem) + + /** Connectivity issue (DNS, timeout, offline). */ + data object NetworkError : AuthError(problem = null) + + /** Anything not covered above (parsing failure, unexpected exception). */ + data class Unknown(val cause: Throwable) : AuthError(problem = null) +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthErrorResponse.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthErrorResponse.kt new file mode 100644 index 0000000000..e895e6c289 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthErrorResponse.kt @@ -0,0 +1,26 @@ +package com.tangem.lib.auth.session + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * RFC 9457 / RFC 7807 Problem Details response. Returned by Tangem Auth Service with + * `Content-Type: application/problem+json` on every 4xx / 5xx response. + */ +@Serializable +data class AuthErrorResponse( + /** URI identifying the problem type. */ + @SerialName("type") val type: String, + /** Short human-readable summary (e.g. `"Too Many Requests"`). */ + @SerialName("title") val title: String, + /** HTTP status code. */ + @SerialName("status") val status: Int, + /** Human-readable explanation. */ + @SerialName("detail") val detail: String? = null, + /** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */ + @SerialName("instance") val instance: String? = null, + /** Application-specific error code. */ + @SerialName("code") val code: String? = null, + /** Retry delay for rate limiting (`429`). */ + @SerialName("retryAfterSeconds") val retryAfterSeconds: Int? = null, +) \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt new file mode 100644 index 0000000000..5a79eca439 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt @@ -0,0 +1,30 @@ +package com.tangem.lib.auth.session + +/** + * Typed failure mode of `SessionTokenRefresher.refresh()`. Distinguishes terminal failures + * (re-registration required) from transient ones (network / server) so callers can decide + * whether to retry, surface UI, or trigger deferred-registration flow. + */ +sealed class SessionRefreshError { + + /** API-level error from `/refresh`, `/authenticate` or `/nonce/auth`. Transient unless [cause] says otherwise. */ + data class Api(val cause: AuthError) : SessionRefreshError() + + /** + * Terminal — `/authenticate` returned 401/403. Session store was cleared; the device must + * re-register ([REDACTED_TASK_KEY] / deferred-registration flow). + */ + data object SessionRevoked : SessionRefreshError() + + /** Device key is not provisioned in Keystore (registration not yet run, or Keystore unavailable). */ + data object DeviceKeyUnavailable : SessionRefreshError() + + /** RSA/OAEP decryption of the server-issued auth nonce failed. */ + data class NonceDecryptionFailed(val cause: Throwable) : SessionRefreshError() + + /** Device-key signing of the authentication payload failed (Keystore I/O or ECDSA failure). */ + data class SigningFailed(val cause: Throwable) : SessionRefreshError() + + /** Refresher is disabled via `AND_15438_BACKEND_AUTHENTICATION_ENABLED` feature toggle. */ + data object Disabled : SessionRefreshError() +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt new file mode 100644 index 0000000000..8c585af554 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt @@ -0,0 +1,24 @@ +package com.tangem.lib.auth.session + +import arrow.core.Either + +/** + * Refreshes (rotates) session tokens. + * + * Implementations must serialise refresh attempts so concurrent callers share a single + * network round-trip — replaying a consumed refresh token causes the backend to revoke + * the entire session chain (SR-8 / RFC 9449 §5). + * + * Refresh strategy: + * 1. Call `/api/v1/auth/refresh` with the stored refresh token when it is present and unexpired. + * 2. On 401/403 from `/refresh` (revoked / replayed / RED-tier downgrade), fall back to + * full re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate` + * signed by the device key. + * 3. On 401/403 from `/authenticate`, clear the session store and return + * [SessionRefreshError.SessionRevoked] — the device must be re-registered (see [REDACTED_TASK_KEY] + * for the deferred-registration flag). + */ +interface SessionTokenRefresher { + + suspend fun refresh(): Either +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt new file mode 100644 index 0000000000..6e9ec92012 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt @@ -0,0 +1,46 @@ +package com.tangem.lib.auth.session.internal + +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.lib.auth.session.AuthError +import com.tangem.lib.auth.session.AuthErrorResponse +import com.tangem.utils.converter.Converter +import kotlinx.serialization.json.Json +import javax.inject.Inject + +/** + * Converts the raw `ApiResponseError` produced by Retrofit into a typed [AuthError], parsing + * `application/problem+json` payloads into [AuthErrorResponse] when present. Follows the + * project pattern set by `TangemPayErrorConverter`, `ExpressErrorConverter`, etc. + */ +internal class AuthErrorConverter @Inject constructor() : Converter { + + private val json = Json { ignoreUnknownKeys = true } + + override fun convert(error: Throwable): AuthError = when (error) { + is ApiResponseError.HttpException -> convertHttp(error) + is ApiResponseError.NetworkException, + is ApiResponseError.TimeoutException, + -> AuthError.NetworkError + // Unwrap the wrapper so consumers inspect the original failure instead of + // `ApiResponseError.UnknownException` itself. + is ApiResponseError.UnknownException -> AuthError.Unknown(error.cause) + else -> AuthError.Unknown(error) + } + + private fun convertHttp(error: ApiResponseError.HttpException): AuthError { + val problem = error.errorBody?.let(::parseErrorResponse) + return when (error.code) { + Code.BAD_REQUEST -> AuthError.BadRequest(problem) + Code.UNAUTHORIZED -> AuthError.Unauthorized(problem) + Code.FORBIDDEN -> AuthError.Forbidden(problem) + Code.NOT_FOUND -> AuthError.NotFound(problem) + Code.TOO_MANY_REQUESTS -> AuthError.RateLimited(problem?.retryAfterSeconds, problem) + else -> if (error.isServerError()) AuthError.ServerUnavailable(problem) else AuthError.Unknown(error) + } + } + + private fun parseErrorResponse(response: String): AuthErrorResponse? { + return runCatching { json.decodeFromString(response) }.getOrNull() + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt new file mode 100644 index 0000000000..a9d9bc4dd7 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt @@ -0,0 +1,202 @@ +package com.tangem.lib.auth.session.internal + +import android.util.Base64 +import arrow.core.Either +import arrow.core.left +import arrow.core.raise.either +import arrow.core.right +import com.tangem.datasource.api.auth.AuthApi +import com.tangem.datasource.api.auth.models.request.AuthApiRequest +import com.tangem.datasource.api.auth.models.request.AuthenticationPayload +import com.tangem.datasource.api.auth.models.request.AuthenticationPayload.DeviceMetadata +import com.tangem.datasource.api.auth.models.request.NonceApiRequest +import com.tangem.datasource.api.auth.models.request.RefreshApiRequest +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.lib.auth.nonce.AuthNonceDecryptor +import com.tangem.lib.auth.session.AuthError +import com.tangem.lib.auth.session.SessionRefreshError +import com.tangem.lib.auth.session.SessionTokenRefresher +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock + +@Suppress("LongParameterList") +internal class DefaultSessionTokenRefresher( + private val authApi: AuthApi, + private val store: SessionTokensStore, + private val deviceKeyManager: DeviceKeyManager, + private val nonceDecryptor: AuthNonceDecryptor, + private val appInfoProvider: AppInfoProvider, + private val errorConverter: AuthErrorConverter, + private val clock: Clock, + private val dispatchers: CoroutineDispatcherProvider, +) : SessionTokenRefresher { + + private val mutex = Mutex() + private var inFlight: CompletableDeferred>? = null + + override suspend fun refresh(): Either = withContext(dispatchers.io) { + // True single-flight (SR-8 / RFC 9449 §5): concurrent callers share one network round-trip + // and receive the same result — both on success and on transient failures. This prevents + // refresh-token replay (which revokes the family) and avoids amplifying outages/rate limits. + var isOwner = false + val deferred = mutex.withLock { + inFlight ?: CompletableDeferred>().also { deferred -> + inFlight = deferred + isOwner = true + } + } + + if (isOwner) { + try { + deferred.complete(runRefresh(current = store.get().getOrNull())) + } catch (t: Throwable) { + // Propagate to every waiter — without this they'd suspend forever on `await()`. + deferred.completeExceptionally(t) + throw t + } finally { + // `NonCancellable` keeps the slot-clearing alive even if the owner coroutine is + // cancelled mid-refresh, so the next caller can start a fresh attempt. + withContext(NonCancellable) { + mutex.withLock { inFlight = null } + } + } + } + + deferred.await() + } + + private suspend fun runRefresh(current: SessionTokens?): Either { + val now = clock.now() + + val isRefreshTokenValid = current?.refreshTokenExpiresAt != null && current.refreshTokenExpiresAt > now + if (current?.refreshToken != null && isRefreshTokenValid) { + when (val result = callRefresh(current.refreshToken)) { + is RefreshOutcome.Success -> return result.tokens.right() + RefreshOutcome.Unauthenticated -> Unit // fall through to /authenticate + is RefreshOutcome.Transient -> return SessionRefreshError.Api(result.cause).left() + } + } + + return runAuthenticate() + } + + private suspend fun callRefresh(refreshToken: String): RefreshOutcome { + val response = authApi.refresh(RefreshApiRequest(refreshToken = refreshToken)) + return handleTokenResponse(response, clearOnUnauthenticated = false) + } + + private suspend fun runAuthenticate(): Either = either { + val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull() + ?: raise(SessionRefreshError.DeviceKeyUnavailable) + + val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap() + + val nonceResponse = authApi.requestAuthNonce(NonceApiRequest(devicePublicKey = devicePublicKeyBase64)) + val cipheredNonce = when (nonceResponse) { + is ApiResponse.Success -> nonceResponse.data.cipheredNonce + is ApiResponse.Error -> { + val authError = errorConverter.convert(nonceResponse.cause) + raise(SessionRefreshError.Api(authError)) + } + } + + val nonce = try { + nonceDecryptor.decryptNonce(cipheredNonce) + } catch (e: Exception) { + TangemLogger.e("Failed to decrypt auth nonce", e) + raise(SessionRefreshError.NonceDecryptionFailed(e)) + } + + val payload = AuthenticationPayload( + devicePublicKey = devicePublicKeyBase64, + nonce = nonce, + attestationToken = null, + metadata = buildDeviceMetadata(), + ) + val signaturePayload = canonicalize(payload) + val signature = try { + deviceKeyManager.sign(signaturePayload).toBase64NoWrap() + } catch (e: Exception) { + TangemLogger.e("Failed to sign authentication payload", e) + raise(SessionRefreshError.SigningFailed(e)) + } + + val authResponse = authApi.authenticate(AuthApiRequest(payload = payload, signature = signature)) + return when (val outcome = handleTokenResponse(authResponse, clearOnUnauthenticated = true)) { + is RefreshOutcome.Success -> outcome.tokens.right() + RefreshOutcome.Unauthenticated -> SessionRefreshError.SessionRevoked.left() + is RefreshOutcome.Transient -> SessionRefreshError.Api(outcome.cause).left() + } + } + + private suspend fun handleTokenResponse( + response: ApiResponse, + clearOnUnauthenticated: Boolean, + ): RefreshOutcome { + return when (response) { + is ApiResponse.Success -> { + val tokens = SessionTokensConverter.convertBack(response.data) + store.save(tokens) + RefreshOutcome.Success(tokens) + } + is ApiResponse.Error -> { + when (val authError = errorConverter.convert(response.cause)) { + is AuthError.Unauthorized, is AuthError.Forbidden -> { + if (clearOnUnauthenticated) { + TangemLogger.i("Session revoked: ${authError.problem?.detail ?: authError}") + store.clear() + } + RefreshOutcome.Unauthenticated + } + else -> RefreshOutcome.Transient(authError) + } + } + } + } + + private fun buildDeviceMetadata(): DeviceMetadata = DeviceMetadata( + deviceModel = appInfoProvider.device, + os = appInfoProvider.platform, + osVersion = appInfoProvider.osVersion, + appVersion = appInfoProvider.appVersion, + userAgent = null, + locale = appInfoProvider.language, + timezone = appInfoProvider.timezone, + ) + + private fun canonicalize(payload: AuthenticationPayload): ByteArray { + // Stable, line-separated representation; backend treats the signed bytes opaquely. If the + // server pins to a specific canonicalisation (e.g. CBOR / sorted JSON), update both sides + // together. + return buildString { + append(payload.devicePublicKey).append('\n') + append(payload.nonce).append('\n') + append(payload.attestationToken.orEmpty()).append('\n') + append(payload.metadata.deviceModel.orEmpty()).append('\n') + append(payload.metadata.os).append('\n') + append(payload.metadata.osVersion.orEmpty()).append('\n') + append(payload.metadata.appVersion.orEmpty()).append('\n') + append(payload.metadata.locale.orEmpty()).append('\n') + append(payload.metadata.timezone.orEmpty()) + }.toByteArray(Charsets.UTF_8) + } + + private fun ByteArray.toBase64NoWrap(): String = Base64.encodeToString(this, Base64.NO_WRAP) + + private sealed interface RefreshOutcome { + data class Success(val tokens: SessionTokens) : RefreshOutcome + data object Unauthenticated : RefreshOutcome + data class Transient(val cause: AuthError) : RefreshOutcome + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokenRefresher.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokenRefresher.kt new file mode 100644 index 0000000000..c806e7cba5 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokenRefresher.kt @@ -0,0 +1,16 @@ +package com.tangem.lib.auth.session.internal + +import arrow.core.Either +import arrow.core.left +import com.tangem.lib.auth.session.SessionRefreshError +import com.tangem.lib.auth.session.SessionTokenRefresher +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.utils.annotations.RemoveWithToggle + +@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED") +internal object DisabledSessionTokenRefresher : SessionTokenRefresher { + + override suspend fun refresh(): Either { + return SessionRefreshError.Disabled.left() + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt new file mode 100644 index 0000000000..6e782c6df0 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt @@ -0,0 +1,196 @@ +package com.tangem.lib.auth.session.internal + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code +import com.tangem.lib.auth.session.AuthError +import com.tangem.lib.auth.session.AuthErrorResponse +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AuthErrorConverterTest { + + private val converter = AuthErrorConverter() + + private val sampleBody = """ + { + "type": "https://problems.tangem.com/auth/invalid-signature", + "title": "Bad Request", + "status": 400, + "detail": "Nonce or signature validation failed.", + "instance": "/api/v1/auth/refresh", + "code": "invalid_signature", + "retryAfterSeconds": null + } + """.trimIndent() + + private val sampleProblem = AuthErrorResponse( + type = "https://problems.tangem.com/auth/invalid-signature", + title = "Bad Request", + status = 400, + detail = "Nonce or signature validation failed.", + instance = "/api/v1/auth/refresh", + code = "invalid_signature", + retryAfterSeconds = null, + ) + + @Test + fun `400 with problem body is converted to BadRequest with parsed problem`() { + val result = converter.convert(httpError(Code.BAD_REQUEST, sampleBody)) + + assertThat(result).isEqualTo(AuthError.BadRequest(sampleProblem)) + } + + @Test + fun `400 without body is converted to BadRequest with null problem`() { + val result = converter.convert(httpError(Code.BAD_REQUEST, errorBody = null)) + + assertThat(result).isEqualTo(AuthError.BadRequest(problem = null)) + } + + @Test + fun `401 is converted to Unauthorized`() { + val result = converter.convert(httpError(Code.UNAUTHORIZED, sampleBody)) + + assertThat(result).isInstanceOf(AuthError.Unauthorized::class.java) + assertThat((result as AuthError.Unauthorized).problem).isEqualTo(sampleProblem) + } + + @Test + fun `403 is converted to Forbidden`() { + val result = converter.convert(httpError(Code.FORBIDDEN, sampleBody)) + + assertThat(result).isInstanceOf(AuthError.Forbidden::class.java) + } + + @Test + fun `404 is converted to NotFound`() { + val result = converter.convert(httpError(Code.NOT_FOUND, sampleBody)) + + assertThat(result).isInstanceOf(AuthError.NotFound::class.java) + } + + @Test + fun `429 surfaces retryAfterSeconds from problem`() { + val rateLimitBody = """ + { + "type": "https://problems.tangem.com/auth/rate-limit", + "title": "Too Many Requests", + "status": 429, + "detail": "Try again later.", + "instance": "/api/v1/auth/refresh", + "code": "rate_limited", + "retryAfterSeconds": 45 + } + """.trimIndent() + + val result = converter.convert(httpError(Code.TOO_MANY_REQUESTS, rateLimitBody)) + + assertThat(result).isInstanceOf(AuthError.RateLimited::class.java) + val rateLimited = result as AuthError.RateLimited + assertThat(rateLimited.retryAfterSeconds).isEqualTo(45) + assertThat(rateLimited.problem?.code).isEqualTo("rate_limited") + } + + @Test + fun `429 without body has null retryAfterSeconds`() { + val result = converter.convert(httpError(Code.TOO_MANY_REQUESTS, errorBody = null)) + + assertThat(result).isEqualTo(AuthError.RateLimited(retryAfterSeconds = null, problem = null)) + } + + @Test + fun `500 is converted to ServerUnavailable`() { + val result = converter.convert(httpError(Code.INTERNAL_SERVER_ERROR, errorBody = null)) + + assertThat(result).isInstanceOf(AuthError.ServerUnavailable::class.java) + } + + @Test + fun `502 is converted to ServerUnavailable`() { + val result = converter.convert(httpError(Code.BAD_GATEWAY, errorBody = null)) + + assertThat(result).isInstanceOf(AuthError.ServerUnavailable::class.java) + } + + @Test + fun `503 is converted to ServerUnavailable`() { + val result = converter.convert(httpError(Code.SERVICE_UNAVAILABLE, errorBody = null)) + + assertThat(result).isInstanceOf(AuthError.ServerUnavailable::class.java) + } + + @Test + fun `non-server 4xx not explicitly mapped becomes Unknown`() { + // 418 I_M_A_TEAPOT is a 4xx that isServerError() reports as non-server. + val httpException = httpError(Code.IM_A_TEAPOT, errorBody = null) + + val result = converter.convert(httpException) + + assertThat(result).isInstanceOf(AuthError.Unknown::class.java) + assertThat((result as AuthError.Unknown).cause).isSameInstanceAs(httpException) + } + + @Test + fun `NetworkException becomes NetworkError`() { + val result = converter.convert(ApiResponseError.NetworkException()) + + assertThat(result).isSameInstanceAs(AuthError.NetworkError) + } + + @Test + fun `TimeoutException becomes NetworkError`() { + val result = converter.convert(ApiResponseError.TimeoutException()) + + assertThat(result).isSameInstanceAs(AuthError.NetworkError) + } + + @Test + fun `UnknownException is unwrapped to its underlying cause`() { + val cause = IllegalStateException("boom") + val result = converter.convert(ApiResponseError.UnknownException(cause)) + + // Consumers reading AuthError.Unknown.cause should see the original failure, not the wrapper. + assertThat(result).isEqualTo(AuthError.Unknown(cause)) + } + + @Test + fun `non-ApiResponseError Throwable is wrapped as Unknown`() { + val cause = RuntimeException("misc") + val result = converter.convert(cause) + + assertThat(result).isEqualTo(AuthError.Unknown(cause)) + } + + @Test + fun `malformed JSON in errorBody yields null problem but preserves AuthError shape`() { + val result = converter.convert(httpError(Code.BAD_REQUEST, errorBody = "{not json")) + + assertThat(result).isEqualTo(AuthError.BadRequest(problem = null)) + } + + @Test + fun `unknown JSON fields in errorBody are ignored (ignoreUnknownKeys)`() { + val bodyWithExtra = """ + { + "type": "https://problems.tangem.com/auth/x", + "title": "Bad Request", + "status": 400, + "detail": null, + "instance": null, + "code": null, + "retryAfterSeconds": null, + "futureField": "ignored" + } + """.trimIndent() + + val result = converter.convert(httpError(Code.BAD_REQUEST, bodyWithExtra)) + + assertThat(result).isInstanceOf(AuthError.BadRequest::class.java) + assertThat((result as AuthError.BadRequest).problem?.title).isEqualTo("Bad Request") + } + + private fun httpError(code: Code, errorBody: String?): ApiResponseError.HttpException = + ApiResponseError.HttpException(code = code, message = null, errorBody = errorBody) +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt new file mode 100644 index 0000000000..a0cd03e070 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt @@ -0,0 +1,324 @@ +package com.tangem.lib.auth.session.internal + +import arrow.core.None +import arrow.core.Some +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.auth.AuthApi +import com.tangem.datasource.api.auth.models.request.AuthApiRequest +import com.tangem.datasource.api.auth.models.request.RefreshApiRequest +import com.tangem.datasource.api.auth.models.response.NonceApiResponse +import com.tangem.datasource.api.auth.models.response.TokenApiResponse +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.lib.auth.nonce.AuthNonceDecryptor +import com.tangem.lib.auth.session.SessionRefreshError +import com.tangem.lib.auth.session.SessionTokens +import com.tangem.lib.auth.session.SessionTokensStore +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import com.tangem.utils.info.AppInfoProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultSessionTokenRefresherTest { + + private val authApi: AuthApi = mockk() + private val store: SessionTokensStore = mockk(relaxUnitFun = true) + private val deviceKeyManager: DeviceKeyManager = mockk() + private val nonceDecryptor: AuthNonceDecryptor = mockk() + private val appInfoProvider: AppInfoProvider = mockk(relaxed = true) + private val errorConverter = AuthErrorConverter() + private val dispatchers = TestingCoroutineDispatcherProvider() + private val fixedClock = object : Clock { + override fun now(): Instant = Instant.fromEpochSeconds(1_700_000_000) + } + + private lateinit var refresher: DefaultSessionTokenRefresher + + @BeforeEach + fun setup() { + clearMocks(authApi, store, deviceKeyManager, nonceDecryptor) + mockkStatic(android.util.Base64::class) + every { android.util.Base64.encodeToString(any(), any()) } answers { + java.util.Base64.getEncoder().encodeToString(firstArg()) + } + refresher = DefaultSessionTokenRefresher( + authApi = authApi, + store = store, + deviceKeyManager = deviceKeyManager, + nonceDecryptor = nonceDecryptor, + appInfoProvider = appInfoProvider, + errorConverter = errorConverter, + clock = fixedClock, + dispatchers = dispatchers, + ) + } + + @AfterEach + fun teardown() = unmockkAll() + + @Test + fun `refresh hits refresh endpoint when refresh token valid`() = runTest { + val stored = SessionTokens( + accessToken = "old-access", + accessTokenExpiresAt = fixedClock.now().plus(60), + refreshToken = "rt-1", + refreshTokenExpiresAt = fixedClock.now().plus(3600), + walletIds = listOf("w1"), + ) + coEvery { store.get() } returns Some(stored) + coEvery { authApi.refresh(RefreshApiRequest("rt-1")) } returns ApiResponse.Success( + data = TokenApiResponse( + accessToken = "new-access", + accessTokenExpiresAt = "2024-01-01T00:00:00Z", + refreshToken = "rt-2", + refreshTokenExpiresAt = "2024-02-01T00:00:00Z", + walletIds = listOf("w1", "w2"), + ), + ) + + val result = refresher.refresh() + + assertThat(result.isRight()).isTrue() + val tokens = result.getOrNull()!! + assertThat(tokens.accessToken).isEqualTo("new-access") + assertThat(tokens.refreshToken).isEqualTo("rt-2") + assertThat(tokens.walletIds).containsExactly("w1", "w2") + coVerify { store.save(tokens) } + } + + @Test + fun `refresh falls back to authenticate when refresh returns 401`() = runTest { + val stored = SessionTokens( + accessToken = "old-access", + accessTokenExpiresAt = fixedClock.now().plus(60), + refreshToken = "rt-1", + refreshTokenExpiresAt = fixedClock.now().plus(3600), + walletIds = listOf("w1"), + ) + coEvery { store.get() } returns Some(stored) + @Suppress("UNCHECKED_CAST") + coEvery { authApi.refresh(any()) } returns ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.UNAUTHORIZED, + message = "revoked", + errorBody = null, + ), + ) as ApiResponse + stubAuthenticateHappyPath() + + val result = refresher.refresh() + + assertThat(result.isRight()).isTrue() + assertThat(result.getOrNull()?.accessToken).isEqualTo("post-auth-access") + coVerify { authApi.refresh(any()) } + coVerify { authApi.requestAuthNonce(any()) } + coVerify { authApi.authenticate(any()) } + } + + @Test + fun `refresh clears store when authenticate returns 403`() = runTest { + coEvery { store.get() } returns None + + coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { authApi.requestAuthNonce(any()) } returns ApiResponse.Success( + data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), + ) + coEvery { nonceDecryptor.decryptNonce("abc") } returns "nonce-decrypted" + coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) + @Suppress("UNCHECKED_CAST") + coEvery { authApi.authenticate(any()) } returns ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.FORBIDDEN, + message = "RED", + errorBody = null, + ), + ) as ApiResponse + + val result = refresher.refresh() + + assertThat(result.isLeft()).isTrue() + assertThat(result.leftOrNull()).isEqualTo(SessionRefreshError.SessionRevoked) + coVerify { store.clear() } + } + + @Test + fun `concurrent callers share a single refresh round-trip — both get same result`() = runTest { + val stored = SessionTokens( + accessToken = "old-access", + accessTokenExpiresAt = fixedClock.now().plus(60), + refreshToken = "rt-1", + refreshTokenExpiresAt = fixedClock.now().plus(3600), + walletIds = listOf("w1"), + ) + coEvery { store.get() } returns Some(stored) + + // Gate the network call so both callers reach the single-flight check before the owner + // completes the in-flight Deferred. + val networkGate = CompletableDeferred() + coEvery { authApi.refresh(RefreshApiRequest("rt-1")) } coAnswers { + networkGate.await() + ApiResponse.Success( + data = TokenApiResponse( + accessToken = "new-access", + accessTokenExpiresAt = "2024-01-01T00:00:00Z", + refreshToken = "rt-2", + refreshTokenExpiresAt = "2024-02-01T00:00:00Z", + walletIds = listOf("w1"), + ), + ) + } + + val a = async(start = CoroutineStart.UNDISPATCHED) { refresher.refresh() } + val b = async(start = CoroutineStart.UNDISPATCHED) { refresher.refresh() } + + networkGate.complete(Unit) + + val resultA = a.await() + val resultB = b.await() + + assertThat(resultA).isEqualTo(resultB) + assertThat(resultA.getOrNull()?.accessToken).isEqualTo("new-access") + coVerify(exactly = 1) { authApi.refresh(any()) } + } + + @Test + fun `concurrent callers share a transient failure — only one network call is made`() = runTest { + val stored = SessionTokens( + accessToken = "old-access", + accessTokenExpiresAt = fixedClock.now().plus(60), + refreshToken = "rt-1", + refreshTokenExpiresAt = fixedClock.now().plus(3600), + walletIds = listOf("w1"), + ) + coEvery { store.get() } returns Some(stored) + + val networkGate = CompletableDeferred() + @Suppress("UNCHECKED_CAST") + coEvery { authApi.refresh(any()) } coAnswers { + networkGate.await() + ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR, + message = "boom", + errorBody = null, + ), + ) as ApiResponse + } + + val a = async(start = CoroutineStart.UNDISPATCHED) { refresher.refresh() } + val b = async(start = CoroutineStart.UNDISPATCHED) { refresher.refresh() } + + networkGate.complete(Unit) + + val resultA = a.await() + val resultB = b.await() + + // Both waiters receive the same transient failure; the failing network call wasn't repeated + // — protects against amplifying outages or replaying a possibly-consumed refresh token. + assertThat(resultA).isEqualTo(resultB) + assertThat(resultA.leftOrNull()).isInstanceOf(SessionRefreshError.Api::class.java) + coVerify(exactly = 1) { authApi.refresh(any()) } + } + + @Test + fun `concurrent waiters receive the same exception when the owner's refresh throws`() = runTest { + val stored = SessionTokens( + accessToken = "old-access", + accessTokenExpiresAt = fixedClock.now().plus(60), + refreshToken = "rt-1", + refreshTokenExpiresAt = fixedClock.now().plus(3600), + walletIds = listOf("w1"), + ) + coEvery { store.get() } returns Some(stored) + + val networkGate = CompletableDeferred() + coEvery { authApi.refresh(any()) } coAnswers { + networkGate.await() + throw IllegalStateException("boom") + } + + val a = async(start = CoroutineStart.UNDISPATCHED) { runCatching { refresher.refresh() } } + val b = async(start = CoroutineStart.UNDISPATCHED) { runCatching { refresher.refresh() } } + + networkGate.complete(Unit) + + val resultA = a.await() + val resultB = b.await() + + // Both the owner and the waiter receive the same `IllegalStateException` — proves waiters + // can't suspend forever when the owner throws (deferred is completed exceptionally). + assertThat(resultA.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(resultB.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + coVerify(exactly = 1) { authApi.refresh(any()) } + + // The inFlight slot must be cleared so the next call can start a fresh attempt. + coEvery { authApi.refresh(any()) } returns ApiResponse.Success( + data = TokenApiResponse( + accessToken = "after-recovery", + accessTokenExpiresAt = "2024-01-01T00:00:00Z", + refreshToken = "rt-2", + refreshTokenExpiresAt = "2024-02-01T00:00:00Z", + walletIds = listOf("w1"), + ), + ) + val recovered = refresher.refresh() + assertThat(recovered.getOrNull()?.accessToken).isEqualTo("after-recovery") + } + + @Test + fun `refresh skips refresh endpoint when refresh token expired`() = runTest { + val stored = SessionTokens( + accessToken = "old-access", + accessTokenExpiresAt = fixedClock.now().minus(60), + refreshToken = "rt-1", + refreshTokenExpiresAt = fixedClock.now().minus(1), + walletIds = listOf("w1"), + ) + coEvery { store.get() } returns Some(stored) + stubAuthenticateHappyPath() + + val result = refresher.refresh() + + assertThat(result.isRight()).isTrue() + coVerify(exactly = 0) { authApi.refresh(any()) } + coVerify { authApi.authenticate(any()) } + } + + private fun stubAuthenticateHappyPath() { + coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { authApi.requestAuthNonce(any()) } returns ApiResponse.Success( + data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), + ) + coEvery { nonceDecryptor.decryptNonce("abc") } returns "nonce-decrypted" + coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) + coEvery { authApi.authenticate(any()) } returns ApiResponse.Success( + data = TokenApiResponse( + accessToken = "post-auth-access", + accessTokenExpiresAt = "2024-01-01T00:00:00Z", + refreshToken = "post-auth-rt", + refreshTokenExpiresAt = "2024-02-01T00:00:00Z", + walletIds = listOf("w1"), + ), + ) + } + + private fun Instant.plus(seconds: Long): Instant = Instant.fromEpochSeconds(epochSeconds + seconds) + private fun Instant.minus(seconds: Long): Instant = Instant.fromEpochSeconds(epochSeconds - seconds) +} \ No newline at end of file