diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt index 1161483d0c..68aec6cd5a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt @@ -4,6 +4,7 @@ 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.request.WalletRegistrationRequest 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 @@ -30,8 +31,7 @@ interface AuthApi { * session token pair. Called once per app install. */ @POST("api/v1/auth/register") - @RequiresDpopProof - suspend fun register(@Body request: RegisterApiRequest): ApiResponse + suspend fun registerDevice(@Body request: RegisterApiRequest): ApiResponse /** * Request authentication nonce. @@ -61,4 +61,23 @@ interface AuthApi { @POST("api/v1/auth/refresh") @RequiresDpopProof suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse + + /** + * Request wallet registration nonce. + * + * Generates a nonce bound to the device public key for the wallet registration flow. + */ + @POST("api/v1/auth/nonce/wallet") + suspend fun requestWalletNonce(@Body request: NonceApiRequest): ApiResponse + + /** + * Register a wallet. + * + * Binds a new wallet to an already-registered device. When a card signature is provided the + * wallet is bound as COLD (card-backed); otherwise it is registered as a MOBILE (hot) wallet. + * Returns refreshed session tokens reflecting the updated wallet list. + */ + @POST("api/v1/auth/wallet") + @RequiresDpopProof + suspend fun registerWallet(@Body request: WalletRegistrationRequest): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt index 960957a6a4..b381ec0359 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt @@ -10,17 +10,17 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class DeviceMetadata( /** Device hardware model (e.g. `iPhone 15 Pro`). */ - @Json(name = "deviceModel") val deviceModel: String?, + @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?, + @Json(name = "osVersion") val osVersion: String, /** Application version (e.g. `5.8.0`). */ - @Json(name = "appVersion") val appVersion: String?, + @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?, + @Json(name = "userAgent") val userAgent: String, /** Client locale (e.g. `en-US`). */ - @Json(name = "locale") val locale: String?, + @Json(name = "locale") val locale: String, /** Client timezone (e.g. `Europe/Moscow`). */ - @Json(name = "timezone") val timezone: String?, + @Json(name = "timezone") val timezone: String, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt new file mode 100644 index 0000000000..8e9ce11c2e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt @@ -0,0 +1,46 @@ +package com.tangem.datasource.api.auth.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Wallet registration request — binds a new wallet to an already-registered device. + * + * When [cardSignature] (and the accompanying [cardSignatureSalt] / [walletStatus]) is provided the + * wallet is bound as COLD (card-backed); otherwise it is registered as a MOBILE (hot) wallet. + * Mirrors the `WalletRegistrationRequest` schema in the backend OpenAPI contract. + */ +@JsonClass(generateAdapter = true) +data class WalletRegistrationRequest( + /** Deciphered nonce value from `/api/v1/auth/nonce/wallet`. */ + @Json(name = "nonce") val nonce: String, + /** + * Wallet identifier — Base64-encoded + * `HMAC-SHA256(key = SHA-256(walletPublicKey), data = "UserWalletID")`. + */ + @Json(name = "walletId") val walletId: String, + /** + * Base64-encoded secp256k1 RSV signature (65 bytes) over `sha256(nonce || walletSignatureSalt)`. + * The server recovers `walletPublicKey` from this signature. + */ + @Json(name = "walletSignature") val walletSignature: String, + /** Base64-encoded salt used in the wallet signature hash. */ + @Json(name = "walletSignatureSalt") val walletSignatureSalt: String, + /** + * Base64-encoded secp256k1 RSV signature (65 bytes) over + * `sha256(walletPublicKey || nonce || cardSignatureSalt || walletStatus)`. Required for + * cold-wallet registration; `null` for mobile (hot) wallets. + */ + @Json(name = "cardSignature") val cardSignature: String?, + /** Base64-encoded salt used in the card signature hash. Required for cold-wallet registration. */ + @Json(name = "cardSignatureSalt") val cardSignatureSalt: String?, + /** + * Base64-encoded single byte describing wallet provenance on the card + * (`0x82` = generated on card, `0xC2` = SEED imported). Required for cold-wallet registration. + */ + @Json(name = "walletStatus") val walletStatus: String?, + /** Platform attestation token (Play Integrity / App Attest). */ + @Json(name = "attestationToken") val attestationToken: String?, + /** Client-reported device metadata. */ + @Json(name = "metadata") val metadata: DeviceMetadata, +) \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt index eb2242eaa5..2a94fe25e7 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt @@ -3,7 +3,9 @@ package com.tangem.lib.auth.dpop.internal import arrow.core.None import arrow.core.Option import com.tangem.lib.auth.dpop.DpopProofFactory +import com.tangem.utils.annotations.RemoveWithToggle +@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED") internal object DisabledDpopProofFactory : DpopProofFactory { override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option = None diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt index 2d01b7e578..cf6c367cb9 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt @@ -19,6 +19,9 @@ sealed class AuthError(open val problem: AuthErrorResponse?) { /** `404` — token / resource not found. */ data class NotFound(override val problem: AuthErrorResponse?) : AuthError(problem) + /** `409` — conflict / already exists (e.g. device or wallet already registered). */ + data class Conflict(override val problem: AuthErrorResponse?) : AuthError(problem) + /** `429` — server-side rate limit; honour [retryAfterSeconds] before retrying. */ data class RateLimited( val retryAfterSeconds: Int?, diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt index 5a79eca439..2f8780035e 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt @@ -2,8 +2,8 @@ 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. + * (re-registration required, server-side block) from transient ones (network / server) so callers + * can decide whether to retry, surface UI, or trigger deferred-registration flow. */ sealed class SessionRefreshError { @@ -12,10 +12,18 @@ sealed class SessionRefreshError { /** * Terminal — `/authenticate` returned 401/403. Session store was cleared; the device must - * re-register ([REDACTED_TASK_KEY] / deferred-registration flow). + * re-register. */ data object SessionRevoked : SessionRefreshError() + /** + * Terminal — `/refresh` returned 403 (RED tier). The device is server-side blocked; + * `/authenticate` won't help (it would also return 403). The client should not retry within + * the current session — only attempt `/refresh` again on the next app launch, in case the + * server-side block was lifted. + */ + data object DeviceBlocked : SessionRefreshError() + /** Device key is not provisioned in Keystore (registration not yet run, or Keystore unavailable). */ data object DeviceKeyUnavailable : SessionRefreshError() diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt index 8c585af554..04be67d148 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt @@ -11,12 +11,13 @@ import arrow.core.Either * * 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). + * 2. On 401 from `/refresh` (revoked / replayed / expired refresh token), fall back to full + * re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate` signed by the + * device key. + * 3. On 403 from `/refresh` (RED tier — device blocked server-side), return + * [SessionRefreshError.DeviceBlocked] without trying `/authenticate` (it would also 403). + * 4. On 401/403 from `/authenticate`, clear the session store and return + * [SessionRefreshError.SessionRevoked] — the device must be re-registered. */ interface SessionTokenRefresher { diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt index 6e9ec92012..117912dbc0 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt @@ -35,6 +35,7 @@ internal class AuthErrorConverter @Inject constructor() : Converter AuthError.Unauthorized(problem) Code.FORBIDDEN -> AuthError.Forbidden(problem) Code.NOT_FOUND -> AuthError.NotFound(problem) + Code.CONFLICT -> AuthError.Conflict(problem) Code.TOO_MANY_REQUESTS -> AuthError.RateLimited(problem?.retryAfterSeconds, problem) else -> if (error.isServerError()) AuthError.ServerUnavailable(problem) else AuthError.Unknown(error) } diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt index 44abb0afb4..af14515777 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt @@ -1,11 +1,13 @@ package com.tangem.lib.auth.session.internal import arrow.core.Either +import arrow.core.raise.Raise 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.auth.models.response.TokenApiResponse import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys @@ -13,6 +15,7 @@ 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.AuthError import com.tangem.lib.auth.session.DeviceRegistrar import com.tangem.lib.auth.session.DeviceRegistrationError import com.tangem.lib.auth.session.SessionTokensStore @@ -89,10 +92,16 @@ internal class DefaultDeviceRegistrar( raise(DeviceRegistrationError.SigningFailed(e)) } - val registerResponse = authApi.register(RegisterApiRequest(payload = payload, signature = signature)) - when (registerResponse) { + val registerResponse = authApi.registerDevice(RegisterApiRequest(payload = payload, signature = signature)) + handleRegisterResponse(registerResponse) + } + + private suspend fun Raise.handleRegisterResponse( + response: ApiResponse, + ) { + when (response) { is ApiResponse.Success -> { - val tokens = SessionTokensConverter.convertBack(registerResponse.data) + val tokens = SessionTokensConverter.convertBack(response.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 @@ -106,10 +115,27 @@ internal class DefaultDeviceRegistrar( TangemLogger.i("Device registered successfully") } is ApiResponse.Error -> { - val authError = errorConverter.convert(registerResponse.cause) + val authError = errorConverter.convert(response.cause) + if (authError is AuthError.Conflict) { + // Device is already registered server-side (e.g. the local flag was lost on + // reinstall). Persist the flag to stop retrying; session tokens will be minted + // on demand via /authenticate. + TangemLogger.i("Device already registered server-side (409) — marking as registered") + markRegistered(onFailureLog = "Failed to persist device-registration flag after 409") + return + } TangemLogger.e("/register request failed: $authError") raise(DeviceRegistrationError.Api(authError)) } } } + + private suspend fun Raise.markRegistered(onFailureLog: String) { + try { + appPreferencesStore.store(key = PreferencesKeys.IS_DEVICE_REGISTERED_KEY, value = true) + } catch (e: Exception) { + TangemLogger.e(onFailureLog, e) + raise(DeviceRegistrationError.PersistenceFailed(e)) + } + } } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt index 3357b50e06..17e2eff7c9 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt @@ -9,7 +9,6 @@ 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 @@ -55,10 +54,17 @@ internal class DefaultSessionTokenRefresher( } if (isOwner) { + TangemLogger.i("Session refresh started (owner)") try { - deferred.complete(runRefresh(current = store.get().getOrNull())) + val outcome = runRefresh(current = store.get().getOrNull()) + outcome.fold( + ifLeft = { TangemLogger.e("Session refresh finished with error: $it") }, + ifRight = { TangemLogger.i("Session refresh finished successfully") }, + ) + deferred.complete(outcome) } catch (t: Throwable) { // Propagate to every waiter — without this they'd suspend forever on `await()`. + TangemLogger.e("Session refresh threw; propagating to waiters", t) deferred.completeExceptionally(t) throw t } finally { @@ -68,6 +74,8 @@ internal class DefaultSessionTokenRefresher( mutex.withLock { inFlight = null } } } + } else { + TangemLogger.i("Session refresh already in-flight — joining as waiter") } deferred.await() @@ -78,11 +86,31 @@ internal class DefaultSessionTokenRefresher( val isRefreshTokenValid = current?.refreshTokenExpiresAt != null && current.refreshTokenExpiresAt > now if (current?.refreshToken != null && isRefreshTokenValid) { + TangemLogger.i("Calling /refresh with stored refresh token") 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() + is RefreshOutcome.Success -> { + store.save(result.tokens) + TangemLogger.i("/refresh succeeded; session tokens persisted") + return result.tokens.right() + } + // 401: refresh token is invalid/expired/revoked/replayed but the device key is + // intact server-side → fall back to /authenticate to mint a new pair. + RefreshOutcome.RefreshTokenInvalid -> { + TangemLogger.i("/refresh returned 401 — falling back to /authenticate") + } + // 403: device is blocked server-side (RED tier). `/authenticate` would also 403, + // so don't waste the call — surface the terminal state and let the caller bail. + RefreshOutcome.DeviceBlocked -> { + TangemLogger.e("/refresh returned 403 — device is blocked server-side (terminal)") + return SessionRefreshError.DeviceBlocked.left() + } + is RefreshOutcome.Transient -> { + TangemLogger.e("/refresh failed with transient error: ${result.cause}") + return SessionRefreshError.Api(result.cause).left() + } } + } else { + TangemLogger.i("No valid refresh token in store — proceeding directly to /authenticate") } return runAuthenticate() @@ -90,10 +118,19 @@ internal class DefaultSessionTokenRefresher( private suspend fun callRefresh(refreshToken: String): RefreshOutcome { val response = authApi.refresh(RefreshApiRequest(refreshToken = refreshToken)) - return handleTokenResponse(response, clearOnUnauthenticated = false) + return when (response) { + is ApiResponse.Success -> RefreshOutcome.Success(SessionTokensConverter.convertBack(response.data)) + is ApiResponse.Error -> when (val authError = errorConverter.convert(response.cause)) { + is AuthError.Unauthorized -> RefreshOutcome.RefreshTokenInvalid + is AuthError.Forbidden -> RefreshOutcome.DeviceBlocked + else -> RefreshOutcome.Transient(authError) + } + } } private suspend fun runAuthenticate(): Either = either { + TangemLogger.i("Starting /authenticate") + val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull() ?: raise(SessionRefreshError.DeviceKeyUnavailable) @@ -104,6 +141,7 @@ internal class DefaultSessionTokenRefresher( is ApiResponse.Success -> nonceResponse.data.cipheredNonce is ApiResponse.Error -> { val authError = errorConverter.convert(nonceResponse.cause) + TangemLogger.e("/nonce/auth request failed: $authError") raise(SessionRefreshError.Api(authError)) } } @@ -129,33 +167,25 @@ internal class DefaultSessionTokenRefresher( } 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, - clearOnUnauthenticated: Boolean, - ): RefreshOutcome { - return when (response) { + when (authResponse) { is ApiResponse.Success -> { - val tokens = SessionTokensConverter.convertBack(response.data) + val tokens = SessionTokensConverter.convertBack(authResponse.data) store.save(tokens) - RefreshOutcome.Success(tokens) + TangemLogger.i("/authenticate succeeded; session tokens persisted") + tokens } is ApiResponse.Error -> { - when (val authError = errorConverter.convert(response.cause)) { + val authError = errorConverter.convert(authResponse.cause) + when (authError) { is AuthError.Unauthorized, is AuthError.Forbidden -> { - if (clearOnUnauthenticated) { - TangemLogger.i("Session revoked: ${authError.problem?.detail ?: authError}") - store.clear() - } - RefreshOutcome.Unauthenticated + TangemLogger.i("Session revoked: ${authError.problem?.detail ?: authError}") + store.clear() + raise(SessionRefreshError.SessionRevoked) + } + else -> { + TangemLogger.e("/authenticate request failed: $authError") + raise(SessionRefreshError.Api(authError)) } - else -> RefreshOutcome.Transient(authError) } } } @@ -163,7 +193,8 @@ internal class DefaultSessionTokenRefresher( private sealed interface RefreshOutcome { data class Success(val tokens: SessionTokens) : RefreshOutcome - data object Unauthenticated : RefreshOutcome + data object RefreshTokenInvalid : RefreshOutcome + data object DeviceBlocked : RefreshOutcome data class Transient(val cause: AuthError) : RefreshOutcome } } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt index 6b6dee0459..4e25661427 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt @@ -4,7 +4,9 @@ import arrow.core.Either import arrow.core.left import com.tangem.lib.auth.session.DeviceRegistrar import com.tangem.lib.auth.session.DeviceRegistrationError +import com.tangem.utils.annotations.RemoveWithToggle +@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED") internal object DisabledDeviceRegistrar : DeviceRegistrar { override suspend fun register(): Either { diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt index beecbe578f..51bf498bca 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt @@ -17,7 +17,7 @@ internal class SignedRequestPayload @Inject constructor( private val appInfoProvider: AppInfoProvider, ) { - /** Snapshot of [appInfoProvider]'s device facts as the network DTO. `userAgent` is intentionally null. */ + /** Snapshot of [appInfoProvider]'s device facts as the network DTO. */ val deviceMetadata: DeviceMetadata get() = DeviceMetadata( deviceModel = appInfoProvider.device, @@ -25,7 +25,7 @@ internal class SignedRequestPayload @Inject constructor( os = appInfoProvider.platform.lowercase(), osVersion = appInfoProvider.osVersion, appVersion = appInfoProvider.appVersion, - userAgent = null, + userAgent = with(appInfoProvider) { "Tangem/$appVersion ($device; $platform $osVersion)" }, locale = appInfoProvider.language, timezone = appInfoProvider.timezone, ) @@ -49,9 +49,7 @@ internal class SignedRequestPayload @Inject constructor( /** * 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). + * declaration order of [RegisterPayload] / [AuthenticationPayload] and [DeviceMetadata]. */ private fun canonicalize( devicePublicKey: String, @@ -62,12 +60,13 @@ internal class SignedRequestPayload @Inject constructor( append(devicePublicKey).append('\n') append(nonce).append('\n') append(attestationToken.orEmpty()).append('\n') - append(metadata.deviceModel.orEmpty()).append('\n') + append(metadata.deviceModel).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()) + append(metadata.osVersion).append('\n') + append(metadata.appVersion).append('\n') + append(metadata.userAgent).append('\n') + append(metadata.locale).append('\n') + append(metadata.timezone) }.toByteArray(Charsets.UTF_8) } diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt index 6e782c6df0..b9d720db27 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt @@ -71,6 +71,14 @@ class AuthErrorConverterTest { assertThat(result).isInstanceOf(AuthError.NotFound::class.java) } + @Test + fun `409 is converted to Conflict`() { + val result = converter.convert(httpError(Code.CONFLICT, sampleBody)) + + assertThat(result).isInstanceOf(AuthError.Conflict::class.java) + assertThat((result as AuthError.Conflict).problem).isEqualTo(sampleProblem) + } + @Test fun `429 surfaces retryAfterSeconds from problem`() { val rateLimitBody = """ diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt index ebf0d14a60..b523254315 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt @@ -9,7 +9,6 @@ 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 @@ -89,7 +88,7 @@ class DefaultDeviceRegistrarTest { assertThat(result.isRight()).isTrue() coVerify { authApi.requestDeviceNonce(any()) } - coVerify { authApi.register(any()) } + coVerify { authApi.registerDevice(any()) } coVerify { store.save(any()) } assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isTrue() } @@ -102,7 +101,7 @@ class DefaultDeviceRegistrarTest { assertThat(result.isRight()).isTrue() coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) } - coVerify(exactly = 0) { authApi.register(any()) } + coVerify(exactly = 0) { authApi.registerDevice(any()) } coVerify(exactly = 0) { store.save(any()) } } @@ -114,7 +113,7 @@ class DefaultDeviceRegistrarTest { assertThat(result.leftOrNull()).isEqualTo(DeviceRegistrationError.DeviceKeyUnavailable) coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) } - coVerify(exactly = 0) { authApi.register(any()) } + coVerify(exactly = 0) { authApi.registerDevice(any()) } assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull() } @@ -133,7 +132,7 @@ class DefaultDeviceRegistrarTest { val result = registrar.register() assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.Api::class.java) - coVerify(exactly = 0) { authApi.register(any()) } + coVerify(exactly = 0) { authApi.registerDevice(any()) } assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull() } @@ -148,7 +147,7 @@ class DefaultDeviceRegistrarTest { val result = registrar.register() assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.NonceDecryptionFailed::class.java) - coVerify(exactly = 0) { authApi.register(any()) } + coVerify(exactly = 0) { authApi.registerDevice(any()) } } @Test @@ -163,7 +162,7 @@ class DefaultDeviceRegistrarTest { val result = registrar.register() assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.SigningFailed::class.java) - coVerify(exactly = 0) { authApi.register(any()) } + coVerify(exactly = 0) { authApi.registerDevice(any()) } } @Test @@ -175,7 +174,7 @@ class DefaultDeviceRegistrarTest { coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) @Suppress("UNCHECKED_CAST") - coEvery { authApi.register(any()) } returns ApiResponse.Error( + coEvery { authApi.registerDevice(any()) } returns ApiResponse.Error( cause = ApiResponseError.HttpException( code = ApiResponseError.HttpException.Code.FORBIDDEN, message = "already registered", @@ -190,6 +189,31 @@ class DefaultDeviceRegistrarTest { assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull() } + @Test + fun `register treats 409 Conflict as success, sets flag without persisting tokens`() = 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.registerDevice(any()) } returns ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.CONFLICT, + message = "device already registered", + errorBody = null, + ), + ) as ApiResponse + + val result = registrar.register() + + // Device is already registered server-side — no error, flag set, but no tokens minted here. + assertThat(result.isRight()).isTrue() + assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isTrue() + coVerify(exactly = 0) { store.save(any()) } + } + @Test fun `register returns PersistenceFailed when SessionTokensStore_save throws`() = runTest { stubHappyPath() @@ -209,7 +233,7 @@ class DefaultDeviceRegistrarTest { ) coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) - coEvery { authApi.register(any()) } returns ApiResponse.Success( + coEvery { authApi.registerDevice(any()) } returns ApiResponse.Success( data = TokenApiResponse( accessToken = "fresh-access", accessTokenExpiresAt = "2024-01-01T00:00:00Z", diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt index f5263ea5b4..7bc6838580 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt @@ -102,6 +102,7 @@ class DefaultSessionTokenRefresherTest { assertThat(tokens.refreshToken).isEqualTo("rt-2") assertThat(tokens.walletIds).containsExactly("w1", "w2") coVerify { store.save(tokens) } + coVerify(exactly = 0) { authApi.authenticate(any()) } } @Test @@ -133,6 +134,34 @@ class DefaultSessionTokenRefresherTest { coVerify { authApi.authenticate(any()) } } + @Test + fun `refresh returns DeviceBlocked when refresh returns 403 — does not call authenticate`() = 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.FORBIDDEN, + message = "RED tier", + errorBody = null, + ), + ) as ApiResponse + + val result = refresher.refresh() + + // 403 means device is server-side blocked; /authenticate would also fail with 403. + // Don't fall through. + assertThat(result.leftOrNull()).isEqualTo(SessionRefreshError.DeviceBlocked) + coVerify(exactly = 0) { authApi.requestAuthNonce(any()) } + coVerify(exactly = 0) { authApi.authenticate(any()) } + } + @Test fun `refresh clears store when authenticate returns 403`() = runTest { coEvery { store.get() } returns None @@ -159,6 +188,17 @@ class DefaultSessionTokenRefresherTest { coVerify { store.clear() } } + @Test + fun `refresh returns DeviceKeyUnavailable when authenticate fallback has no key`() = runTest { + coEvery { store.get() } returns None + coEvery { deviceKeyManager.getPublicKey() } returns None + + val result = refresher.refresh() + + assertThat(result.leftOrNull()).isEqualTo(SessionRefreshError.DeviceKeyUnavailable) + coVerify(exactly = 0) { authApi.requestAuthNonce(any()) } + } + @Test fun `concurrent callers share a single refresh round-trip — both get same result`() = runTest { val stored = SessionTokens( diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt index 6c83b8845d..5b7fba6f0e 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt @@ -25,7 +25,7 @@ class SignedRequestPayloadTest { private val signedRequestPayload = SignedRequestPayload(appInfoProvider) @Test - fun `deviceMetadata wires AppInfoProvider fields, forces userAgent to null, lowercases platform`() { + fun `deviceMetadata wires AppInfoProvider fields, builds userAgent, lowercases platform`() { val metadata = signedRequestPayload.deviceMetadata // Backend contract is lowercase `android`/`ios` — verify normalization at the source. @@ -35,7 +35,7 @@ class SignedRequestPayloadTest { os = "android", osVersion = "14", appVersion = "5.40.0", - userAgent = null, + userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)", locale = "en-US", timezone = "Europe/Moscow", ), @@ -49,7 +49,7 @@ class SignedRequestPayloadTest { os = "Android", osVersion = "14", appVersion = "5.40.0", - userAgent = null, + userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)", locale = "en-US", timezone = "Europe/Moscow", ) @@ -71,6 +71,7 @@ class SignedRequestPayloadTest { Android 14 5.40.0 + Tangem/5.40.0 (Pixel 8; Android 14) en-US Europe/Moscow """.trimIndent(), @@ -78,15 +79,15 @@ class SignedRequestPayloadTest { } @Test - fun `canonicalize replaces null fields with empty string`() { + fun `canonicalize replaces null attestationToken with empty string`() { val metadata = DeviceMetadata( - deviceModel = null, + deviceModel = "Pixel 8", os = "Android", - osVersion = null, - appVersion = null, - userAgent = null, - locale = null, - timezone = null, + osVersion = "14", + appVersion = "5.40.0", + userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)", + locale = "en-US", + timezone = "Europe/Moscow", ) val payload = RegisterPayload( devicePublicKey = "pub", @@ -97,8 +98,10 @@ class SignedRequestPayloadTest { 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") + // The null attestationToken collapses to an empty slot between `nonce` and `deviceModel`. + assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo( + "pub\nnonce-1\n\nPixel 8\nAndroid\n14\n5.40.0\nTangem/5.40.0 (Pixel 8; Android 14)\nen-US\nEurope/Moscow", + ) } @Test @@ -110,7 +113,7 @@ class SignedRequestPayloadTest { os = "Android", osVersion = "14", appVersion = "5.40.0", - userAgent = null, + userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)", locale = "en-US", timezone = "Europe/Moscow", )