Updated on 2026-08-14
This commit is contained in:
parent
16ed712a09
commit
f6f3aabbfc
11 changed files with 933 additions and 26 deletions
|
|
@ -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?,
|
|
||||||
)
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue