Updated on 2026-08-14
This commit is contained in:
parent
0b3914cf6a
commit
7581e22ee7
19 changed files with 784 additions and 74 deletions
|
|
@ -9,6 +9,8 @@ import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
|||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
|
||||
import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
||||
import com.tangem.lib.auth.AuthFeatureToggles
|
||||
import com.tangem.lib.auth.session.DeviceRegistrar
|
||||
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
|
||||
import com.tangem.domain.wallets.repository.WalletsRepository
|
||||
|
|
@ -52,4 +54,8 @@ interface ApplicationEntryPoint {
|
|||
fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor
|
||||
|
||||
fun getDeviceKeyManager(): DeviceKeyManager
|
||||
|
||||
fun getDeviceRegistrar(): DeviceRegistrar
|
||||
|
||||
fun getAuthFeatureToggles(): AuthFeatureToggles
|
||||
}
|
||||
|
|
@ -21,7 +21,9 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
|||
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
|
||||
import com.tangem.domain.common.LogConfig
|
||||
import com.tangem.domain.wallets.repository.WalletsRepository
|
||||
import com.tangem.lib.auth.AuthFeatureToggles
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||
import com.tangem.lib.auth.session.DeviceRegistrar
|
||||
import com.tangem.tap.common.analytics.AnalyticsFactory
|
||||
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
|
||||
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
|
||||
|
|
@ -96,6 +98,12 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
|
|||
private val deviceKeyManager: DeviceKeyManager
|
||||
get() = entryPoint.getDeviceKeyManager()
|
||||
|
||||
private val deviceRegistrar: DeviceRegistrar
|
||||
get() = entryPoint.getDeviceRegistrar()
|
||||
|
||||
private val authFeatureToggles: AuthFeatureToggles
|
||||
get() = entryPoint.getAuthFeatureToggles()
|
||||
|
||||
// endregion
|
||||
|
||||
private val appScope = MainScope()
|
||||
|
|
@ -136,8 +144,15 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
|
|||
}
|
||||
|
||||
fun init() {
|
||||
appScope.launch {
|
||||
deviceKeyManager.generateIfMissing()
|
||||
if (authFeatureToggles.isBackendAuthenticationEnabled) {
|
||||
appScope.launch {
|
||||
// Order matters: registration reads the device public key, so it must wait for
|
||||
// generation to complete. Running them concurrently on first launch would race —
|
||||
// register() would see `DeviceKeyUnavailable` and defer to the next app launch.
|
||||
deviceKeyManager.generateIfMissing()
|
||||
deviceRegistrar.register()
|
||||
.onLeft { error -> TangemLogger.w("Device registration deferred: $error") }
|
||||
}
|
||||
}
|
||||
walletsRepository = entryPoint.getWalletsRepository()
|
||||
|
||||
|
|
|
|||
|
|
@ -89,19 +89,19 @@ dependencies {
|
|||
/** Coroutines */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.coroutines.rx2)
|
||||
implementation(deps.kotlin.datetime)
|
||||
implementation(deps.kotlin.serialization)
|
||||
api(deps.kotlin.datetime)
|
||||
api(deps.kotlin.serialization)
|
||||
|
||||
/** Logging */
|
||||
|
||||
/** Network */
|
||||
implementation(deps.moshi)
|
||||
api(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(deps.moshi.adapters)
|
||||
implementation(deps.moshi.adapters.ext)
|
||||
implementation(deps.okHttp)
|
||||
api(deps.okHttp)
|
||||
implementation(deps.okHttp.prettyLogging)
|
||||
implementation(deps.retrofit)
|
||||
api(deps.retrofit)
|
||||
implementation(deps.retrofit.moshi)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
|
|
@ -120,7 +120,7 @@ dependencies {
|
|||
releaseImplementation(deps.chuckerStub)
|
||||
|
||||
/** Local storages */
|
||||
implementation(deps.androidx.datastore)
|
||||
api(deps.androidx.datastore)
|
||||
implementation(deps.room.runtime)
|
||||
implementation(deps.room.ktx)
|
||||
ksp(deps.room.compiler)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.tangem.datasource.api.auth
|
|||
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
|
||||
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.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
|
||||
|
|
@ -14,6 +15,24 @@ import retrofit2.http.POST
|
|||
*/
|
||||
interface AuthApi {
|
||||
|
||||
/**
|
||||
* Request device registration nonce.
|
||||
*
|
||||
* Generates a nonce bound to the device public key for the device registration flow.
|
||||
*/
|
||||
@POST("api/v1/auth/nonce/device")
|
||||
suspend fun requestDeviceNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
|
||||
|
||||
/**
|
||||
* Register device.
|
||||
*
|
||||
* Registers a new device using its hardware-backed public key and issues the initial
|
||||
* session token pair. Called once per app install.
|
||||
*/
|
||||
@POST("api/v1/auth/register")
|
||||
@RequiresDpopProof
|
||||
suspend fun register(@Body request: RegisterApiRequest): ApiResponse<TokenApiResponse>
|
||||
|
||||
/**
|
||||
* Request authentication nonce.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -23,24 +23,4 @@ data class AuthenticationPayload(
|
|||
@Json(name = "attestationToken") val attestationToken: String?,
|
||||
/** Client-reported device metadata. */
|
||||
@Json(name = "metadata") val metadata: DeviceMetadata,
|
||||
) {
|
||||
|
||||
/** Device metadata collection. */
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class DeviceMetadata(
|
||||
/** Device hardware model (e.g. `iPhone 15 Pro`). */
|
||||
@Json(name = "deviceModel") val deviceModel: String?,
|
||||
/** Operating system (`android` / `ios`). */
|
||||
@Json(name = "os") val os: String,
|
||||
/** OS version string (e.g. `17.4.1`). */
|
||||
@Json(name = "osVersion") val osVersion: String?,
|
||||
/** Application version (e.g. `5.8.0`). */
|
||||
@Json(name = "appVersion") val appVersion: String?,
|
||||
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
|
||||
@Json(name = "userAgent") val userAgent: String?,
|
||||
/** Client locale (e.g. `en-US`). */
|
||||
@Json(name = "locale") val locale: String?,
|
||||
/** Client timezone (e.g. `Europe/Moscow`). */
|
||||
@Json(name = "timezone") val timezone: String?,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.datasource.api.auth.models.request
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/**
|
||||
* Client-reported device metadata, included in both [AuthenticationPayload] and [RegisterPayload].
|
||||
* Mirrors the `DeviceMetadata` schema in the backend OpenAPI contract.
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class DeviceMetadata(
|
||||
/** Device hardware model (e.g. `iPhone 15 Pro`). */
|
||||
@Json(name = "deviceModel") val deviceModel: String?,
|
||||
/** Operating system (`android` / `ios`). */
|
||||
@Json(name = "os") val os: String,
|
||||
/** OS version string (e.g. `17.4.1`). */
|
||||
@Json(name = "osVersion") val osVersion: String?,
|
||||
/** Application version (e.g. `5.8.0`). */
|
||||
@Json(name = "appVersion") val appVersion: String?,
|
||||
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
|
||||
@Json(name = "userAgent") val userAgent: String?,
|
||||
/** Client locale (e.g. `en-US`). */
|
||||
@Json(name = "locale") val locale: String?,
|
||||
/** Client timezone (e.g. `Europe/Moscow`). */
|
||||
@Json(name = "timezone") val timezone: String?,
|
||||
)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.tangem.datasource.api.auth.models.request
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/**
|
||||
* Registration request — registers a new device and establishes initial trust.
|
||||
*
|
||||
* Posted to `POST /api/v1/auth/register`; on success the server returns
|
||||
* [com.tangem.datasource.api.auth.models.response.TokenApiResponse] (the initial session token pair).
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RegisterApiRequest(
|
||||
/** Signed registration payload. */
|
||||
@Json(name = "payload") val payload: RegisterPayload,
|
||||
/** EC signature over the registration payload, signed by the device private key (Base64). */
|
||||
@Json(name = "signature") val signature: String,
|
||||
)
|
||||
|
||||
/** Signed registration payload — the data that is signed by the device private key. */
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RegisterPayload(
|
||||
/** Base64-encoded EC public key of the device. */
|
||||
@Json(name = "devicePublicKey") val devicePublicKey: String,
|
||||
/** Deciphered nonce value from the `/api/v1/auth/nonce/device` endpoint. */
|
||||
@Json(name = "nonce") val nonce: String,
|
||||
/** Platform attestation token (Play Integrity / App Attest). Optional; backend accepts `null`. */
|
||||
@Json(name = "attestationToken") val attestationToken: String?,
|
||||
/** Client-reported device metadata. */
|
||||
@Json(name = "metadata") val metadata: DeviceMetadata,
|
||||
)
|
||||
|
|
@ -103,6 +103,8 @@ object PreferencesKeys {
|
|||
|
||||
val IS_GOOGLE_PAY_AVAILABLE_KEY by lazy { booleanPreferencesKey(name = "isGooglePayAvailable") }
|
||||
|
||||
val IS_DEVICE_REGISTERED_KEY by lazy { booleanPreferencesKey(name = "isDeviceRegistered") }
|
||||
|
||||
val WAS_LOG_FILE_CLEARED by lazy { booleanPreferencesKey(name = "wasLogFileCleared") }
|
||||
|
||||
val SEED_FIRST_NOTIFICATION_SHOW_TIME by lazy { longPreferencesKey("seedFirstNotificationTime") }
|
||||
|
|
|
|||
|
|
@ -26,11 +26,6 @@ dependencies {
|
|||
|
||||
/** Other */
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.datetime)
|
||||
implementation(deps.kotlin.serialization)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.okHttp)
|
||||
implementation(deps.retrofit)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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
|
||||
|
|
@ -20,16 +21,19 @@ 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.info.AppInfoProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -145,7 +149,7 @@ internal object AuthModule {
|
|||
store: SessionTokensStore,
|
||||
deviceKeyManager: DeviceKeyManager,
|
||||
nonceDecryptor: AuthNonceDecryptor,
|
||||
appInfoProvider: AppInfoProvider,
|
||||
signedRequestPayload: SignedRequestPayload,
|
||||
errorConverter: AuthErrorConverter,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): SessionTokenRefresher {
|
||||
|
|
@ -156,13 +160,41 @@ internal object AuthModule {
|
|||
store = store,
|
||||
deviceKeyManager = deviceKeyManager,
|
||||
nonceDecryptor = nonceDecryptor,
|
||||
appInfoProvider = appInfoProvider,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import android.util.Base64
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.raise.either
|
||||
|
|
@ -8,7 +7,6 @@ import arrow.core.right
|
|||
import com.tangem.datasource.api.auth.AuthApi
|
||||
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
|
||||
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload
|
||||
import com.tangem.datasource.api.auth.models.request.AuthenticationPayload.DeviceMetadata
|
||||
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
|
||||
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
|
||||
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||
|
|
@ -21,7 +19,6 @@ import com.tangem.lib.auth.session.SessionTokenRefresher
|
|||
import com.tangem.lib.auth.session.SessionTokens
|
||||
import com.tangem.lib.auth.session.SessionTokensStore
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.info.AppInfoProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
|
|
@ -36,7 +33,7 @@ internal class DefaultSessionTokenRefresher(
|
|||
private val store: SessionTokensStore,
|
||||
private val deviceKeyManager: DeviceKeyManager,
|
||||
private val nonceDecryptor: AuthNonceDecryptor,
|
||||
private val appInfoProvider: AppInfoProvider,
|
||||
private val signedRequestPayload: SignedRequestPayload,
|
||||
private val errorConverter: AuthErrorConverter,
|
||||
private val clock: Clock,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
|
|
@ -122,11 +119,10 @@ internal class DefaultSessionTokenRefresher(
|
|||
devicePublicKey = devicePublicKeyBase64,
|
||||
nonce = nonce,
|
||||
attestationToken = null,
|
||||
metadata = buildDeviceMetadata(),
|
||||
metadata = signedRequestPayload.deviceMetadata,
|
||||
)
|
||||
val signaturePayload = canonicalize(payload)
|
||||
val signature = try {
|
||||
deviceKeyManager.sign(signaturePayload).toBase64NoWrap()
|
||||
deviceKeyManager.sign(signedRequestPayload.canonicalize(payload)).toBase64NoWrap()
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to sign authentication payload", e)
|
||||
raise(SessionRefreshError.SigningFailed(e))
|
||||
|
|
@ -165,35 +161,6 @@ internal class DefaultSessionTokenRefresher(
|
|||
}
|
||||
}
|
||||
|
||||
private fun buildDeviceMetadata(): DeviceMetadata = DeviceMetadata(
|
||||
deviceModel = appInfoProvider.device,
|
||||
os = appInfoProvider.platform,
|
||||
osVersion = appInfoProvider.osVersion,
|
||||
appVersion = appInfoProvider.appVersion,
|
||||
userAgent = null,
|
||||
locale = appInfoProvider.language,
|
||||
timezone = appInfoProvider.timezone,
|
||||
)
|
||||
|
||||
private fun canonicalize(payload: AuthenticationPayload): ByteArray {
|
||||
// Stable, line-separated representation; backend treats the signed bytes opaquely. If the
|
||||
// server pins to a specific canonicalisation (e.g. CBOR / sorted JSON), update both sides
|
||||
// together.
|
||||
return buildString {
|
||||
append(payload.devicePublicKey).append('\n')
|
||||
append(payload.nonce).append('\n')
|
||||
append(payload.attestationToken.orEmpty()).append('\n')
|
||||
append(payload.metadata.deviceModel.orEmpty()).append('\n')
|
||||
append(payload.metadata.os).append('\n')
|
||||
append(payload.metadata.osVersion.orEmpty()).append('\n')
|
||||
append(payload.metadata.appVersion.orEmpty()).append('\n')
|
||||
append(payload.metadata.locale.orEmpty()).append('\n')
|
||||
append(payload.metadata.timezone.orEmpty())
|
||||
}.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun ByteArray.toBase64NoWrap(): String = Base64.encodeToString(this, Base64.NO_WRAP)
|
||||
|
||||
private sealed interface RefreshOutcome {
|
||||
data class Success(val tokens: SessionTokens) : RefreshOutcome
|
||||
data object Unauthenticated : RefreshOutcome
|
||||
|
|
|
|||
|
|
@ -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,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,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ class DefaultSessionTokenRefresherTest {
|
|||
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 {
|
||||
|
|
@ -63,7 +64,7 @@ class DefaultSessionTokenRefresherTest {
|
|||
store = store,
|
||||
deviceKeyManager = deviceKeyManager,
|
||||
nonceDecryptor = nonceDecryptor,
|
||||
appInfoProvider = appInfoProvider,
|
||||
signedRequestPayload = signedRequestPayload,
|
||||
errorConverter = errorConverter,
|
||||
clock = fixedClock,
|
||||
dispatchers = dispatchers,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue