Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 22:43:52 +03:00
commit d6f9f59866
1729 changed files with 67614 additions and 9361 deletions

View file

@ -1,9 +1,39 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.lib.auth"
}
dependencies {
/** Core */
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.utils)
/** Tangem libraries */
implementation(tangemDeps.card.core)
implementation(tangemDeps.card.android)
/** Firebase */
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.crashlytics)
/** Other */
implementation(deps.arrow.core)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit5)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
}

View file

@ -0,0 +1,5 @@
package com.tangem.lib.auth
interface AuthFeatureToggles {
val isBackendAuthenticationEnabled: Boolean
}

View file

@ -0,0 +1,13 @@
package com.tangem.lib.auth
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import javax.inject.Inject
internal class DefaultAuthFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : AuthFeatureToggles {
override val isBackendAuthenticationEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15438_BACKEND_AUTHENTICATION_ENABLED)
}

View file

@ -0,0 +1,27 @@
package com.tangem.lib.auth.devicekey
import arrow.core.Option
/**
* Manages a device-bound secp256r1 keypair in Android Keystore (TEE/StrongBox).
* The private key never leaves the secure hardware.
*/
interface DeviceKeyManager {
/**
* Ensures the device keypair exists. Generates one if missing.
* Never throws generation failures are logged and reported via the return value.
* @return `true` if a new keypair was generated, `false` if it already existed or generation failed
*/
suspend fun generateIfMissing(): Boolean
/** Raw uncompressed public key (0x04 || x || y), or [arrow.core.None] if it cannot be read. */
suspend fun getPublicKey(): Option<ByteArray>
/**
* Signs [data] with SHA256withECDSA using the device private key.
* @return raw 64-byte signature (r || s), each component zero-padded to 32 bytes
* @throws DeviceKeySigningException if signing fails
*/
suspend fun sign(data: ByteArray): ByteArray
}

View file

@ -0,0 +1,3 @@
package com.tangem.lib.auth.devicekey
class DeviceKeySigningException(message: String, cause: Throwable? = null) : Exception(message, cause)

View file

@ -0,0 +1,125 @@
package com.tangem.lib.auth.devicekey.internal
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import arrow.core.None
import arrow.core.Option
import com.tangem.crypto.Secp256r1
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
internal class DefaultDeviceKeyManager(
private val keyStore: KeyStore,
private val dispatchers: CoroutineDispatcherProvider,
) : DeviceKeyManager {
override suspend fun generateIfMissing(): Boolean = withContext(dispatchers.io) {
try {
if (keyStore.containsAlias(KEY_ALIAS)) return@withContext false
generateKey()
TangemLogger.i("Device key generated")
true
} catch (e: Exception) {
TangemLogger.e("Failed to generate device key", e)
false
}
}
override suspend fun getPublicKey(): Option<ByteArray> = withContext(dispatchers.io) {
Option.catch(
recover = { e ->
TangemLogger.e("Failed to get device public key", e)
None
},
f = ::getPublicKeyBytes,
)
}
override suspend fun sign(data: ByteArray): ByteArray = withContext(dispatchers.io) {
try {
val privateKey = keyStore.getKey(KEY_ALIAS, null)
?: throw DeviceKeySigningException("Device key not found")
val signature = Signature.getInstance(SIGNATURE_ALGORITHM).apply {
initSign(privateKey as java.security.PrivateKey)
update(data)
}
val derSignature = signature.sign()
Secp256r1.toByte64(derSignature)
} catch (e: DeviceKeySigningException) {
TangemLogger.e("Device key signing failed", e)
throw e
} catch (e: Exception) {
TangemLogger.e("Device key signing failed", e)
throw DeviceKeySigningException("Signing failed", e)
}
}
private fun generateKey() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
initAndGenerateKeyPair(strongBox = true)
} catch (e: Exception) {
TangemLogger.i("StrongBox unavailable, falling back to TEE", e)
initAndGenerateKeyPair(strongBox = false)
}
} else {
initAndGenerateKeyPair(strongBox = false)
}
}
private fun initAndGenerateKeyPair(strongBox: Boolean) {
val spec = buildKeyGenSpec(strongBox)
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE_PROVIDER)
generator.initialize(spec)
generator.generateKeyPair()
}
private fun buildKeyGenSpec(strongBox: Boolean): KeyGenParameterSpec {
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setUserAuthenticationRequired(false)
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setIsStrongBoxBacked(true)
}
return builder.build()
}
private fun getPublicKeyBytes(): ByteArray {
val cert = checkNotNull(keyStore.getCertificate(KEY_ALIAS)) { "Device key not found" }
val encoded = cert.publicKey.encoded
check(encoded.size >= EC_UNCOMPRESSED_POINT_SIZE) {
"Invalid encoded public key: expected at least $EC_UNCOMPRESSED_POINT_SIZE bytes, got ${encoded.size}"
}
val point = encoded.copyOfRange(encoded.size - EC_UNCOMPRESSED_POINT_SIZE, encoded.size)
check(point[0] == UNCOMPRESSED_POINT_PREFIX) {
"Invalid EC public key: expected uncompressed point prefix 0x04, got 0x${"%02x".format(point[0])}"
}
return point
}
private companion object {
const val KEYSTORE_PROVIDER = "AndroidKeyStore"
const val KEY_ALIAS = "tangem_device_key"
const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
const val EC_UNCOMPRESSED_POINT_SIZE = 65
const val UNCOMPRESSED_POINT_PREFIX = 0x04.toByte()
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.lib.auth.devicekey.internal
import arrow.core.None
import arrow.core.Option
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
internal object DisabledDeviceKeyManager : DeviceKeyManager {
override suspend fun generateIfMissing(): Boolean = false
override suspend fun getPublicKey(): Option<ByteArray> = None
override suspend fun sign(data: ByteArray): ByteArray {
throw DeviceKeySigningException(
"DeviceKeyManager is disabled: feature toggle is off or keystore is unavailable",
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.lib.auth.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.DefaultAuthFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AuthFeatureTogglesModule {
@Provides
@Singleton
fun provideAuthFeatureToggles(featureTogglesManager: FeatureTogglesManager): AuthFeatureToggles {
return DefaultAuthFeatureToggles(featureTogglesManager)
}
}

View file

@ -0,0 +1,211 @@
package com.tangem.lib.auth.di
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.api.auth.qualifier.SessionAuthAuthenticator
import com.tangem.datasource.api.auth.qualifier.SessionAuthInterceptor
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager
import com.tangem.lib.auth.devicekey.internal.DisabledDeviceKeyManager
import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.lib.auth.dpop.internal.DefaultDpopProofFactory
import com.tangem.lib.auth.dpop.internal.DisabledDpopProofFactory
import com.tangem.lib.auth.http.DpopAuthorizationInterceptor
import com.tangem.lib.auth.http.SessionAuthenticator
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.DeviceRegistrar
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.DefaultDeviceRegistrar
import com.tangem.lib.auth.session.internal.DefaultSessionTokenRefresher
import com.tangem.lib.auth.session.internal.DefaultSessionTokensStore
import com.tangem.lib.auth.session.internal.DisabledDeviceRegistrar
import com.tangem.lib.auth.session.internal.DisabledSessionTokenRefresher
import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore
import com.tangem.lib.auth.session.internal.SignedRequestPayload
import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Interceptor
import java.security.KeyStore
import javax.inject.Named
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AuthModule {
/**
* Exposes the backend-authentication feature toggle as a plain `Boolean` so that callers
* in `core:datasource` (which can't depend on `libs:auth` for layering reasons) can gate
* session-auth wiring without importing [AuthFeatureToggles].
*/
@Provides
@Named("isBackendAuthenticationEnabled")
fun provideIsBackendAuthenticationEnabled(authFeatureToggles: AuthFeatureToggles): Boolean =
authFeatureToggles.isBackendAuthenticationEnabled
@Provides
@Singleton
fun provideDeviceKeyManager(
authFeatureToggles: AuthFeatureToggles,
dispatchers: CoroutineDispatcherProvider,
): DeviceKeyManager {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledDeviceKeyManager
return runCatching {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
DefaultDeviceKeyManager(keyStore, dispatchers)
}.getOrElse { e ->
TangemLogger.e("Failed to init AndroidKeyStore, falling back to disabled DeviceKeyManager", e)
FirebaseCrashlytics.getInstance().recordException(e)
DisabledDeviceKeyManager
}
}
@Provides
@Singleton
fun provideAuthNonceDecryptor(
authFeatureToggles: AuthFeatureToggles,
@Named("authServiceKey") authServiceKey: String?,
dispatchers: CoroutineDispatcherProvider,
): AuthNonceDecryptor {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledAuthNonceDecryptor
if (authServiceKey.isNullOrEmpty()) return DisabledAuthNonceDecryptor
return runCatching { DefaultAuthNonceDecryptor(authServiceKey, dispatchers) }
.getOrElse { e ->
TangemLogger.e("Failed to create AuthNonceDecryptor", e)
FirebaseCrashlytics.getInstance().recordException(e)
DisabledAuthNonceDecryptor
}
}
@Provides
@Singleton
fun provideSessionTokensStore(
authFeatureToggles: AuthFeatureToggles,
@ApplicationContext context: Context,
@NetworkMoshi moshi: Moshi,
dispatchers: CoroutineDispatcherProvider,
): SessionTokensStore {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledSessionTokensStore
return runCatching {
val storage: SecureStorage = AndroidSecureStorageV2(
appContext = context,
useStrongBox = false,
name = "tangem_session_tokens",
)
DefaultSessionTokensStore(storage, moshi, dispatchers)
}.getOrElse { e ->
TangemLogger.e("Failed to init DefaultSessionTokensStore, falling back to disabled store", e)
FirebaseCrashlytics.getInstance().recordException(e)
DisabledSessionTokensStore
}
}
@Provides
@Singleton
fun provideDpopProofFactory(
authFeatureToggles: AuthFeatureToggles,
deviceKeyManager: DeviceKeyManager,
dispatchers: CoroutineDispatcherProvider,
): DpopProofFactory {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledDpopProofFactory
return DefaultDpopProofFactory(
deviceKeyManager = deviceKeyManager,
json = Json.Default,
clock = Clock.System,
dispatchers = dispatchers,
)
}
@Suppress("LongParameterList")
@Provides
@Singleton
fun provideSessionTokenRefresher(
authFeatureToggles: AuthFeatureToggles,
authApi: AuthApi,
store: SessionTokensStore,
deviceKeyManager: DeviceKeyManager,
nonceDecryptor: AuthNonceDecryptor,
signedRequestPayload: SignedRequestPayload,
errorConverter: AuthErrorConverter,
dispatchers: CoroutineDispatcherProvider,
): SessionTokenRefresher {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledSessionTokenRefresher
return DefaultSessionTokenRefresher(
authApi = authApi,
store = store,
deviceKeyManager = deviceKeyManager,
nonceDecryptor = nonceDecryptor,
signedRequestPayload = signedRequestPayload,
errorConverter = errorConverter,
clock = Clock.System,
dispatchers = dispatchers,
)
}
@Suppress("LongParameterList")
@Provides
@Singleton
fun provideDeviceRegistrar(
authFeatureToggles: AuthFeatureToggles,
authApi: AuthApi,
store: SessionTokensStore,
deviceKeyManager: DeviceKeyManager,
nonceDecryptor: AuthNonceDecryptor,
signedRequestPayload: SignedRequestPayload,
errorConverter: AuthErrorConverter,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): DeviceRegistrar {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledDeviceRegistrar
return DefaultDeviceRegistrar(
authApi = authApi,
store = store,
deviceKeyManager = deviceKeyManager,
nonceDecryptor = nonceDecryptor,
signedRequestPayload = signedRequestPayload,
errorConverter = errorConverter,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
@SessionAuthInterceptor
fun provideDpopAuthorizationInterceptor(store: SessionTokensStore, proofFactory: DpopProofFactory): Interceptor {
return DpopAuthorizationInterceptor(store, proofFactory)
}
@Provides
@Singleton
@SessionAuthAuthenticator
fun provideSessionAuthenticator(refresher: SessionTokenRefresher, proofFactory: DpopProofFactory): Authenticator {
return SessionAuthenticator(refresher, proofFactory)
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.lib.auth.dpop
import arrow.core.Option
/**
* Builds [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP proofs (JWS) for outgoing
* HTTP requests. Each proof is bound to a single request `htm` / `htu` / `ath` claims must
* not be reused, and `jti` is a fresh UUID per invocation.
*/
interface DpopProofFactory {
/**
* Builds a DPoP-proof for the given request.
*
* @param httpMethod uppercase HTTP method (e.g. `"POST"`).
* @param httpUri target URI **without** query and fragment (RFC 9449 §4.2).
* @param accessToken access token bound to this proof; when present, the SHA-256 hash
* is included as `ath` claim (RFC 9449 §4.3). Pass `null` for unauthenticated
* requests (initial registration, `/authenticate`) or `/refresh` where the access
* token has already expired (RFC 9449 §5).
* @return compact-serialised JWS suitable for the `DPoP:` header, or [arrow.core.None]
* when the device key is unavailable or signing fails.
*/
suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String>
}

View file

@ -0,0 +1,101 @@
package com.tangem.lib.auth.dpop.internal
import android.util.Base64
import arrow.core.None
import arrow.core.Option
import arrow.core.Some
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.security.MessageDigest
import java.util.UUID
internal class DefaultDpopProofFactory(
private val deviceKeyManager: DeviceKeyManager,
private val json: Json,
private val clock: Clock,
private val dispatchers: CoroutineDispatcherProvider,
) : DpopProofFactory {
override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String> =
withContext(dispatchers.default) {
val publicKey = deviceKeyManager.getPublicKey().getOrNull()
if (publicKey == null) {
TangemLogger.e("DPoP proof skipped: device key unavailable")
return@withContext None
}
// DeviceKeyManager.getPublicKey() guarantees an uncompressed P-256 point
// (0x04 || X(32) || Y(32)) — see DefaultDeviceKeyManager.getPublicKeyBytes.
val x = publicKey.copyOfRange(fromIndex = 1, toIndex = 1 + COORDINATE_SIZE)
val y = publicKey.copyOfRange(fromIndex = 1 + COORDINATE_SIZE, toIndex = 1 + 2 * COORDINATE_SIZE)
val header: JsonObject = buildJsonObject {
put("alg", ES256_ALG)
put("typ", DPOP_TYP)
putJsonObject("jwk") {
put("kty", EC_KTY)
put("crv", P256_CRV)
put("x", x.base64UrlNoPad())
put("y", y.base64UrlNoPad())
}
}
val claims: JsonObject = buildJsonObject {
put("jti", UUID.randomUUID().toString())
put("iat", clock.now().epochSeconds)
put("htm", httpMethod.uppercase())
put("htu", stripQueryAndFragment(httpUri))
if (accessToken != null) {
put("ath", sha256(accessToken.toByteArray(Charsets.US_ASCII)).base64UrlNoPad())
}
}
val signingInput = json.encodeToString(JsonObject.serializer(), header)
.toByteArray(Charsets.UTF_8).base64UrlNoPad() +
"." +
json.encodeToString(JsonObject.serializer(), claims)
.toByteArray(Charsets.UTF_8).base64UrlNoPad()
val signature = try {
deviceKeyManager.sign(signingInput.toByteArray(Charsets.US_ASCII))
} catch (e: Exception) {
TangemLogger.e("Failed to sign DPoP proof", e)
return@withContext None
}
Some("$signingInput.${signature.base64UrlNoPad()}")
}
private fun sha256(bytes: ByteArray): ByteArray {
return MessageDigest.getInstance(SHA_256).digest(bytes)
}
private fun ByteArray.base64UrlNoPad(): String =
Base64.encodeToString(this, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
/**
* Removes query and fragment without touching scheme/authority/path encoding.
* `java.net.URI.path` would decode percent-encoded bytes (e.g. `%2F` `/`), which would
* make the `htu` claim diverge from the wire URI and fail DPoP verification.
*/
private fun stripQueryAndFragment(uri: String): String = uri.substringBefore('#').substringBefore('?')
private companion object {
const val ES256_ALG = "ES256"
const val DPOP_TYP = "dpop+jwt"
const val EC_KTY = "EC"
const val P256_CRV = "P-256"
const val SHA_256 = "SHA-256"
const val COORDINATE_SIZE = 32
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.lib.auth.dpop.internal
import arrow.core.None
import arrow.core.Option
import com.tangem.lib.auth.dpop.DpopProofFactory
internal object DisabledDpopProofFactory : DpopProofFactory {
override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String> = None
}

View file

@ -0,0 +1,55 @@
package com.tangem.lib.auth.http
import com.tangem.datasource.api.auth.RequiresDpopProof
import com.tangem.datasource.api.auth.RequiresSessionAuth
import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.lib.auth.session.SessionTokensStore
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
/**
* Adds [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP headers to requests whose
* Retrofit method is marked with [RequiresDpopProof] or the umbrella [RequiresSessionAuth]:
* - `Authorization: DPoP <access-token>` present if [SessionTokensStore] holds an access token.
* - `DPoP: <proof-jwt>` freshly generated for every annotated request; `ath` claim is set if
* the access token is present.
*
* Methods **without** the annotation pass through unchanged keeps public endpoints
* (e.g. `/auth/nonce/auth`, `/auth/authenticate`) free of unnecessary proof generation.
*
* On unrecoverable proof-generation failures (e.g. device key unavailable) the request is passed
* through unmodified the upstream HTTP layer will surface the resulting 401/403 and the
* `SessionAuthenticator` (if installed) will attempt recovery.
*/
class DpopAuthorizationInterceptor(
private val store: SessionTokensStore,
private val proofFactory: DpopProofFactory,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
if (!original.requiresDpopProof()) return chain.proceed(original)
val accessToken = runBlocking { store.get().getOrNull()?.accessToken }
if (accessToken == null) {
// Annotated endpoint reached without a session — let the upstream HTTP layer surface
// the resulting 401 so `SessionAuthenticator` can drive recovery.
TangemLogger.e("Skipping DPoP headers: no access token in store")
return chain.proceed(original)
}
val proof = runBlocking {
proofFactory.create(original.method, original.htuUrl(), accessToken)
}.getOrNull()
if (proof == null) {
TangemLogger.e("DPoP proof generation failed; sending request without DPoP headers")
return chain.proceed(original)
}
return chain.proceed(original.withDpopHeaders(accessToken, proof))
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.lib.auth.http
import com.tangem.datasource.api.auth.RequiresDpopProof
import com.tangem.datasource.api.auth.RequiresSessionAuth
import com.tangem.datasource.api.auth.RequiresSessionRefresh
import okhttp3.Request
import retrofit2.Invocation
internal const val HEADER_AUTHORIZATION = "Authorization"
internal const val HEADER_DPOP = "DPoP"
internal const val DPOP_SCHEME = "DPoP"
/**
* `true` when the Retrofit method behind this request opts into outgoing DPoP proof headers
* either explicitly via [RequiresDpopProof] or transitively via the umbrella [RequiresSessionAuth].
*/
internal fun Request.requiresDpopProof(): Boolean =
hasMethodAnnotation<RequiresDpopProof>() || hasMethodAnnotation<RequiresSessionAuth>()
/**
* `true` when the Retrofit method behind this request opts into automatic session-token refresh
* on 401/403 either explicitly via [RequiresSessionRefresh] or transitively via [RequiresSessionAuth].
*/
internal fun Request.requiresSessionRefresh(): Boolean =
hasMethodAnnotation<RequiresSessionRefresh>() || hasMethodAnnotation<RequiresSessionAuth>()
/** Returns a copy of this request with `Authorization: DPoP <token>` and `DPoP: <proof>` headers set. */
internal fun Request.withDpopHeaders(accessToken: String, proof: String): Request = newBuilder()
.header(HEADER_AUTHORIZATION, "$DPOP_SCHEME $accessToken")
.header(HEADER_DPOP, proof)
.build()
/**
* Target URI for the DPoP `htu` claim full URL stripped of query and fragment per RFC 9449 §4.2.
* Callers must pass this (not the raw `url.toString()`) to `DpopProofFactory.create` so the contract
* is honoured at the call site rather than relying on defensive stripping inside any one factory impl.
*/
internal fun Request.htuUrl(): String = url.toString().substringBefore('#').substringBefore('?')
private inline fun <reified A : Annotation> Request.hasMethodAnnotation(): Boolean =
tag(Invocation::class.java)?.method()?.isAnnotationPresent(A::class.java) == true

View file

@ -0,0 +1,54 @@
package com.tangem.lib.auth.http
import arrow.core.getOrElse
import com.tangem.datasource.api.auth.RequiresSessionRefresh
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.lib.auth.session.SessionTokenRefresher
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
/**
* OkHttp [Authenticator] that reacts to 401/403 by rotating session tokens via
* [SessionTokenRefresher] and retrying the original request with a fresh DPoP proof.
*
* Returns `null` (giving up) when:
* - the response code is not 401/403;
* - the Retrofit method is **not** annotated with [RequiresSessionRefresh] (or the umbrella
* [RequiresSessionAuth]) keeps public endpoints and refresh-flow endpoints themselves
* (annotated with `@RequiresDpopProof` only) from triggering token rotation on incidental 401s;
* - the request was already retried once (`response.priorResponse != null`);
* - the refresher fails (revoked session, network error, etc.).
*
* This guarantees at most one retry per call site OkHttp will not loop on persistent 401s.
*/
class SessionAuthenticator(
private val refresher: SessionTokenRefresher,
private val proofFactory: DpopProofFactory,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (response.code != Code.UNAUTHORIZED.numericCode && response.code != Code.FORBIDDEN.numericCode) return null
if (response.priorResponse != null) return null
if (!response.request.requiresSessionRefresh()) return null
val refreshed = runBlocking { refresher.refresh() }.getOrElse { error ->
TangemLogger.e("Session refresh failed ($error); surfacing original ${response.code}")
return null
}
val request = response.request
val proof = runBlocking {
proofFactory.create(request.method, request.htuUrl(), refreshed.accessToken)
}.getOrElse {
TangemLogger.e("DPoP proof generation failed after refresh; cannot retry request")
return null
}
return request.withDpopHeaders(refreshed.accessToken, proof)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.lib.auth.nonce
/**
* Decrypts server-issued nonces
*/
interface AuthNonceDecryptor {
/**
* Decrypts [encryptedNonce] a Base64url-encoded (no padding) RSA-encrypted nonce from the backend.
*
* @param encryptedNonce Base64url-encoded encrypted nonce
* @return decrypted nonce as a string
* @throws Exception if decryption fails (invalid key, corrupted ciphertext, etc.)
*/
suspend fun decryptNonce(encryptedNonce: String): String
}

View file

@ -0,0 +1,52 @@
package com.tangem.lib.auth.nonce.internal
import android.util.Base64
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
import java.security.KeyFactory
import java.security.spec.MGF1ParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource
internal class DefaultAuthNonceDecryptor(
authServiceKeyBase64: String,
private val dispatchers: CoroutineDispatcherProvider,
) : AuthNonceDecryptor {
private val privateKey = run {
val keyBytes = Base64.decode(authServiceKeyBase64, Base64.NO_WRAP)
val keySpec = PKCS8EncodedKeySpec(keyBytes)
KeyFactory.getInstance(KEY_ALGORITHM).generatePrivate(keySpec)
}
override suspend fun decryptNonce(encryptedNonce: String): String = withContext(dispatchers.default) {
try {
val encryptedBytes = Base64.decode(encryptedNonce, Base64.URL_SAFE or Base64.NO_PADDING)
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_PARAM_SPEC)
val decryptedBytes = cipher.doFinal(encryptedBytes)
String(decryptedBytes, Charsets.UTF_8)
} catch (e: Exception) {
TangemLogger.e("Nonce decryption failed", e)
throw e
}
}
private companion object {
const val KEY_ALGORITHM = "RSA"
const val CIPHER_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
val OAEP_PARAM_SPEC = OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT,
)
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.lib.auth.nonce.internal
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
internal object DisabledAuthNonceDecryptor : AuthNonceDecryptor {
override suspend fun decryptNonce(encryptedNonce: String): String =
error("AuthNonceDecryptor is disabled: feature toggle is off or auth service key is missing")
}

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,24 @@
package com.tangem.lib.auth.session
import arrow.core.Either
/**
* Registers the device with the Tangem Auth Service and persists the initial session tokens.
*
* Idempotent and safe to call on every app launch:
* - on first run, fetches a ciphered nonce from `POST /api/v1/auth/nonce/device`, decrypts it
* with the app's RSA private key, signs a `RegisterPayload` with the device key, posts it to
* `POST /api/v1/auth/register`, persists the resulting `SessionTokens` and flips the
* "device registered" flag in `AppPreferencesStore`,
* - on subsequent runs, sees the flag and short-circuits without any network traffic.
*
* Tokens returned by `/register` are not surfaced to callers they're written to
* `SessionTokensStore` and accessed from there. The result type carries only success/failure
* so callers can log/report transient errors.
*
* Implementations serialise concurrent callers so the server-issued nonce isn't consumed twice.
*/
interface DeviceRegistrar {
suspend fun register(): Either<DeviceRegistrationError, Unit>
}

View file

@ -0,0 +1,29 @@
package com.tangem.lib.auth.session
/**
* Typed failure mode of `DeviceRegistrar.register()`. Mirrors [SessionRefreshError] but covers
* the registration-specific paths (`/nonce/device` + `/register`).
*/
sealed class DeviceRegistrationError {
/** API-level error from `/nonce/device` or `/register`. Transient unless [cause] says otherwise. */
data class Api(val cause: AuthError) : DeviceRegistrationError()
/** Device key is not provisioned in Keystore (registration cannot proceed without one). */
data object DeviceKeyUnavailable : DeviceRegistrationError()
/** RSA/OAEP decryption of the server-issued device-registration nonce failed. */
data class NonceDecryptionFailed(val cause: Throwable) : DeviceRegistrationError()
/** Device-key signing of the registration payload failed (Keystore I/O or ECDSA failure). */
data class SigningFailed(val cause: Throwable) : DeviceRegistrationError()
/**
* Persisting the freshly minted tokens or the `IS_DEVICE_REGISTERED_KEY` flag failed
* (DataStore I/O). The flag stays `false`, so the next launch retries cleanly.
*/
data class PersistenceFailed(val cause: Throwable) : DeviceRegistrationError()
/** Registrar is disabled via `AND_15438_BACKEND_AUTHENTICATION_ENABLED` feature toggle. */
data object Disabled : DeviceRegistrationError()
}

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,31 @@
package com.tangem.lib.auth.session
import kotlinx.datetime.Instant
/**
* JWT session tokens issued by the Tangem Auth Service pure domain model.
*
* Persisted on the device via [SessionTokensStore]. The default implementation serialises a
* storage DTO mirroring the wire format (`TokenApiResponse`) into AES-256-GCM-encrypted local
* storage (`SecureStorage` / `AndroidSecureStorageV2`) with the master key residing in
* AndroidKeystore. Survives app process death but not user data wipe / app uninstall.
*
* The domain class itself carries no serialization annotations: it can grow with business
* helpers (`isAccessTokenExpired`, computed properties, etc.) without touching the on-disk
* format.
*
* @property accessToken short-lived signed JWT (verified via JWKS at API Gateway). Sent as
* `Authorization: DPoP <accessToken>` on every authenticated request.
* @property refreshToken opaque rotation token. `null` for ORANGE-tier sessions (require full
* re-authentication for every new access token see SR-8 / token policy by trust tier).
* @property refreshTokenExpiresAt `null` if [refreshToken] is `null`.
* @property walletIds wallet ids bound to the device by the backend, mirrored from token claims
* to avoid parsing the JWT on the client.
*/
data class SessionTokens(
val accessToken: String,
val accessTokenExpiresAt: Instant,
val refreshToken: String?,
val refreshTokenExpiresAt: Instant?,
val walletIds: List<String>,
)

View file

@ -0,0 +1,20 @@
package com.tangem.lib.auth.session
import arrow.core.Option
/**
* Persistent, hardware-backed storage of [SessionTokens].
*
* Implementations are expected to survive app process death but not user data wipe / app uninstall.
*/
interface SessionTokensStore {
/** Returns the currently stored tokens, or [arrow.core.None] if the device is not authenticated. */
suspend fun get(): Option<SessionTokens>
/** Atomically replaces the stored tokens. */
suspend fun save(tokens: SessionTokens)
/** Removes stored tokens. */
suspend fun clear()
}

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,115 @@
package com.tangem.lib.auth.session.internal
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterPayload
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.lib.auth.session.DeviceRegistrationError
import com.tangem.lib.auth.session.SessionTokensStore
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@Suppress("LongParameterList")
internal class DefaultDeviceRegistrar(
private val authApi: AuthApi,
private val store: SessionTokensStore,
private val deviceKeyManager: DeviceKeyManager,
private val nonceDecryptor: AuthNonceDecryptor,
private val signedRequestPayload: SignedRequestPayload,
private val errorConverter: AuthErrorConverter,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : DeviceRegistrar {
private val mutex = Mutex()
override suspend fun register(): Either<DeviceRegistrationError, Unit> = withContext(dispatchers.io) {
// `Mutex` guards against the unlikely case of two concurrent callers passing the
// already-registered check together and consuming the same `/nonce/device` value twice.
mutex.withLock { runRegister() }
}
private suspend fun runRegister(): Either<DeviceRegistrationError, Unit> = either {
val isAlreadyRegistered = appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.IS_DEVICE_REGISTERED_KEY,
default = false,
)
if (isAlreadyRegistered) {
TangemLogger.i("Device already registered — skipping /register")
return@either
}
TangemLogger.i("Starting device registration")
val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull()
?: raise(DeviceRegistrationError.DeviceKeyUnavailable)
val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap()
val nonceResponse = authApi.requestDeviceNonce(NonceApiRequest(devicePublicKey = devicePublicKeyBase64))
val cipheredNonce = when (nonceResponse) {
is ApiResponse.Success -> nonceResponse.data.cipheredNonce
is ApiResponse.Error -> {
val authError = errorConverter.convert(nonceResponse.cause)
TangemLogger.e("/nonce/device request failed: $authError")
raise(DeviceRegistrationError.Api(authError))
}
}
val nonce = try {
nonceDecryptor.decryptNonce(cipheredNonce)
} catch (e: Exception) {
TangemLogger.e("Failed to decrypt device-registration nonce", e)
raise(DeviceRegistrationError.NonceDecryptionFailed(e))
}
val payload = RegisterPayload(
devicePublicKey = devicePublicKeyBase64,
nonce = nonce,
attestationToken = null,
metadata = signedRequestPayload.deviceMetadata,
)
val signature = try {
deviceKeyManager.sign(signedRequestPayload.canonicalize(payload)).toBase64NoWrap()
} catch (e: Exception) {
TangemLogger.e("Failed to sign device-registration payload", e)
raise(DeviceRegistrationError.SigningFailed(e))
}
val registerResponse = authApi.register(RegisterApiRequest(payload = payload, signature = signature))
when (registerResponse) {
is ApiResponse.Success -> {
val tokens = SessionTokensConverter.convertBack(registerResponse.data)
try {
// Keep both writes inside one catch — if the second one fails, the flag stays
// `false` and the next launch retries cleanly. Worst case: tokens are persisted
// without the flag, and the retry mints fresh ones that overwrite them.
store.save(tokens)
appPreferencesStore.store(key = PreferencesKeys.IS_DEVICE_REGISTERED_KEY, value = true)
} catch (e: Exception) {
TangemLogger.e("Failed to persist device-registration tokens / flag", e)
raise(DeviceRegistrationError.PersistenceFailed(e))
}
TangemLogger.i("Device registered successfully")
}
is ApiResponse.Error -> {
val authError = errorConverter.convert(registerResponse.cause)
TangemLogger.e("/register request failed: $authError")
raise(DeviceRegistrationError.Api(authError))
}
}
}
}

View file

@ -0,0 +1,169 @@
package com.tangem.lib.auth.session.internal
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.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.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 signedRequestPayload: SignedRequestPayload,
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 = signedRequestPayload.deviceMetadata,
)
val signature = try {
deviceKeyManager.sign(signedRequestPayload.canonicalize(payload)).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 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,62 @@
package com.tangem.lib.auth.session.internal
import arrow.core.None
import arrow.core.Option
import arrow.core.Some
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.tangem.common.services.secure.SecureStorage
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.lib.auth.session.SessionTokens
import com.tangem.lib.auth.session.SessionTokensStore
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
/**
* Stores tokens in [SecureStorage] using the wire-format [TokenApiResponse] as the on-disk
* DTO keeps the storage layout in lockstep with the Auth Service contract while
* isolating the [SessionTokens] domain model from serialization concerns.
*
* The underlying `AndroidSecureStorageV2` wraps `SharedPreferences` with an AES-256-GCM
* cipher whose key lives in AndroidKeystore, so token blobs are encrypted at rest and only
* decryptable on this device.
*/
internal class DefaultSessionTokensStore(
private val storage: SecureStorage,
private val moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
) : SessionTokensStore {
private val adapter: JsonAdapter<TokenApiResponse> by lazy {
moshi.adapter(TokenApiResponse::class.java)
}
override suspend fun get(): Option<SessionTokens> = withContext(dispatchers.io) {
val payload = storage.getAsString(KEY) ?: return@withContext None
try {
val dto = adapter.fromJson(payload) ?: return@withContext None
Some(SessionTokensConverter.convertBack(dto))
} catch (e: Exception) {
TangemLogger.e("Failed to decode session tokens; clearing storage", e)
storage.delete(KEY)
None
}
}
override suspend fun save(tokens: SessionTokens) {
withContext(dispatchers.io) {
storage.store(KEY, adapter.toJson(SessionTokensConverter.convert(tokens)))
}
}
override suspend fun clear() {
withContext(dispatchers.io) {
storage.delete(KEY)
}
}
private companion object {
const val KEY = "session_tokens"
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.lib.auth.session.internal
import arrow.core.Either
import arrow.core.left
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.lib.auth.session.DeviceRegistrationError
internal object DisabledDeviceRegistrar : DeviceRegistrar {
override suspend fun register(): Either<DeviceRegistrationError, Unit> {
return DeviceRegistrationError.Disabled.left()
}
}

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,19 @@
package com.tangem.lib.auth.session.internal
import arrow.core.None
import arrow.core.Option
import com.tangem.lib.auth.session.SessionTokens
import com.tangem.lib.auth.session.SessionTokensStore
/**
* No-op fallback used when the backend-authentication feature toggle is off
* or the encrypted storage failed to initialise.
*/
internal object DisabledSessionTokensStore : SessionTokensStore {
override suspend fun get(): Option<SessionTokens> = None
override suspend fun save(tokens: SessionTokens) = Unit
override suspend fun clear() = Unit
}

View file

@ -0,0 +1,30 @@
package com.tangem.lib.auth.session.internal
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.lib.auth.session.SessionTokens
import com.tangem.utils.converter.TwoWayConverter
import kotlinx.datetime.Instant
/**
* Maps between the [SessionTokens] domain model and the [TokenApiResponse] wire/storage DTO.
* `convert` produces the on-disk / on-wire shape; `convertBack` parses ISO-8601 timestamps
* into [kotlinx.datetime.Instant].
*/
internal object SessionTokensConverter : TwoWayConverter<SessionTokens, TokenApiResponse> {
override fun convert(value: SessionTokens): TokenApiResponse = TokenApiResponse(
accessToken = value.accessToken,
accessTokenExpiresAt = value.accessTokenExpiresAt.toString(),
refreshToken = value.refreshToken,
refreshTokenExpiresAt = value.refreshTokenExpiresAt?.toString(),
walletIds = value.walletIds,
)
override fun convertBack(value: TokenApiResponse): SessionTokens = SessionTokens(
accessToken = value.accessToken,
accessTokenExpiresAt = Instant.parse(value.accessTokenExpiresAt),
refreshToken = value.refreshToken,
refreshTokenExpiresAt = value.refreshTokenExpiresAt?.let(Instant::parse),
walletIds = value.walletIds,
)
}

View file

@ -0,0 +1,75 @@
package com.tangem.lib.auth.session.internal
import android.util.Base64
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload
import com.tangem.datasource.api.auth.models.request.DeviceMetadata
import com.tangem.datasource.api.auth.models.request.RegisterPayload
import com.tangem.utils.info.AppInfoProvider
import javax.inject.Inject
/**
* Shared helpers for the device-signed request payloads used by `/auth/register`
* ([RegisterPayload]) and `/auth/authenticate` ([AuthenticationPayload]). The two DTOs have
* identical field shapes the canonicalisation is parameterised by primitives and exposed via
* type-specific overloads, so the two DTOs don't need to share a common interface.
*/
internal class SignedRequestPayload @Inject constructor(
private val appInfoProvider: AppInfoProvider,
) {
/** Snapshot of [appInfoProvider]'s device facts as the network DTO. `userAgent` is intentionally null. */
val deviceMetadata: DeviceMetadata
get() = DeviceMetadata(
deviceModel = appInfoProvider.device,
// Backend contract is lowercase `android`/`ios`; AppInfoProvider returns `"Android"`.
os = appInfoProvider.platform.lowercase(),
osVersion = appInfoProvider.osVersion,
appVersion = appInfoProvider.appVersion,
userAgent = null,
locale = appInfoProvider.language,
timezone = appInfoProvider.timezone,
)
/** @see canonicalize */
fun canonicalize(payload: AuthenticationPayload): ByteArray = canonicalize(
devicePublicKey = payload.devicePublicKey,
nonce = payload.nonce,
attestationToken = payload.attestationToken,
metadata = payload.metadata,
)
/** @see canonicalize */
fun canonicalize(payload: RegisterPayload): ByteArray = canonicalize(
devicePublicKey = payload.devicePublicKey,
nonce = payload.nonce,
attestationToken = payload.attestationToken,
metadata = payload.metadata,
)
/**
* Stable, newline-separated representation of the signed payload. Backend treats the bytes
* opaquely; must stay aligned with the server-side canonicalisation. Field order matches the
* declaration order of [RegisterPayload] / [AuthenticationPayload], with one exception:
* [DeviceMetadata.userAgent] is intentionally NOT included in the signed bytes (it's always
* `null` in [deviceMetadata] and the server doesn't sign it either).
*/
private fun canonicalize(
devicePublicKey: String,
nonce: String,
attestationToken: String?,
metadata: DeviceMetadata,
): ByteArray = buildString {
append(devicePublicKey).append('\n')
append(nonce).append('\n')
append(attestationToken.orEmpty()).append('\n')
append(metadata.deviceModel.orEmpty()).append('\n')
append(metadata.os).append('\n')
append(metadata.osVersion.orEmpty()).append('\n')
append(metadata.appVersion.orEmpty()).append('\n')
append(metadata.locale.orEmpty()).append('\n')
append(metadata.timezone.orEmpty())
}.toByteArray(Charsets.UTF_8)
}
/** Base64-encodes [this] without line wraps — required for DPoP proofs and device signatures. */
internal fun ByteArray.toBase64NoWrap(): String = Base64.encodeToString(this, Base64.NO_WRAP)

View file

@ -0,0 +1,175 @@
package com.tangem.lib.auth.devicekey.internal
import arrow.core.None
import com.google.common.truth.Truth.assertThat
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertThrows
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.Certificate
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultDeviceKeyManagerTest {
private val keyStore: KeyStore = mockk(relaxed = true)
private val dispatchers = TestingCoroutineDispatcherProvider()
private val manager: DefaultDeviceKeyManager = DefaultDeviceKeyManager(keyStore, dispatchers)
@BeforeEach
fun setup() {
clearMocks(keyStore)
}
@AfterEach
fun teardown() {
unmockkAll()
}
@Test
fun `generateIfMissing returns false when key already exists`() = runTest {
every { keyStore.containsAlias(KEY_ALIAS) } returns true
val result = manager.generateIfMissing()
assertThat(result).isFalse()
}
@Test
fun `generateIfMissing returns false when generation fails`() = runTest {
every { keyStore.containsAlias(KEY_ALIAS) } returns false
val keyPairGenerator = mockk<KeyPairGenerator>(relaxed = true)
every { keyPairGenerator.generateKeyPair() } throws RuntimeException("keystore unavailable")
mockkStatic(KeyPairGenerator::class)
every { KeyPairGenerator.getInstance("EC", "AndroidKeyStore") } returns keyPairGenerator
val result = manager.generateIfMissing()
assertThat(result).isFalse()
}
@Test
fun `getPublicKey returns last 65 bytes from encoded key`() = runTest {
val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x04 }
val x509Header = ByteArray(26) { 0x30 }
val encoded = x509Header + rawPoint
val publicKey = mockk<java.security.PublicKey>()
every { publicKey.encoded } returns encoded
val cert = mockk<Certificate>()
every { cert.publicKey } returns publicKey
every { keyStore.getCertificate(KEY_ALIAS) } returns cert
val result = manager.getPublicKey()
assertThat(result.getOrNull()).isEqualTo(rawPoint)
}
@Test
fun `getPublicKey returns None when certificate not found`() = runTest {
every { keyStore.getCertificate(KEY_ALIAS) } returns null
val result = manager.getPublicKey()
assertThat(result).isEqualTo(None)
}
@Test
fun `getPublicKey returns None when point prefix is not uncompressed`() = runTest {
val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x02 }
val x509Header = ByteArray(26) { 0x30 }
val encoded = x509Header + rawPoint
val publicKey = mockk<java.security.PublicKey>()
every { publicKey.encoded } returns encoded
val cert = mockk<Certificate>()
every { cert.publicKey } returns publicKey
every { keyStore.getCertificate(KEY_ALIAS) } returns cert
val result = manager.getPublicKey()
assertThat(result).isEqualTo(None)
}
@Test
fun `sign returns raw 64-byte signature`() = runTest {
val data = "test data".toByteArray()
val r = ByteArray(32) { 0x01 }
val s = ByteArray(32) { 0x02 }
val derSignature = buildDer(r, s)
val privateKey = mockk<PrivateKey>()
every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey
val javaSig = mockk<java.security.Signature>()
every { javaSig.initSign(privateKey) } returns Unit
every { javaSig.update(data) } returns Unit
every { javaSig.sign() } returns derSignature
mockkSignatureGetInstance(javaSig)
val result = manager.sign(data)
assertThat(result).hasLength(64)
assertThat(result.copyOfRange(0, 32)).isEqualTo(r)
assertThat(result.copyOfRange(32, 64)).isEqualTo(s)
}
@Test
fun `sign throws DeviceKeySigningException when key not found`() = runTest {
every { keyStore.getKey(KEY_ALIAS, null) } returns null
val exception = assertThrows<DeviceKeySigningException> {
manager.sign("data".toByteArray())
}
assertThat(exception.message).contains("Device key not found")
}
@Test
fun `sign wraps unexpected exception in DeviceKeySigningException`() = runTest {
val privateKey = mockk<PrivateKey>()
every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey
val javaSig = mockk<java.security.Signature>()
every { javaSig.initSign(privateKey) } throws RuntimeException("hardware error")
mockkSignatureGetInstance(javaSig)
val exception = assertThrows<DeviceKeySigningException> {
manager.sign("data".toByteArray())
}
assertThat(exception.message).isEqualTo("Signing failed")
assertThat(exception.cause).isInstanceOf(RuntimeException::class.java)
}
private fun mockkSignatureGetInstance(mock: java.security.Signature) {
io.mockk.mockkStatic(java.security.Signature::class)
every { java.security.Signature.getInstance("SHA256withECDSA") } returns mock
}
private fun buildDer(r: ByteArray, s: ByteArray): ByteArray {
val rTlv = byteArrayOf(0x02, r.size.toByte()) + r
val sTlv = byteArrayOf(0x02, s.size.toByte()) + s
val body = rTlv + sTlv
return byteArrayOf(0x30, body.size.toByte()) + body
}
private companion object {
const val KEY_ALIAS = "tangem_device_key"
}
}

View file

@ -0,0 +1,170 @@
package com.tangem.lib.auth.dpop.internal
import arrow.core.None
import arrow.core.Some
import com.google.common.truth.Truth.assertThat
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
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.test.runTest
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.security.MessageDigest
import java.util.Base64
import java.util.UUID
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultDpopProofFactoryTest {
private val dispatchers = TestingCoroutineDispatcherProvider()
private val deviceKeyManager: DeviceKeyManager = mockk()
private val json = Json.Default
// Fixed P-256 public key (uncompressed): 0x04 || X(32) || Y(32). Values are arbitrary but
// span both halves so any off-by-one slice mistake is caught.
private val devicePublicKey: ByteArray = byteArrayOf(0x04) +
ByteArray(COORDINATE_SIZE) { it.toByte() } +
ByteArray(COORDINATE_SIZE) { (it + COORDINATE_SIZE).toByte() }
private val signatureBytes: ByteArray = ByteArray(SIGNATURE_SIZE) { (it + 1).toByte() }
private val fixedInstant = Instant.fromEpochSeconds(1_700_000_000)
private val fixedJti = UUID.fromString("11111111-2222-3333-4444-555555555555")
private lateinit var factory: DefaultDpopProofFactory
@BeforeEach
fun setup() {
// android.util.Base64 → java.util.Base64
mockkStatic(android.util.Base64::class)
every { android.util.Base64.encodeToString(any(), any()) } answers {
val bytes = firstArg<ByteArray>()
val flags = secondArg<Int>()
val padded = flags and android.util.Base64.NO_PADDING == 0
val encoder = if (flags and android.util.Base64.URL_SAFE != 0) {
if (padded) Base64.getUrlEncoder() else Base64.getUrlEncoder().withoutPadding()
} else {
Base64.getEncoder()
}
encoder.encodeToString(bytes)
}
mockkStatic(UUID::class)
every { UUID.randomUUID() } returns fixedJti
coEvery { deviceKeyManager.getPublicKey() } returns Some(devicePublicKey)
coEvery { deviceKeyManager.sign(any()) } returns signatureBytes
factory = DefaultDpopProofFactory(
deviceKeyManager = deviceKeyManager,
json = json,
clock = object : Clock { override fun now(): Instant = fixedInstant },
dispatchers = dispatchers,
)
}
@AfterEach
fun teardown() = unmockkAll()
@Test
fun `create produces JWS with ath when access token is provided`() = runTest {
val token = "header.payload.signature"
val proof = factory.create("post", "https://example.com/api/v1/auth/refresh?ignored=1#frag", token)
.getOrNull()!!
val parts = proof.split('.')
assertThat(parts).hasSize(3)
val header = decodeJsonObject(parts[0])
assertThat(header["alg"]?.jsonPrimitive?.contentOrNull).isEqualTo("ES256")
assertThat(header["typ"]?.jsonPrimitive?.contentOrNull).isEqualTo("dpop+jwt")
val jwk = header["jwk"]!!.jsonObject
assertThat(jwk["kty"]?.jsonPrimitive?.contentOrNull).isEqualTo("EC")
assertThat(jwk["crv"]?.jsonPrimitive?.contentOrNull).isEqualTo("P-256")
assertThat(jwk["x"]?.jsonPrimitive?.contentOrNull)
.isEqualTo(base64UrlNoPad(devicePublicKey.sliceArray(1..COORDINATE_SIZE)))
assertThat(jwk["y"]?.jsonPrimitive?.contentOrNull)
.isEqualTo(base64UrlNoPad(devicePublicKey.sliceArray(COORDINATE_SIZE + 1..2 * COORDINATE_SIZE)))
val claims = decodeJsonObject(parts[1])
assertThat(claims["jti"]?.jsonPrimitive?.contentOrNull).isEqualTo(fixedJti.toString())
assertThat(claims["iat"]?.jsonPrimitive?.longOrNull).isEqualTo(fixedInstant.epochSeconds)
assertThat(claims["htm"]?.jsonPrimitive?.contentOrNull).isEqualTo("POST")
assertThat(claims["htu"]?.jsonPrimitive?.contentOrNull).isEqualTo("https://example.com/api/v1/auth/refresh")
assertThat(claims["ath"]?.jsonPrimitive?.contentOrNull)
.isEqualTo(base64UrlNoPad(sha256(token.toByteArray(Charsets.US_ASCII))))
assertThat(parts[2]).isEqualTo(base64UrlNoPad(signatureBytes))
}
@Test
fun `create omits ath when access token is null`() = runTest {
val proof = factory.create("POST", "https://example.com/refresh", null).getOrNull()!!
val claims = decodeJsonObject(proof.split('.')[1])
assertThat(claims.containsKey("ath")).isFalse()
assertThat(claims["htm"]?.jsonPrimitive?.contentOrNull).isEqualTo("POST")
}
@Test
fun `htu preserves percent-encoded characters in path`() = runTest {
// DPoP verification is byte-sensitive: %2F must NOT be decoded to / in htu.
val proof = factory.create("GET", "https://api.example.com/wallet%2F123/sub?x=1", null).getOrNull()!!
val claims = decodeJsonObject(proof.split('.')[1])
assertThat(claims["htu"]?.jsonPrimitive?.contentOrNull)
.isEqualTo("https://api.example.com/wallet%2F123/sub")
}
@Test
fun `create returns None when device key unavailable`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns None
val result = factory.create("POST", "https://example.com", null)
assertThat(result).isEqualTo(None)
}
@Test
fun `create signs the b64u-encoded header dot payload`() = runTest {
factory.create("GET", "https://example.com", null)
// The signing input is `<header_b64>.<claims_b64>` — verify it has a dot separator and
// a non-empty header section.
coVerify {
deviceKeyManager.sign(match { bytes ->
val text = String(bytes, Charsets.US_ASCII)
text.contains('.') && text.substringBefore('.').isNotEmpty()
})
}
}
private fun decodeJsonObject(b64: String): JsonObject =
json.parseToJsonElement(String(Base64.getUrlDecoder().decode(b64), Charsets.UTF_8)).jsonObject
private fun base64UrlNoPad(bytes: ByteArray): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
private fun sha256(bytes: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(bytes)
private companion object {
const val COORDINATE_SIZE = 32
const val SIGNATURE_SIZE = 64
}
}

View file

@ -0,0 +1,175 @@
package com.tangem.lib.auth.http
import arrow.core.None
import arrow.core.Some
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.auth.RequiresDpopProof
import com.tangem.datasource.api.auth.RequiresSessionAuth
import com.tangem.datasource.api.auth.RequiresSessionRefresh
import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.lib.auth.session.SessionTokens
import com.tangem.lib.auth.session.SessionTokensStore
import io.mockk.CapturingSlot
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.datetime.Instant
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import retrofit2.Invocation
import java.lang.reflect.Method
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DpopAuthorizationInterceptorTest {
private val store: SessionTokensStore = mockk()
private val proofFactory: DpopProofFactory = mockk()
private val interceptor = DpopAuthorizationInterceptor(store, proofFactory)
@BeforeEach
fun setup() {
clearMocks(store, proofFactory)
}
private val storedTokens = SessionTokens(
accessToken = "old-access",
accessTokenExpiresAt = Instant.fromEpochSeconds(1_700_000_000),
refreshToken = "rt",
refreshTokenExpiresAt = Instant.fromEpochSeconds(1_700_003_600),
walletIds = emptyList(),
)
@Test
fun `@RequiresDpopProof method gets Authorization and DPoP headers`() {
coEvery { store.get() } returns Some(storedTokens)
coEvery { proofFactory.create(any(), any(), "old-access") } returns Some("proof-jwt")
val proceeded = slot<Request>()
val chain = chain(request(dpop = true), proceeded)
interceptor.intercept(chain)
assertThat(proceeded.captured.header("Authorization")).isEqualTo("DPoP old-access")
assertThat(proceeded.captured.header("DPoP")).isEqualTo("proof-jwt")
}
@Test
fun `@RequiresSessionAuth (umbrella) method gets headers — covers proof path transitively`() {
coEvery { store.get() } returns Some(storedTokens)
coEvery { proofFactory.create(any(), any(), "old-access") } returns Some("proof-jwt")
val proceeded = slot<Request>()
val chain = chain(request(sessionAuth = true), proceeded)
interceptor.intercept(chain)
assertThat(proceeded.captured.header("Authorization")).isEqualTo("DPoP old-access")
assertThat(proceeded.captured.header("DPoP")).isEqualTo("proof-jwt")
}
@Test
fun `@RequiresSessionRefresh-only method does NOT get DPoP headers`() {
val proceeded = slot<Request>()
val chain = chain(request(sessionRefresh = true), proceeded)
interceptor.intercept(chain)
assertThat(proceeded.captured.header("Authorization")).isNull()
assertThat(proceeded.captured.header("DPoP")).isNull()
coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) }
}
@Test
fun `annotated request without access token passes through unmodified`() {
coEvery { store.get() } returns None
val proceeded = slot<Request>()
val chain = chain(request(dpop = true), proceeded)
interceptor.intercept(chain)
assertThat(proceeded.captured.header("Authorization")).isNull()
assertThat(proceeded.captured.header("DPoP")).isNull()
coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) }
}
@Test
fun `unannotated request passes through unchanged — proof factory never invoked`() {
val proceeded = slot<Request>()
val chain = chain(request(), proceeded)
interceptor.intercept(chain)
assertThat(proceeded.captured.header("Authorization")).isNull()
assertThat(proceeded.captured.header("DPoP")).isNull()
coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) }
}
@Test
fun `request without Invocation tag (not via Retrofit) is treated as unannotated`() {
val original = Request.Builder().url("https://example.com/api/v1/foo").build()
val proceeded = slot<Request>()
val chain = chain(original, proceeded)
interceptor.intercept(chain)
assertThat(proceeded.captured.header("DPoP")).isNull()
coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) }
}
@Test
fun `proof generation failure on annotated request passes through without headers`() {
coEvery { store.get() } returns Some(storedTokens)
coEvery { proofFactory.create(any(), any(), any()) } returns None
val proceeded = slot<Request>()
val chain = chain(request(dpop = true), proceeded)
interceptor.intercept(chain)
assertThat(proceeded.captured.header("Authorization")).isNull()
assertThat(proceeded.captured.header("DPoP")).isNull()
}
private fun request(
dpop: Boolean = false,
sessionRefresh: Boolean = false,
sessionAuth: Boolean = false,
): Request {
val builder = Request.Builder().url("https://example.com/api/v1/foo")
builder.tag(Invocation::class.java, invocationWith(dpop, sessionRefresh, sessionAuth))
return builder.build()
}
private fun invocationWith(dpop: Boolean, sessionRefresh: Boolean, sessionAuth: Boolean): Invocation {
val method = mockk<Method>()
every { method.isAnnotationPresent(RequiresDpopProof::class.java) } returns dpop
every { method.isAnnotationPresent(RequiresSessionRefresh::class.java) } returns sessionRefresh
every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns sessionAuth
val invocation = mockk<Invocation>()
every { invocation.method() } returns method
return invocation
}
private fun chain(request: Request, captureSlot: CapturingSlot<Request>): Interceptor.Chain {
val response = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("ok")
.build()
val chain = mockk<Interceptor.Chain>()
every { chain.request() } returns request
every { chain.proceed(capture(captureSlot)) } returns response
return chain
}
}

View file

@ -0,0 +1,175 @@
package com.tangem.lib.auth.http
import arrow.core.None
import arrow.core.Some
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.auth.RequiresDpopProof
import com.tangem.datasource.api.auth.RequiresSessionAuth
import com.tangem.datasource.api.auth.RequiresSessionRefresh
import com.tangem.lib.auth.dpop.DpopProofFactory
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 io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.datetime.Instant
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import retrofit2.Invocation
import java.lang.reflect.Method
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SessionAuthenticatorTest {
private val refresher: SessionTokenRefresher = mockk()
private val proofFactory: DpopProofFactory = mockk()
private val authenticator = SessionAuthenticator(refresher, proofFactory)
private val refreshedTokens = SessionTokens(
accessToken = "new-access",
accessTokenExpiresAt = Instant.fromEpochSeconds(1_700_000_000),
refreshToken = "rt-2",
refreshTokenExpiresAt = Instant.fromEpochSeconds(1_700_003_600),
walletIds = emptyList(),
)
@Test
fun `401 on @RequiresSessionRefresh triggers refresh and retries with new headers`() {
coEvery { refresher.refresh() } returns refreshedTokens.right()
coEvery { proofFactory.create(any(), any(), "new-access") } returns Some("fresh-proof")
val retried = authenticator.authenticate(
route = null,
response = response(code = 401, sessionRefresh = true),
)
assertThat(retried).isNotNull()
assertThat(retried!!.header("Authorization")).isEqualTo("DPoP new-access")
assertThat(retried.header("DPoP")).isEqualTo("fresh-proof")
}
@Test
fun `401 on @RequiresSessionAuth (umbrella) triggers refresh — covers refresh path transitively`() {
coEvery { refresher.refresh() } returns refreshedTokens.right()
coEvery { proofFactory.create(any(), any(), "new-access") } returns Some("fresh-proof")
val retried = authenticator.authenticate(
route = null,
response = response(code = 401, sessionAuth = true),
)
assertThat(retried).isNotNull()
}
@Test
fun `401 on @RequiresDpopProof-only method does NOT trigger refresh — prevents recursion`() {
val retried = authenticator.authenticate(
route = null,
response = response(code = 401, dpop = true),
)
assertThat(retried).isNull()
}
@Test
fun `403 also triggers refresh`() {
coEvery { refresher.refresh() } returns refreshedTokens.right()
coEvery { proofFactory.create(any(), any(), "new-access") } returns Some("fresh-proof")
val retried = authenticator.authenticate(
route = null,
response = response(code = 403, sessionRefresh = true),
)
assertThat(retried).isNotNull()
}
@Test
fun `other 4xx codes are passed through`() {
val retried = authenticator.authenticate(
route = null,
response = response(code = 404, sessionRefresh = true),
)
assertThat(retried).isNull()
}
@Test
fun `prior response present means we already retried — give up`() {
val first = response(code = 401, sessionRefresh = true)
val second = response(code = 401, sessionRefresh = true, priorResponse = first)
val retried = authenticator.authenticate(route = null, response = second)
assertThat(retried).isNull()
}
@Test
fun `unannotated request 401 is passed through without refresh`() {
val retried = authenticator.authenticate(route = null, response = response(code = 401))
assertThat(retried).isNull()
}
@Test
fun `refresh failure gives up`() {
coEvery { refresher.refresh() } returns SessionRefreshError.Api(AuthError.NetworkError).left()
val retried = authenticator.authenticate(
route = null,
response = response(code = 401, sessionRefresh = true),
)
assertThat(retried).isNull()
}
@Test
fun `proof generation None result gives up`() {
coEvery { refresher.refresh() } returns refreshedTokens.right()
coEvery { proofFactory.create(any(), any(), any()) } returns None
val retried = authenticator.authenticate(
route = null,
response = response(code = 401, sessionRefresh = true),
)
assertThat(retried).isNull()
}
private fun response(
code: Int,
dpop: Boolean = false,
sessionRefresh: Boolean = false,
sessionAuth: Boolean = false,
priorResponse: Response? = null,
): Response {
val builder = Request.Builder().url("https://example.com/api/v1/foo")
builder.tag(Invocation::class.java, invocationWith(dpop, sessionRefresh, sessionAuth))
val request = builder.build()
return Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(code)
.message("test")
.apply { if (priorResponse != null) priorResponse(priorResponse) }
.build()
}
private fun invocationWith(dpop: Boolean, sessionRefresh: Boolean, sessionAuth: Boolean): Invocation {
val method = mockk<Method>()
every { method.isAnnotationPresent(RequiresDpopProof::class.java) } returns dpop
every { method.isAnnotationPresent(RequiresSessionRefresh::class.java) } returns sessionRefresh
every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns sessionAuth
val invocation = mockk<Invocation>()
every { invocation.method() } returns method
return invocation
}
}

View file

@ -0,0 +1,101 @@
package com.tangem.lib.auth.nonce.internal
import com.google.common.truth.Truth.assertThat
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertThrows
import java.security.KeyPairGenerator
import java.security.spec.MGF1ParameterSpec
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultAuthNonceDecryptorTest {
private val dispatchers = TestingCoroutineDispatcherProvider()
private val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
private val privateKeyBase64: String =
Base64.getEncoder().encodeToString(keyPair.private.encoded)
private lateinit var decryptor: DefaultAuthNonceDecryptor
@BeforeEach
fun setup() {
mockkStatic(android.util.Base64::class)
every { android.util.Base64.decode(any<String>(), any()) } answers {
val input = firstArg<String>()
val flags = secondArg<Int>()
if (flags and android.util.Base64.URL_SAFE != 0) {
Base64.getUrlDecoder().decode(input)
} else {
Base64.getDecoder().decode(input)
}
}
decryptor = DefaultAuthNonceDecryptor(privateKeyBase64, dispatchers)
}
@AfterEach
fun teardown() {
unmockkAll()
}
@Test
fun `decryptNonce returns original nonce string`() = runTest {
val nonce = "dGVzdC1ub25jZS0xMjM0NQ"
val encrypted = encryptAndEncodeBase64Url(nonce)
val result = decryptor.decryptNonce(encrypted)
assertThat(result).isEqualTo(nonce)
}
@Test
fun `decryptNonce handles base64url nonce from backend`() = runTest {
val randomBytes = ByteArray(32) { it.toByte() }
val nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes)
val encrypted = encryptAndEncodeBase64Url(nonce)
val result = decryptor.decryptNonce(encrypted)
assertThat(result).isEqualTo(nonce)
}
@Test
fun `constructor throws on invalid key`() {
assertThrows<Exception> {
DefaultAuthNonceDecryptor("not-a-valid-base64-key!!", dispatchers)
}
}
@Test
fun `decryptNonce throws on corrupted ciphertext`() = runTest {
val corrupted = Base64.getUrlEncoder().withoutPadding().encodeToString(ByteArray(256) { 0x42 })
assertThrows<Exception> {
decryptor.decryptNonce(corrupted)
}
}
private fun encryptAndEncodeBase64Url(plainNonce: String): String {
val oaepSpec = OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT,
)
val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding")
cipher.init(Cipher.ENCRYPT_MODE, keyPair.public, oaepSpec)
val encrypted = cipher.doFinal(plainNonce.toByteArray(Charsets.UTF_8))
return Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted)
}
}

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,245 @@
package com.tangem.lib.auth.session.internal
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import arrow.core.None
import arrow.core.Some
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterApiRequest
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.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
import com.tangem.lib.auth.session.DeviceRegistrationError
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.flow.flowOf
import kotlinx.coroutines.test.runTest
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 DefaultDeviceRegistrarTest {
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 signedRequestPayload = SignedRequestPayload(appInfoProvider)
private val errorConverter = AuthErrorConverter()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val preferencesDataStore = InMemoryPreferencesDataStore()
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = dispatchers,
preferencesDataStore = preferencesDataStore,
)
private lateinit var registrar: DefaultDeviceRegistrar
@BeforeEach
fun setup() {
clearMocks(authApi, store, deviceKeyManager, nonceDecryptor)
preferencesDataStore.reset()
mockkStatic(android.util.Base64::class)
every { android.util.Base64.encodeToString(any(), any()) } answers {
java.util.Base64.getEncoder().encodeToString(firstArg())
}
registrar = DefaultDeviceRegistrar(
authApi = authApi,
store = store,
deviceKeyManager = deviceKeyManager,
nonceDecryptor = nonceDecryptor,
signedRequestPayload = signedRequestPayload,
errorConverter = errorConverter,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
}
@AfterEach
fun teardown() = unmockkAll()
@Test
fun `register posts nonce + register and persists tokens and flag on success`() = runTest {
stubHappyPath()
val result = registrar.register()
assertThat(result.isRight()).isTrue()
coVerify { authApi.requestDeviceNonce(any()) }
coVerify { authApi.register(any<RegisterApiRequest>()) }
coVerify { store.save(any()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isTrue()
}
@Test
fun `register short-circuits without network when the flag is already set`() = runTest {
preferencesDataStore.edit { it[PreferencesKeys.IS_DEVICE_REGISTERED_KEY] = true }
val result = registrar.register()
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) }
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
coVerify(exactly = 0) { store.save(any()) }
}
@Test
fun `register returns DeviceKeyUnavailable when keystore has no key`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns None
val result = registrar.register()
assertThat(result.leftOrNull()).isEqualTo(DeviceRegistrationError.DeviceKeyUnavailable)
coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) }
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
@Test
fun `register surfaces nonce-endpoint API error`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
@Suppress("UNCHECKED_CAST")
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.TOO_MANY_REQUESTS,
message = "rate-limited",
errorBody = null,
),
) as ApiResponse<NonceApiResponse>
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.Api::class.java)
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
@Test
fun `register returns NonceDecryptionFailed when decryptor throws`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
)
coEvery { nonceDecryptor.decryptNonce("abc") } throws IllegalStateException("OAEP failed")
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.NonceDecryptionFailed::class.java)
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
}
@Test
fun `register returns SigningFailed when signing throws`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
)
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } throws IllegalStateException("Keystore offline")
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.SigningFailed::class.java)
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
}
@Test
fun `register surfaces register-endpoint API error and does not touch tokens or flag`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
)
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
@Suppress("UNCHECKED_CAST")
coEvery { authApi.register(any<RegisterApiRequest>()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.FORBIDDEN,
message = "already registered",
errorBody = null,
),
) as ApiResponse<TokenApiResponse>
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.Api::class.java)
coVerify(exactly = 0) { store.save(any()) }
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
@Test
fun `register returns PersistenceFailed when SessionTokensStore_save throws`() = runTest {
stubHappyPath()
coEvery { store.save(any()) } throws IllegalStateException("DataStore I/O")
val result = registrar.register()
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.PersistenceFailed::class.java)
// Flag must stay unset so the next launch retries cleanly.
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
}
private fun stubHappyPath() {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
)
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
coEvery { authApi.register(any<RegisterApiRequest>()) } returns ApiResponse.Success(
data = TokenApiResponse(
accessToken = "fresh-access",
accessTokenExpiresAt = "2024-01-01T00:00:00Z",
refreshToken = "fresh-rt",
refreshTokenExpiresAt = "2024-02-01T00:00:00Z",
walletIds = listOf("w1"),
),
)
}
/** Minimal in-memory [DataStore] implementation — only the surface area used by tests. */
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private var preferences: MutablePreferences = mutablePreferencesOf()
override val data get() = flowOf(preferences)
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
preferences = transform(preferences).toMutablePreferences()
return preferences
}
fun edit(block: (MutablePreferences) -> Unit) {
preferences = preferences.toMutablePreferences().also(block)
}
fun current(): Preferences = preferences
fun reset() {
preferences = mutablePreferencesOf()
}
}
}

