Updated on 2026-08-14
This commit is contained in:
commit
d6f9f59866
1729 changed files with 67614 additions and 9361 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.lib.auth
|
||||
|
||||
interface AuthFeatureToggles {
|
||||
val isBackendAuthenticationEnabled: Boolean
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.tangem.lib.auth.devicekey
|
||||
|
||||
class DeviceKeySigningException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
211
libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt
Normal file
211
libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
/**
|
||||
* Typed Tangem Auth Service failure produced by `AuthErrorConverter` out of the raw
|
||||
* `ApiResponseError`. Carries the parsed [AuthErrorResponse] when the server returned
|
||||
* an `application/problem+json` body (RFC 9457).
|
||||
*/
|
||||
sealed class AuthError(open val problem: AuthErrorResponse?) {
|
||||
|
||||
/** `400` — invalid nonce / signature / wallet already registered. */
|
||||
data class BadRequest(override val problem: AuthErrorResponse?) : AuthError(problem)
|
||||
|
||||
/** `401` — invalid / expired / revoked / replayed access or refresh token. */
|
||||
data class Unauthorized(override val problem: AuthErrorResponse?) : AuthError(problem)
|
||||
|
||||
/** `403` — device blocked (RED tier), max wallets exceeded, or token-device mismatch. */
|
||||
data class Forbidden(override val problem: AuthErrorResponse?) : AuthError(problem)
|
||||
|
||||
/** `404` — token / resource not found. */
|
||||
data class NotFound(override val problem: AuthErrorResponse?) : AuthError(problem)
|
||||
|
||||
/** `429` — server-side rate limit; honour [retryAfterSeconds] before retrying. */
|
||||
data class RateLimited(
|
||||
val retryAfterSeconds: Int?,
|
||||
override val problem: AuthErrorResponse?,
|
||||
) : AuthError(problem)
|
||||
|
||||
/** `5xx` — server-side outage. */
|
||||
data class ServerUnavailable(override val problem: AuthErrorResponse?) : AuthError(problem)
|
||||
|
||||
/** Connectivity issue (DNS, timeout, offline). */
|
||||
data object NetworkError : AuthError(problem = null)
|
||||
|
||||
/** Anything not covered above (parsing failure, unexpected exception). */
|
||||
data class Unknown(val cause: Throwable) : AuthError(problem = null)
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* RFC 9457 / RFC 7807 Problem Details response. Returned by Tangem Auth Service with
|
||||
* `Content-Type: application/problem+json` on every 4xx / 5xx response.
|
||||
*/
|
||||
@Serializable
|
||||
data class AuthErrorResponse(
|
||||
/** URI identifying the problem type. */
|
||||
@SerialName("type") val type: String,
|
||||
/** Short human-readable summary (e.g. `"Too Many Requests"`). */
|
||||
@SerialName("title") val title: String,
|
||||
/** HTTP status code. */
|
||||
@SerialName("status") val status: Int,
|
||||
/** Human-readable explanation. */
|
||||
@SerialName("detail") val detail: String? = null,
|
||||
/** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */
|
||||
@SerialName("instance") val instance: String? = null,
|
||||
/** Application-specific error code. */
|
||||
@SerialName("code") val code: String? = null,
|
||||
/** Retry delay for rate limiting (`429`). */
|
||||
@SerialName("retryAfterSeconds") val retryAfterSeconds: Int? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,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>
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
/**
|
||||
* Typed failure mode of `SessionTokenRefresher.refresh()`. Distinguishes terminal failures
|
||||
* (re-registration required) from transient ones (network / server) so callers can decide
|
||||
* whether to retry, surface UI, or trigger deferred-registration flow.
|
||||
*/
|
||||
sealed class SessionRefreshError {
|
||||
|
||||
/** API-level error from `/refresh`, `/authenticate` or `/nonce/auth`. Transient unless [cause] says otherwise. */
|
||||
data class Api(val cause: AuthError) : SessionRefreshError()
|
||||
|
||||
/**
|
||||
* Terminal — `/authenticate` returned 401/403. Session store was cleared; the device must
|
||||
* re-register ([REDACTED_TASK_KEY] / deferred-registration flow).
|
||||
*/
|
||||
data object SessionRevoked : SessionRefreshError()
|
||||
|
||||
/** Device key is not provisioned in Keystore (registration not yet run, or Keystore unavailable). */
|
||||
data object DeviceKeyUnavailable : SessionRefreshError()
|
||||
|
||||
/** RSA/OAEP decryption of the server-issued auth nonce failed. */
|
||||
data class NonceDecryptionFailed(val cause: Throwable) : SessionRefreshError()
|
||||
|
||||
/** Device-key signing of the authentication payload failed (Keystore I/O or ECDSA failure). */
|
||||
data class SigningFailed(val cause: Throwable) : SessionRefreshError()
|
||||
|
||||
/** Refresher is disabled via `AND_15438_BACKEND_AUTHENTICATION_ENABLED` feature toggle. */
|
||||
data object Disabled : SessionRefreshError()
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
import arrow.core.Either
|
||||
|
||||
/**
|
||||
* Refreshes (rotates) session tokens.
|
||||
*
|
||||
* Implementations must serialise refresh attempts so concurrent callers share a single
|
||||
* network round-trip — replaying a consumed refresh token causes the backend to revoke
|
||||
* the entire session chain (SR-8 / RFC 9449 §5).
|
||||
*
|
||||
* Refresh strategy:
|
||||
* 1. Call `/api/v1/auth/refresh` with the stored refresh token when it is present and unexpired.
|
||||
* 2. On 401/403 from `/refresh` (revoked / replayed / RED-tier downgrade), fall back to
|
||||
* full re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate`
|
||||
* signed by the device key.
|
||||
* 3. On 401/403 from `/authenticate`, clear the session store and return
|
||||
* [SessionRefreshError.SessionRevoked] — the device must be re-registered (see [REDACTED_TASK_KEY]
|
||||
* for the deferred-registration flag).
|
||||
*/
|
||||
interface SessionTokenRefresher {
|
||||
|
||||
suspend fun refresh(): Either<SessionRefreshError, SessionTokens>
|
||||
}
|
||||
|
|
@ -0,0 +1,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>,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
|
||||
import com.tangem.lib.auth.session.AuthError
|
||||
import com.tangem.lib.auth.session.AuthErrorResponse
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Converts the raw `ApiResponseError` produced by Retrofit into a typed [AuthError], parsing
|
||||
* `application/problem+json` payloads into [AuthErrorResponse] when present. Follows the
|
||||
* project pattern set by `TangemPayErrorConverter`, `ExpressErrorConverter`, etc.
|
||||
*/
|
||||
internal class AuthErrorConverter @Inject constructor() : Converter<Throwable, AuthError> {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override fun convert(error: Throwable): AuthError = when (error) {
|
||||
is ApiResponseError.HttpException -> convertHttp(error)
|
||||
is ApiResponseError.NetworkException,
|
||||
is ApiResponseError.TimeoutException,
|
||||
-> AuthError.NetworkError
|
||||
// Unwrap the wrapper so consumers inspect the original failure instead of
|
||||
// `ApiResponseError.UnknownException` itself.
|
||||
is ApiResponseError.UnknownException -> AuthError.Unknown(error.cause)
|
||||
else -> AuthError.Unknown(error)
|
||||
}
|
||||
|
||||
private fun convertHttp(error: ApiResponseError.HttpException): AuthError {
|
||||
val problem = error.errorBody?.let(::parseErrorResponse)
|
||||
return when (error.code) {
|
||||
Code.BAD_REQUEST -> AuthError.BadRequest(problem)
|
||||
Code.UNAUTHORIZED -> AuthError.Unauthorized(problem)
|
||||
Code.FORBIDDEN -> AuthError.Forbidden(problem)
|
||||
Code.NOT_FOUND -> AuthError.NotFound(problem)
|
||||
Code.TOO_MANY_REQUESTS -> AuthError.RateLimited(problem?.retryAfterSeconds, problem)
|
||||
else -> if (error.isServerError()) AuthError.ServerUnavailable(problem) else AuthError.Unknown(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseErrorResponse(response: String): AuthErrorResponse? {
|
||||
return runCatching { json.decodeFromString<AuthErrorResponse>(response) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import com.tangem.lib.auth.session.SessionRefreshError
|
||||
import com.tangem.lib.auth.session.SessionTokenRefresher
|
||||
import com.tangem.lib.auth.session.SessionTokens
|
||||
import com.tangem.utils.annotations.RemoveWithToggle
|
||||
|
||||
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
|
||||
internal object DisabledSessionTokenRefresher : SessionTokenRefresher {
|
||||
|
||||
override suspend fun refresh(): Either<SessionRefreshError, SessionTokens> {
|
||||
return SessionRefreshError.Disabled.left()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
|
||||
import com.tangem.lib.auth.session.AuthError
|
||||
import com.tangem.lib.auth.session.AuthErrorResponse
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class AuthErrorConverterTest {
|
||||
|
||||
private val converter = AuthErrorConverter()
|
||||
|
||||
private val sampleBody = """
|
||||
{
|
||||
"type": "https://problems.tangem.com/auth/invalid-signature",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": "Nonce or signature validation failed.",
|
||||
"instance": "/api/v1/auth/refresh",
|
||||
"code": "invalid_signature",
|
||||
"retryAfterSeconds": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private val sampleProblem = AuthErrorResponse(
|
||||
type = "https://problems.tangem.com/auth/invalid-signature",
|
||||
title = "Bad Request",
|
||||
status = 400,
|
||||
detail = "Nonce or signature validation failed.",
|
||||
instance = "/api/v1/auth/refresh",
|
||||
code = "invalid_signature",
|
||||
retryAfterSeconds = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `400 with problem body is converted to BadRequest with parsed problem`() {
|
||||
val result = converter.convert(httpError(Code.BAD_REQUEST, sampleBody))
|
||||
|
||||
assertThat(result).isEqualTo(AuthError.BadRequest(sampleProblem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `400 without body is converted to BadRequest with null problem`() {
|
||||
val result = converter.convert(httpError(Code.BAD_REQUEST, errorBody = null))
|
||||
|
||||
assertThat(result).isEqualTo(AuthError.BadRequest(problem = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `401 is converted to Unauthorized`() {
|
||||
val result = converter.convert(httpError(Code.UNAUTHORIZED, sampleBody))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.Unauthorized::class.java)
|
||||
assertThat((result as AuthError.Unauthorized).problem).isEqualTo(sampleProblem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `403 is converted to Forbidden`() {
|
||||
val result = converter.convert(httpError(Code.FORBIDDEN, sampleBody))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.Forbidden::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `404 is converted to NotFound`() {
|
||||
val result = converter.convert(httpError(Code.NOT_FOUND, sampleBody))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.NotFound::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 surfaces retryAfterSeconds from problem`() {
|
||||
val rateLimitBody = """
|
||||
{
|
||||
"type": "https://problems.tangem.com/auth/rate-limit",
|
||||
"title": "Too Many Requests",
|
||||
"status": 429,
|
||||
"detail": "Try again later.",
|
||||
"instance": "/api/v1/auth/refresh",
|
||||
"code": "rate_limited",
|
||||
"retryAfterSeconds": 45
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = converter.convert(httpError(Code.TOO_MANY_REQUESTS, rateLimitBody))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.RateLimited::class.java)
|
||||
val rateLimited = result as AuthError.RateLimited
|
||||
assertThat(rateLimited.retryAfterSeconds).isEqualTo(45)
|
||||
assertThat(rateLimited.problem?.code).isEqualTo("rate_limited")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 without body has null retryAfterSeconds`() {
|
||||
val result = converter.convert(httpError(Code.TOO_MANY_REQUESTS, errorBody = null))
|
||||
|
||||
assertThat(result).isEqualTo(AuthError.RateLimited(retryAfterSeconds = null, problem = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `500 is converted to ServerUnavailable`() {
|
||||
val result = converter.convert(httpError(Code.INTERNAL_SERVER_ERROR, errorBody = null))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.ServerUnavailable::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `502 is converted to ServerUnavailable`() {
|
||||
val result = converter.convert(httpError(Code.BAD_GATEWAY, errorBody = null))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.ServerUnavailable::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `503 is converted to ServerUnavailable`() {
|
||||
val result = converter.convert(httpError(Code.SERVICE_UNAVAILABLE, errorBody = null))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.ServerUnavailable::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-server 4xx not explicitly mapped becomes Unknown`() {
|
||||
// 418 I_M_A_TEAPOT is a 4xx that isServerError() reports as non-server.
|
||||
val httpException = httpError(Code.IM_A_TEAPOT, errorBody = null)
|
||||
|
||||
val result = converter.convert(httpException)
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.Unknown::class.java)
|
||||
assertThat((result as AuthError.Unknown).cause).isSameInstanceAs(httpException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NetworkException becomes NetworkError`() {
|
||||
val result = converter.convert(ApiResponseError.NetworkException())
|
||||
|
||||
assertThat(result).isSameInstanceAs(AuthError.NetworkError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TimeoutException becomes NetworkError`() {
|
||||
val result = converter.convert(ApiResponseError.TimeoutException())
|
||||
|
||||
assertThat(result).isSameInstanceAs(AuthError.NetworkError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UnknownException is unwrapped to its underlying cause`() {
|
||||
val cause = IllegalStateException("boom")
|
||||
val result = converter.convert(ApiResponseError.UnknownException(cause))
|
||||
|
||||
// Consumers reading AuthError.Unknown.cause should see the original failure, not the wrapper.
|
||||
assertThat(result).isEqualTo(AuthError.Unknown(cause))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-ApiResponseError Throwable is wrapped as Unknown`() {
|
||||
val cause = RuntimeException("misc")
|
||||
val result = converter.convert(cause)
|
||||
|
||||
assertThat(result).isEqualTo(AuthError.Unknown(cause))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed JSON in errorBody yields null problem but preserves AuthError shape`() {
|
||||
val result = converter.convert(httpError(Code.BAD_REQUEST, errorBody = "{not json"))
|
||||
|
||||
assertThat(result).isEqualTo(AuthError.BadRequest(problem = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown JSON fields in errorBody are ignored (ignoreUnknownKeys)`() {
|
||||
val bodyWithExtra = """
|
||||
{
|
||||
"type": "https://problems.tangem.com/auth/x",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": null,
|
||||
"instance": null,
|
||||
"code": null,
|
||||
"retryAfterSeconds": null,
|
||||
"futureField": "ignored"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = converter.convert(httpError(Code.BAD_REQUEST, bodyWithExtra))
|
||||
|
||||
assertThat(result).isInstanceOf(AuthError.BadRequest::class.java)
|
||||
assertThat((result as AuthError.BadRequest).problem?.title).isEqualTo("Bad Request")
|
||||
}
|
||||
|
||||
private fun httpError(code: Code, errorBody: String?): ApiResponseError.HttpException =
|
||||
ApiResponseError.HttpException(code = code, message = null, errorBody = errorBody)
|
||||
}
|
||||
|
|
@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue