Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-02 11:01:44 +04:00
parent 16ed712a09
commit f6f3aabbfc
11 changed files with 933 additions and 26 deletions

View file

@ -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?,
)

View file

@ -4,6 +4,7 @@ import android.content.Context
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.tangem.common.services.secure.SecureStorage import com.tangem.common.services.secure.SecureStorage
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.di.NetworkMoshi
import com.tangem.lib.auth.AuthFeatureToggles import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager 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.AuthNonceDecryptor
import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor
import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor 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.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.DefaultSessionTokensStore
import com.tangem.lib.auth.session.internal.DisabledSessionTokenRefresher
import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore
import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import dagger.Module import dagger.Module
import dagger.Provides 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 @Provides
@Singleton @Singleton
fun provideDpopAuthorizationInterceptor( fun provideDpopAuthorizationInterceptor(

View file

@ -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)
}

View file

@ -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,
)

View file

@ -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()
}

View file

@ -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<SessionRefreshError, SessionTokens>
}

View file

@ -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<Throwable, AuthError> {
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<AuthErrorResponse>(response) }.getOrNull()
}
}

View file

@ -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<Either<SessionRefreshError, SessionTokens>>? = null
override suspend fun refresh(): Either<SessionRefreshError, SessionTokens> = 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<Either<SessionRefreshError, SessionTokens>>().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<SessionRefreshError, SessionTokens> {
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<SessionRefreshError, SessionTokens> = 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<TokenApiResponse>,
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
}
}

View file

@ -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<SessionRefreshError, SessionTokens> {
return SessionRefreshError.Disabled.left()
}
}

View file

@ -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)
}

View file

@ -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<TokenApiResponse>
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<AuthApiRequest>()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.FORBIDDEN,
message = "RED",
errorBody = null,
),
) as ApiResponse<TokenApiResponse>
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<Unit>()
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<Unit>()
@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<TokenApiResponse>
}
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<Unit>()
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<AuthApiRequest>()) } 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)
}