View file

@ -0,0 +1,325 @@
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 signedRequestPayload = SignedRequestPayload(appInfoProvider)
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,
signedRequestPayload = signedRequestPayload,
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)
}

View file

@ -0,0 +1,97 @@
package com.tangem.lib.auth.session.internal
import arrow.core.None
import arrow.core.Some
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.common.services.secure.SecureStorage
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.lib.auth.session.SessionTokens
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultSessionTokensStoreTest {
private val dispatchers = TestingCoroutineDispatcherProvider()
private val moshi = Moshi.Builder().build()
private val adapter = moshi.adapter(TokenApiResponse::class.java)
private val sampleDto = TokenApiResponse(
accessToken = "acc",
accessTokenExpiresAt = "2023-11-14T22:13:20Z",
refreshToken = "rt",
refreshTokenExpiresAt = "2023-11-14T23:13:20Z",
walletIds = listOf("w1", "w2"),
)
private val sampleDomain = SessionTokens(
accessToken = "acc",
accessTokenExpiresAt = Instant.parse("2023-11-14T22:13:20Z"),
refreshToken = "rt",
refreshTokenExpiresAt = Instant.parse("2023-11-14T23:13:20Z"),
walletIds = listOf("w1", "w2"),
)
@Test
fun `get returns None when nothing stored`() = runTest {
val storage = mockk<SecureStorage>(relaxed = true)
every { storage.getAsString("session_tokens") } returns null
val store = DefaultSessionTokensStore(storage, moshi, dispatchers)
assertThat(store.get()).isEqualTo(None)
}
@Test
fun `save round-trips through TokenApiResponse adapter`() = runTest {
val storage = mockk<SecureStorage>(relaxed = true)
val captured = slot<String>()
every { storage.store(eq("session_tokens"), capture(captured)) } returns Unit
val store = DefaultSessionTokensStore(storage, moshi, dispatchers)
store.save(sampleDomain)
verify { storage.store("session_tokens", any()) }
val decoded = adapter.fromJson(captured.captured)
assertThat(decoded).isEqualTo(sampleDto)
}
@Test
fun `get decodes TokenApiResponse and maps to domain`() = runTest {
val storage = mockk<SecureStorage>(relaxed = true)
every { storage.getAsString("session_tokens") } returns adapter.toJson(sampleDto)
val store = DefaultSessionTokensStore(storage, moshi, dispatchers)
assertThat(store.get()).isEqualTo(Some(sampleDomain))
}
@Test
fun `get returns None and clears corrupted entry`() = runTest {
val storage = mockk<SecureStorage>(relaxed = true)
every { storage.getAsString("session_tokens") } returns "{not json"
val store = DefaultSessionTokensStore(storage, moshi, dispatchers)
assertThat(store.get()).isEqualTo(None)
verify { storage.delete("session_tokens") }
}
@Test
fun `clear removes the entry`() = runTest {
val storage = mockk<SecureStorage>(relaxed = true)
val store = DefaultSessionTokensStore(storage, moshi, dispatchers)
store.clear()
verify { storage.delete("session_tokens") }
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.lib.auth.session.internal
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.lib.auth.session.SessionTokens
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SessionTokensConverterTest {
private val domain = SessionTokens(
accessToken = "acc",
accessTokenExpiresAt = Instant.parse("2023-11-14T22:13:20Z"),
refreshToken = "rt",
refreshTokenExpiresAt = Instant.parse("2023-11-14T23:13:20Z"),
walletIds = listOf("w1", "w2"),
)
private val dto = TokenApiResponse(
accessToken = "acc",
accessTokenExpiresAt = "2023-11-14T22:13:20Z",
refreshToken = "rt",
refreshTokenExpiresAt = "2023-11-14T23:13:20Z",
walletIds = listOf("w1", "w2"),
)
@Test
fun `convert maps domain to DTO with ISO-8601 timestamps`() {
assertThat(SessionTokensConverter.convert(domain)).isEqualTo(dto)
}
@Test
fun `convertBack maps DTO to domain with parsed Instant timestamps`() {
assertThat(SessionTokensConverter.convertBack(dto)).isEqualTo(domain)
}
@Test
fun `convert round-trip preserves domain value`() {
val roundTripped = SessionTokensConverter.convertBack(SessionTokensConverter.convert(domain))
assertThat(roundTripped).isEqualTo(domain)
}
@Test
fun `null refresh token survives both directions`() {
val orangeTier = domain.copy(refreshToken = null, refreshTokenExpiresAt = null)
val orangeDto = dto.copy(refreshToken = null, refreshTokenExpiresAt = null)
assertThat(SessionTokensConverter.convert(orangeTier)).isEqualTo(orangeDto)
assertThat(SessionTokensConverter.convertBack(orangeDto)).isEqualTo(orangeTier)
}
@Test
fun `empty walletIds list survives both directions`() {
val noWallets = domain.copy(walletIds = emptyList())
val noWalletsDto = dto.copy(walletIds = emptyList())
assertThat(SessionTokensConverter.convert(noWallets)).isEqualTo(noWalletsDto)
assertThat(SessionTokensConverter.convertBack(noWalletsDto)).isEqualTo(noWallets)
}
}

View file

@ -0,0 +1,135 @@
package com.tangem.lib.auth.session.internal
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload
import com.tangem.datasource.api.auth.models.request.DeviceMetadata
import com.tangem.datasource.api.auth.models.request.RegisterPayload
import com.tangem.utils.info.AppInfoProvider
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SignedRequestPayloadTest {
private val appInfoProvider: AppInfoProvider = mockk {
every { device } returns "Pixel 8"
every { platform } returns "Android"
every { osVersion } returns "14"
every { appVersion } returns "5.40.0"
every { language } returns "en-US"
every { timezone } returns "Europe/Moscow"
}
private val signedRequestPayload = SignedRequestPayload(appInfoProvider)
@Test
fun `deviceMetadata wires AppInfoProvider fields, forces userAgent to null, lowercases platform`() {
val metadata = signedRequestPayload.deviceMetadata
// Backend contract is lowercase `android`/`ios` — verify normalization at the source.
assertThat(metadata).isEqualTo(
DeviceMetadata(
deviceModel = "Pixel 8",
os = "android",
osVersion = "14",
appVersion = "5.40.0",
userAgent = null,
locale = "en-US",
timezone = "Europe/Moscow",
),
)
}
@Test
fun `canonicalize produces newline-separated representation in the documented field order`() {
val metadata = DeviceMetadata(
deviceModel = "Pixel 8",
os = "Android",
osVersion = "14",
appVersion = "5.40.0",
userAgent = null,
locale = "en-US",
timezone = "Europe/Moscow",
)
val payload = RegisterPayload(
devicePublicKey = "pub",
nonce = "nonce-1",
attestationToken = "attestation",
metadata = metadata,
)
val bytes = signedRequestPayload.canonicalize(payload)
assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo(
"""
pub
nonce-1
attestation
Pixel 8
Android
14
5.40.0
en-US
Europe/Moscow
""".trimIndent(),
)
}
@Test
fun `canonicalize replaces null fields with empty string`() {
val metadata = DeviceMetadata(
deviceModel = null,
os = "Android",
osVersion = null,
appVersion = null,
userAgent = null,
locale = null,
timezone = null,
)
val payload = RegisterPayload(
devicePublicKey = "pub",
nonce = "nonce-1",
attestationToken = null,
metadata = metadata,
)
val bytes = signedRequestPayload.canonicalize(payload)
// 8 newlines separate 9 logical slots; all but `devicePublicKey`, `nonce`, and `os` are empty.
assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo("pub\nnonce-1\n\n\nAndroid\n\n\n\n")
}
@Test
fun `canonicalize AuthenticationPayload and RegisterPayload with same fields produces same bytes`() {
// Identical canonicalisation across the two DTOs is the whole point of the shared helper —
// verify the overloads can't drift apart silently.
val metadata = DeviceMetadata(
deviceModel = "Pixel 8",
os = "Android",
osVersion = "14",
appVersion = "5.40.0",
userAgent = null,
locale = "en-US",
timezone = "Europe/Moscow",
)
val auth = AuthenticationPayload(
devicePublicKey = "pub",
nonce = "nonce-1",
attestationToken = "attestation",
metadata = metadata,
)
val register = RegisterPayload(
devicePublicKey = "pub",
nonce = "nonce-1",
attestationToken = "attestation",
metadata = metadata,
)
val authBytes = signedRequestPayload.canonicalize(auth)
val registerBytes = signedRequestPayload.canonicalize(register)
assertThat(authBytes).isEqualTo(registerBytes)
}
}

View file

@ -53,7 +53,7 @@ dependencies {
// endregion
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -16,16 +16,16 @@ import javax.inject.Inject
* @property accountCreator account creator
* @property blockchainDataStorage blockchain data storage
* @property blockchainSDKLogger blockchain SDK logger
* @property featureToggleValues blockchain feature toggle values
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class WalletManagerFactoryCreator @Inject constructor(
private val accountCreator: AccountCreator,
private val blockchainDataStorage: BlockchainDataStorage,
private val blockchainSDKLogger: BlockchainSDKLogger,
private val isSolanaTxHistoryEnabled: Boolean,
private val isSolanaScaledUiAmountEnabled: Boolean,
private val isHederaErc20Enabled: Boolean,
private val featureToggleValues: FeatureToggleValues,
) {
fun create(config: BlockchainSdkConfig, blockchainProviderTypes: BlockchainProviderTypes): WalletManagerFactory {
@ -37,13 +37,23 @@ internal class WalletManagerFactoryCreator @Inject constructor(
accountCreator = accountCreator,
featureToggles = BlockchainFeatureToggles(
isYieldSupplyEnabled = true,
isYieldModeSwapEnabled = featureToggleValues.isYieldModeSwapEnabled,
isPendingTransactionsEnabled = true,
isSolanaTxHistoryEnabled = isSolanaTxHistoryEnabled,
isSolanaScaledUiAmountEnabled = isSolanaScaledUiAmountEnabled,
isHederaErc20Enabled = isHederaErc20Enabled,
isSolanaTxHistoryEnabled = featureToggleValues.isSolanaTxHistoryEnabled,
isSolanaScaledUiAmountEnabled = featureToggleValues.isSolanaScaledUiAmountEnabled,
isHederaErc20Enabled = featureToggleValues.isHederaErc20Enabled,
isStateOverrideGasEstimateEnabled = featureToggleValues.isStateOverrideGasEstimateEnabled,
),
blockchainDataStorage = blockchainDataStorage,
loggers = listOf(blockchainSDKLogger),
)
}
data class FeatureToggleValues(
val isSolanaTxHistoryEnabled: Boolean,
val isSolanaScaledUiAmountEnabled: Boolean,
val isYieldModeSwapEnabled: Boolean,
val isHederaErc20Enabled: Boolean,
val isStateOverrideGasEstimateEnabled: Boolean,
)
}

View file

@ -23,8 +23,8 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.libs.blockchain_sdk.BuildConfig
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -97,14 +97,22 @@ internal object BlockchainSDKFactoryModule {
accountCreator = DefaultAccountCreator(tangemTechApi),
blockchainDataStorage = DefaultBlockchainDataStorage(appPreferencesStore),
blockchainSDKLogger = blockchainSDKLogger,
isSolanaTxHistoryEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.SOLANA_TX_HISTORY_ENABLED,
),
isSolanaScaledUiAmountEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.SOLANA_SCALED_UI_AMOUNT_ENABLED,
),
isHederaErc20Enabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.HEDERA_ERC20_ENABLED,
featureToggleValues = WalletManagerFactoryCreator.FeatureToggleValues(
isSolanaTxHistoryEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.SOLANA_TX_HISTORY_ENABLED,
),
isSolanaScaledUiAmountEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.SOLANA_SCALED_UI_AMOUNT_ENABLED,
),
isYieldModeSwapEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED,
),
isHederaErc20Enabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.HEDERA_ERC20_ENABLED,
),
isStateOverrideGasEstimateEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE,
),
),
)
}

View file

@ -12,8 +12,8 @@ import com.tangem.datasource.local.config.providers.models.ProviderModel
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]
@ -35,7 +35,7 @@ internal class BlockchainProvidersResponseLoaderTest {
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Before
@BeforeEach
fun setup() {
mockkStatic(FirebaseCrashlytics::class)
val firebaseCrashlytics = mockk<FirebaseCrashlytics>()

View file

@ -8,8 +8,8 @@ import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.datasource.local.config.providers.models.ProviderModel
import io.mockk.*
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]
@ -24,7 +24,7 @@ internal class BlockchainProvidersResponseMergerTest {
},
)
@Before
@BeforeEach
fun setup() {
mockkStatic(FirebaseCrashlytics::class)
val firebaseCrashlytics = mockk<FirebaseCrashlytics>()

View file

@ -9,11 +9,6 @@ plugins {
android {
namespace = "com.tangem.lib.crypto"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project
@ -32,6 +27,5 @@ dependencies {
// region Test libraries
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
// endregion
}