Updated on 2026-08-14
This commit is contained in:
parent
e79c46caf4
commit
1f27edeb70
16 changed files with 294 additions and 81 deletions
|
|
@ -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.NonceApiRequest
|
||||||
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
|
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.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.NonceApiResponse
|
||||||
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||||
import com.tangem.datasource.api.common.response.ApiResponse
|
import com.tangem.datasource.api.common.response.ApiResponse
|
||||||
|
|
@ -30,8 +31,7 @@ interface AuthApi {
|
||||||
* session token pair. Called once per app install.
|
* session token pair. Called once per app install.
|
||||||
*/
|
*/
|
||||||
@POST("api/v1/auth/register")
|
@POST("api/v1/auth/register")
|
||||||
@RequiresDpopProof
|
suspend fun registerDevice(@Body request: RegisterApiRequest): ApiResponse<TokenApiResponse>
|
||||||
suspend fun register(@Body request: RegisterApiRequest): ApiResponse<TokenApiResponse>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request authentication nonce.
|
* Request authentication nonce.
|
||||||
|
|
@ -61,4 +61,23 @@ interface AuthApi {
|
||||||
@POST("api/v1/auth/refresh")
|
@POST("api/v1/auth/refresh")
|
||||||
@RequiresDpopProof
|
@RequiresDpopProof
|
||||||
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
|
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<NonceApiResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TokenApiResponse>
|
||||||
}
|
}
|
||||||
|
|
@ -10,17 +10,17 @@ import com.squareup.moshi.JsonClass
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class DeviceMetadata(
|
data class DeviceMetadata(
|
||||||
/** Device hardware model (e.g. `iPhone 15 Pro`). */
|
/** Device hardware model (e.g. `iPhone 15 Pro`). */
|
||||||
@Json(name = "deviceModel") val deviceModel: String?,
|
@Json(name = "deviceModel") val deviceModel: String,
|
||||||
/** Operating system (`android` / `ios`). */
|
/** Operating system (`android` / `ios`). */
|
||||||
@Json(name = "os") val os: String,
|
@Json(name = "os") val os: String,
|
||||||
/** OS version string (e.g. `17.4.1`). */
|
/** 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`). */
|
/** 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)`). */
|
/** 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`). */
|
/** Client locale (e.g. `en-US`). */
|
||||||
@Json(name = "locale") val locale: String?,
|
@Json(name = "locale") val locale: String,
|
||||||
/** Client timezone (e.g. `Europe/Moscow`). */
|
/** Client timezone (e.g. `Europe/Moscow`). */
|
||||||
@Json(name = "timezone") val timezone: String?,
|
@Json(name = "timezone") val timezone: String,
|
||||||
)
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -3,7 +3,9 @@ package com.tangem.lib.auth.dpop.internal
|
||||||
import arrow.core.None
|
import arrow.core.None
|
||||||
import arrow.core.Option
|
import arrow.core.Option
|
||||||
import com.tangem.lib.auth.dpop.DpopProofFactory
|
import com.tangem.lib.auth.dpop.DpopProofFactory
|
||||||
|
import com.tangem.utils.annotations.RemoveWithToggle
|
||||||
|
|
||||||
|
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
|
||||||
internal object DisabledDpopProofFactory : DpopProofFactory {
|
internal object DisabledDpopProofFactory : DpopProofFactory {
|
||||||
|
|
||||||
override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String> = None
|
override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String> = None
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ sealed class AuthError(open val problem: AuthErrorResponse?) {
|
||||||
/** `404` — token / resource not found. */
|
/** `404` — token / resource not found. */
|
||||||
data class NotFound(override val problem: AuthErrorResponse?) : AuthError(problem)
|
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. */
|
/** `429` — server-side rate limit; honour [retryAfterSeconds] before retrying. */
|
||||||
data class RateLimited(
|
data class RateLimited(
|
||||||
val retryAfterSeconds: Int?,
|
val retryAfterSeconds: Int?,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ package com.tangem.lib.auth.session
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typed failure mode of `SessionTokenRefresher.refresh()`. Distinguishes terminal failures
|
* Typed failure mode of `SessionTokenRefresher.refresh()`. Distinguishes terminal failures
|
||||||
* (re-registration required) from transient ones (network / server) so callers can decide
|
* (re-registration required, server-side block) from transient ones (network / server) so callers
|
||||||
* whether to retry, surface UI, or trigger deferred-registration flow.
|
* can decide whether to retry, surface UI, or trigger deferred-registration flow.
|
||||||
*/
|
*/
|
||||||
sealed class SessionRefreshError {
|
sealed class SessionRefreshError {
|
||||||
|
|
||||||
|
|
@ -12,10 +12,18 @@ sealed class SessionRefreshError {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terminal — `/authenticate` returned 401/403. Session store was cleared; the device must
|
* 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()
|
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). */
|
/** Device key is not provisioned in Keystore (registration not yet run, or Keystore unavailable). */
|
||||||
data object DeviceKeyUnavailable : SessionRefreshError()
|
data object DeviceKeyUnavailable : SessionRefreshError()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@ import arrow.core.Either
|
||||||
*
|
*
|
||||||
* Refresh strategy:
|
* Refresh strategy:
|
||||||
* 1. Call `/api/v1/auth/refresh` with the stored refresh token when it is present and unexpired.
|
* 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
|
* 2. On 401 from `/refresh` (revoked / replayed / expired refresh token), fall back to full
|
||||||
* full re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate`
|
* re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate` signed by the
|
||||||
* signed by the device key.
|
* device key.
|
||||||
* 3. On 401/403 from `/authenticate`, clear the session store and return
|
* 3. On 403 from `/refresh` (RED tier — device blocked server-side), return
|
||||||
* [SessionRefreshError.SessionRevoked] — the device must be re-registered (see [REDACTED_TASK_KEY]
|
* [SessionRefreshError.DeviceBlocked] without trying `/authenticate` (it would also 403).
|
||||||
* for the deferred-registration flag).
|
* 4. On 401/403 from `/authenticate`, clear the session store and return
|
||||||
|
* [SessionRefreshError.SessionRevoked] — the device must be re-registered.
|
||||||
*/
|
*/
|
||||||
interface SessionTokenRefresher {
|
interface SessionTokenRefresher {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ internal class AuthErrorConverter @Inject constructor() : Converter<Throwable, A
|
||||||
Code.UNAUTHORIZED -> AuthError.Unauthorized(problem)
|
Code.UNAUTHORIZED -> AuthError.Unauthorized(problem)
|
||||||
Code.FORBIDDEN -> AuthError.Forbidden(problem)
|
Code.FORBIDDEN -> AuthError.Forbidden(problem)
|
||||||
Code.NOT_FOUND -> AuthError.NotFound(problem)
|
Code.NOT_FOUND -> AuthError.NotFound(problem)
|
||||||
|
Code.CONFLICT -> AuthError.Conflict(problem)
|
||||||
Code.TOO_MANY_REQUESTS -> AuthError.RateLimited(problem?.retryAfterSeconds, problem)
|
Code.TOO_MANY_REQUESTS -> AuthError.RateLimited(problem?.retryAfterSeconds, problem)
|
||||||
else -> if (error.isServerError()) AuthError.ServerUnavailable(problem) else AuthError.Unknown(error)
|
else -> if (error.isServerError()) AuthError.ServerUnavailable(problem) else AuthError.Unknown(error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
package com.tangem.lib.auth.session.internal
|
package com.tangem.lib.auth.session.internal
|
||||||
|
|
||||||
import arrow.core.Either
|
import arrow.core.Either
|
||||||
|
import arrow.core.raise.Raise
|
||||||
import arrow.core.raise.either
|
import arrow.core.raise.either
|
||||||
import com.tangem.datasource.api.auth.AuthApi
|
import com.tangem.datasource.api.auth.AuthApi
|
||||||
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
|
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.RegisterApiRequest
|
||||||
import com.tangem.datasource.api.auth.models.request.RegisterPayload
|
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.api.common.response.ApiResponse
|
||||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
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.datasource.local.preferences.utils.store
|
||||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||||
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
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.DeviceRegistrar
|
||||||
import com.tangem.lib.auth.session.DeviceRegistrationError
|
import com.tangem.lib.auth.session.DeviceRegistrationError
|
||||||
import com.tangem.lib.auth.session.SessionTokensStore
|
import com.tangem.lib.auth.session.SessionTokensStore
|
||||||
|
|
@ -89,10 +92,16 @@ internal class DefaultDeviceRegistrar(
|
||||||
raise(DeviceRegistrationError.SigningFailed(e))
|
raise(DeviceRegistrationError.SigningFailed(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
val registerResponse = authApi.register(RegisterApiRequest(payload = payload, signature = signature))
|
val registerResponse = authApi.registerDevice(RegisterApiRequest(payload = payload, signature = signature))
|
||||||
when (registerResponse) {
|
handleRegisterResponse(registerResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun Raise<DeviceRegistrationError>.handleRegisterResponse(
|
||||||
|
response: ApiResponse<TokenApiResponse>,
|
||||||
|
) {
|
||||||
|
when (response) {
|
||||||
is ApiResponse.Success -> {
|
is ApiResponse.Success -> {
|
||||||
val tokens = SessionTokensConverter.convertBack(registerResponse.data)
|
val tokens = SessionTokensConverter.convertBack(response.data)
|
||||||
try {
|
try {
|
||||||
// Keep both writes inside one catch — if the second one fails, the flag stays
|
// 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
|
// `false` and the next launch retries cleanly. Worst case: tokens are persisted
|
||||||
|
|
@ -106,10 +115,27 @@ internal class DefaultDeviceRegistrar(
|
||||||
TangemLogger.i("Device registered successfully")
|
TangemLogger.i("Device registered successfully")
|
||||||
}
|
}
|
||||||
is ApiResponse.Error -> {
|
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")
|
TangemLogger.e("/register request failed: $authError")
|
||||||
raise(DeviceRegistrationError.Api(authError))
|
raise(DeviceRegistrationError.Api(authError))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun Raise<DeviceRegistrationError>.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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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.AuthenticationPayload
|
||||||
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
|
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.RefreshApiRequest
|
||||||
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
|
||||||
import com.tangem.datasource.api.common.response.ApiResponse
|
import com.tangem.datasource.api.common.response.ApiResponse
|
||||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||||
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
||||||
|
|
@ -55,10 +54,17 @@ internal class DefaultSessionTokenRefresher(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOwner) {
|
if (isOwner) {
|
||||||
|
TangemLogger.i("Session refresh started (owner)")
|
||||||
try {
|
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) {
|
} catch (t: Throwable) {
|
||||||
// Propagate to every waiter — without this they'd suspend forever on `await()`.
|
// Propagate to every waiter — without this they'd suspend forever on `await()`.
|
||||||
|
TangemLogger.e("Session refresh threw; propagating to waiters", t)
|
||||||
deferred.completeExceptionally(t)
|
deferred.completeExceptionally(t)
|
||||||
throw t
|
throw t
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -68,6 +74,8 @@ internal class DefaultSessionTokenRefresher(
|
||||||
mutex.withLock { inFlight = null }
|
mutex.withLock { inFlight = null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
TangemLogger.i("Session refresh already in-flight — joining as waiter")
|
||||||
}
|
}
|
||||||
|
|
||||||
deferred.await()
|
deferred.await()
|
||||||
|
|
@ -78,11 +86,31 @@ internal class DefaultSessionTokenRefresher(
|
||||||
|
|
||||||
val isRefreshTokenValid = current?.refreshTokenExpiresAt != null && current.refreshTokenExpiresAt > now
|
val isRefreshTokenValid = current?.refreshTokenExpiresAt != null && current.refreshTokenExpiresAt > now
|
||||||
if (current?.refreshToken != null && isRefreshTokenValid) {
|
if (current?.refreshToken != null && isRefreshTokenValid) {
|
||||||
|
TangemLogger.i("Calling /refresh with stored refresh token")
|
||||||
when (val result = callRefresh(current.refreshToken)) {
|
when (val result = callRefresh(current.refreshToken)) {
|
||||||
is RefreshOutcome.Success -> return result.tokens.right()
|
is RefreshOutcome.Success -> {
|
||||||
RefreshOutcome.Unauthenticated -> Unit // fall through to /authenticate
|
store.save(result.tokens)
|
||||||
is RefreshOutcome.Transient -> return SessionRefreshError.Api(result.cause).left()
|
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()
|
return runAuthenticate()
|
||||||
|
|
@ -90,10 +118,19 @@ internal class DefaultSessionTokenRefresher(
|
||||||
|
|
||||||
private suspend fun callRefresh(refreshToken: String): RefreshOutcome {
|
private suspend fun callRefresh(refreshToken: String): RefreshOutcome {
|
||||||
val response = authApi.refresh(RefreshApiRequest(refreshToken = refreshToken))
|
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<SessionRefreshError, SessionTokens> = either {
|
private suspend fun runAuthenticate(): Either<SessionRefreshError, SessionTokens> = either {
|
||||||
|
TangemLogger.i("Starting /authenticate")
|
||||||
|
|
||||||
val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull()
|
val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull()
|
||||||
?: raise(SessionRefreshError.DeviceKeyUnavailable)
|
?: raise(SessionRefreshError.DeviceKeyUnavailable)
|
||||||
|
|
||||||
|
|
@ -104,6 +141,7 @@ internal class DefaultSessionTokenRefresher(
|
||||||
is ApiResponse.Success -> nonceResponse.data.cipheredNonce
|
is ApiResponse.Success -> nonceResponse.data.cipheredNonce
|
||||||
is ApiResponse.Error -> {
|
is ApiResponse.Error -> {
|
||||||
val authError = errorConverter.convert(nonceResponse.cause)
|
val authError = errorConverter.convert(nonceResponse.cause)
|
||||||
|
TangemLogger.e("/nonce/auth request failed: $authError")
|
||||||
raise(SessionRefreshError.Api(authError))
|
raise(SessionRefreshError.Api(authError))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -129,33 +167,25 @@ internal class DefaultSessionTokenRefresher(
|
||||||
}
|
}
|
||||||
|
|
||||||
val authResponse = authApi.authenticate(AuthApiRequest(payload = payload, signature = signature))
|
val authResponse = authApi.authenticate(AuthApiRequest(payload = payload, signature = signature))
|
||||||
return when (val outcome = handleTokenResponse(authResponse, clearOnUnauthenticated = true)) {
|
when (authResponse) {
|
||||||
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 -> {
|
is ApiResponse.Success -> {
|
||||||
val tokens = SessionTokensConverter.convertBack(response.data)
|
val tokens = SessionTokensConverter.convertBack(authResponse.data)
|
||||||
store.save(tokens)
|
store.save(tokens)
|
||||||
RefreshOutcome.Success(tokens)
|
TangemLogger.i("/authenticate succeeded; session tokens persisted")
|
||||||
|
tokens
|
||||||
}
|
}
|
||||||
is ApiResponse.Error -> {
|
is ApiResponse.Error -> {
|
||||||
when (val authError = errorConverter.convert(response.cause)) {
|
val authError = errorConverter.convert(authResponse.cause)
|
||||||
|
when (authError) {
|
||||||
is AuthError.Unauthorized, is AuthError.Forbidden -> {
|
is AuthError.Unauthorized, is AuthError.Forbidden -> {
|
||||||
if (clearOnUnauthenticated) {
|
TangemLogger.i("Session revoked: ${authError.problem?.detail ?: authError}")
|
||||||
TangemLogger.i("Session revoked: ${authError.problem?.detail ?: authError}")
|
store.clear()
|
||||||
store.clear()
|
raise(SessionRefreshError.SessionRevoked)
|
||||||
}
|
}
|
||||||
RefreshOutcome.Unauthenticated
|
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 {
|
private sealed interface RefreshOutcome {
|
||||||
data class Success(val tokens: SessionTokens) : 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
|
data class Transient(val cause: AuthError) : RefreshOutcome
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,7 +4,9 @@ import arrow.core.Either
|
||||||
import arrow.core.left
|
import arrow.core.left
|
||||||
import com.tangem.lib.auth.session.DeviceRegistrar
|
import com.tangem.lib.auth.session.DeviceRegistrar
|
||||||
import com.tangem.lib.auth.session.DeviceRegistrationError
|
import com.tangem.lib.auth.session.DeviceRegistrationError
|
||||||
|
import com.tangem.utils.annotations.RemoveWithToggle
|
||||||
|
|
||||||
|
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
|
||||||
internal object DisabledDeviceRegistrar : DeviceRegistrar {
|
internal object DisabledDeviceRegistrar : DeviceRegistrar {
|
||||||
|
|
||||||
override suspend fun register(): Either<DeviceRegistrationError, Unit> {
|
override suspend fun register(): Either<DeviceRegistrationError, Unit> {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ internal class SignedRequestPayload @Inject constructor(
|
||||||
private val appInfoProvider: AppInfoProvider,
|
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
|
val deviceMetadata: DeviceMetadata
|
||||||
get() = DeviceMetadata(
|
get() = DeviceMetadata(
|
||||||
deviceModel = appInfoProvider.device,
|
deviceModel = appInfoProvider.device,
|
||||||
|
|
@ -25,7 +25,7 @@ internal class SignedRequestPayload @Inject constructor(
|
||||||
os = appInfoProvider.platform.lowercase(),
|
os = appInfoProvider.platform.lowercase(),
|
||||||
osVersion = appInfoProvider.osVersion,
|
osVersion = appInfoProvider.osVersion,
|
||||||
appVersion = appInfoProvider.appVersion,
|
appVersion = appInfoProvider.appVersion,
|
||||||
userAgent = null,
|
userAgent = with(appInfoProvider) { "Tangem/$appVersion ($device; $platform $osVersion)" },
|
||||||
locale = appInfoProvider.language,
|
locale = appInfoProvider.language,
|
||||||
timezone = appInfoProvider.timezone,
|
timezone = appInfoProvider.timezone,
|
||||||
)
|
)
|
||||||
|
|
@ -49,9 +49,7 @@ internal class SignedRequestPayload @Inject constructor(
|
||||||
/**
|
/**
|
||||||
* Stable, newline-separated representation of the signed payload. Backend treats the bytes
|
* 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
|
* opaquely; must stay aligned with the server-side canonicalisation. Field order matches the
|
||||||
* declaration order of [RegisterPayload] / [AuthenticationPayload], with one exception:
|
* declaration order of [RegisterPayload] / [AuthenticationPayload] and [DeviceMetadata].
|
||||||
* [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(
|
private fun canonicalize(
|
||||||
devicePublicKey: String,
|
devicePublicKey: String,
|
||||||
|
|
@ -62,12 +60,13 @@ internal class SignedRequestPayload @Inject constructor(
|
||||||
append(devicePublicKey).append('\n')
|
append(devicePublicKey).append('\n')
|
||||||
append(nonce).append('\n')
|
append(nonce).append('\n')
|
||||||
append(attestationToken.orEmpty()).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.os).append('\n')
|
||||||
append(metadata.osVersion.orEmpty()).append('\n')
|
append(metadata.osVersion).append('\n')
|
||||||
append(metadata.appVersion.orEmpty()).append('\n')
|
append(metadata.appVersion).append('\n')
|
||||||
append(metadata.locale.orEmpty()).append('\n')
|
append(metadata.userAgent).append('\n')
|
||||||
append(metadata.timezone.orEmpty())
|
append(metadata.locale).append('\n')
|
||||||
|
append(metadata.timezone)
|
||||||
}.toByteArray(Charsets.UTF_8)
|
}.toByteArray(Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,14 @@ class AuthErrorConverterTest {
|
||||||
assertThat(result).isInstanceOf(AuthError.NotFound::class.java)
|
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
|
@Test
|
||||||
fun `429 surfaces retryAfterSeconds from problem`() {
|
fun `429 surfaces retryAfterSeconds from problem`() {
|
||||||
val rateLimitBody = """
|
val rateLimitBody = """
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import arrow.core.Some
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.tangem.datasource.api.auth.AuthApi
|
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.RegisterApiRequest
|
||||||
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
|
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
|
||||||
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||||
|
|
@ -89,7 +88,7 @@ class DefaultDeviceRegistrarTest {
|
||||||
|
|
||||||
assertThat(result.isRight()).isTrue()
|
assertThat(result.isRight()).isTrue()
|
||||||
coVerify { authApi.requestDeviceNonce(any()) }
|
coVerify { authApi.requestDeviceNonce(any()) }
|
||||||
coVerify { authApi.register(any<RegisterApiRequest>()) }
|
coVerify { authApi.registerDevice(any<RegisterApiRequest>()) }
|
||||||
coVerify { store.save(any()) }
|
coVerify { store.save(any()) }
|
||||||
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isTrue()
|
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +101,7 @@ class DefaultDeviceRegistrarTest {
|
||||||
|
|
||||||
assertThat(result.isRight()).isTrue()
|
assertThat(result.isRight()).isTrue()
|
||||||
coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) }
|
coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) }
|
||||||
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
|
coVerify(exactly = 0) { authApi.registerDevice(any<RegisterApiRequest>()) }
|
||||||
coVerify(exactly = 0) { store.save(any()) }
|
coVerify(exactly = 0) { store.save(any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,7 +113,7 @@ class DefaultDeviceRegistrarTest {
|
||||||
|
|
||||||
assertThat(result.leftOrNull()).isEqualTo(DeviceRegistrationError.DeviceKeyUnavailable)
|
assertThat(result.leftOrNull()).isEqualTo(DeviceRegistrationError.DeviceKeyUnavailable)
|
||||||
coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) }
|
coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) }
|
||||||
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
|
coVerify(exactly = 0) { authApi.registerDevice(any<RegisterApiRequest>()) }
|
||||||
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
|
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,7 +132,7 @@ class DefaultDeviceRegistrarTest {
|
||||||
val result = registrar.register()
|
val result = registrar.register()
|
||||||
|
|
||||||
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.Api::class.java)
|
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.Api::class.java)
|
||||||
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
|
coVerify(exactly = 0) { authApi.registerDevice(any<RegisterApiRequest>()) }
|
||||||
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
|
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +147,7 @@ class DefaultDeviceRegistrarTest {
|
||||||
val result = registrar.register()
|
val result = registrar.register()
|
||||||
|
|
||||||
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.NonceDecryptionFailed::class.java)
|
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.NonceDecryptionFailed::class.java)
|
||||||
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
|
coVerify(exactly = 0) { authApi.registerDevice(any<RegisterApiRequest>()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -163,7 +162,7 @@ class DefaultDeviceRegistrarTest {
|
||||||
val result = registrar.register()
|
val result = registrar.register()
|
||||||
|
|
||||||
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.SigningFailed::class.java)
|
assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.SigningFailed::class.java)
|
||||||
coVerify(exactly = 0) { authApi.register(any<RegisterApiRequest>()) }
|
coVerify(exactly = 0) { authApi.registerDevice(any<RegisterApiRequest>()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -175,7 +174,7 @@ class DefaultDeviceRegistrarTest {
|
||||||
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
|
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
|
||||||
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
|
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
coEvery { authApi.register(any<RegisterApiRequest>()) } returns ApiResponse.Error(
|
coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Error(
|
||||||
cause = ApiResponseError.HttpException(
|
cause = ApiResponseError.HttpException(
|
||||||
code = ApiResponseError.HttpException.Code.FORBIDDEN,
|
code = ApiResponseError.HttpException.Code.FORBIDDEN,
|
||||||
message = "already registered",
|
message = "already registered",
|
||||||
|
|
@ -190,6 +189,31 @@ class DefaultDeviceRegistrarTest {
|
||||||
assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull()
|
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<RegisterApiRequest>()) } returns ApiResponse.Error(
|
||||||
|
cause = ApiResponseError.HttpException(
|
||||||
|
code = ApiResponseError.HttpException.Code.CONFLICT,
|
||||||
|
message = "device already registered",
|
||||||
|
errorBody = null,
|
||||||
|
),
|
||||||
|
) as ApiResponse<TokenApiResponse>
|
||||||
|
|
||||||
|
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
|
@Test
|
||||||
fun `register returns PersistenceFailed when SessionTokensStore_save throws`() = runTest {
|
fun `register returns PersistenceFailed when SessionTokensStore_save throws`() = runTest {
|
||||||
stubHappyPath()
|
stubHappyPath()
|
||||||
|
|
@ -209,7 +233,7 @@ class DefaultDeviceRegistrarTest {
|
||||||
)
|
)
|
||||||
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
|
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
|
||||||
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
|
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64)
|
||||||
coEvery { authApi.register(any<RegisterApiRequest>()) } returns ApiResponse.Success(
|
coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Success(
|
||||||
data = TokenApiResponse(
|
data = TokenApiResponse(
|
||||||
accessToken = "fresh-access",
|
accessToken = "fresh-access",
|
||||||
accessTokenExpiresAt = "2024-01-01T00:00:00Z",
|
accessTokenExpiresAt = "2024-01-01T00:00:00Z",
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ class DefaultSessionTokenRefresherTest {
|
||||||
assertThat(tokens.refreshToken).isEqualTo("rt-2")
|
assertThat(tokens.refreshToken).isEqualTo("rt-2")
|
||||||
assertThat(tokens.walletIds).containsExactly("w1", "w2")
|
assertThat(tokens.walletIds).containsExactly("w1", "w2")
|
||||||
coVerify { store.save(tokens) }
|
coVerify { store.save(tokens) }
|
||||||
|
coVerify(exactly = 0) { authApi.authenticate(any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -133,6 +134,34 @@ class DefaultSessionTokenRefresherTest {
|
||||||
coVerify { authApi.authenticate(any()) }
|
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<TokenApiResponse>
|
||||||
|
|
||||||
|
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
|
@Test
|
||||||
fun `refresh clears store when authenticate returns 403`() = runTest {
|
fun `refresh clears store when authenticate returns 403`() = runTest {
|
||||||
coEvery { store.get() } returns None
|
coEvery { store.get() } returns None
|
||||||
|
|
@ -159,6 +188,17 @@ class DefaultSessionTokenRefresherTest {
|
||||||
coVerify { store.clear() }
|
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
|
@Test
|
||||||
fun `concurrent callers share a single refresh round-trip — both get same result`() = runTest {
|
fun `concurrent callers share a single refresh round-trip — both get same result`() = runTest {
|
||||||
val stored = SessionTokens(
|
val stored = SessionTokens(
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class SignedRequestPayloadTest {
|
||||||
private val signedRequestPayload = SignedRequestPayload(appInfoProvider)
|
private val signedRequestPayload = SignedRequestPayload(appInfoProvider)
|
||||||
|
|
||||||
@Test
|
@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
|
val metadata = signedRequestPayload.deviceMetadata
|
||||||
|
|
||||||
// Backend contract is lowercase `android`/`ios` — verify normalization at the source.
|
// Backend contract is lowercase `android`/`ios` — verify normalization at the source.
|
||||||
|
|
@ -35,7 +35,7 @@ class SignedRequestPayloadTest {
|
||||||
os = "android",
|
os = "android",
|
||||||
osVersion = "14",
|
osVersion = "14",
|
||||||
appVersion = "5.40.0",
|
appVersion = "5.40.0",
|
||||||
userAgent = null,
|
userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)",
|
||||||
locale = "en-US",
|
locale = "en-US",
|
||||||
timezone = "Europe/Moscow",
|
timezone = "Europe/Moscow",
|
||||||
),
|
),
|
||||||
|
|
@ -49,7 +49,7 @@ class SignedRequestPayloadTest {
|
||||||
os = "Android",
|
os = "Android",
|
||||||
osVersion = "14",
|
osVersion = "14",
|
||||||
appVersion = "5.40.0",
|
appVersion = "5.40.0",
|
||||||
userAgent = null,
|
userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)",
|
||||||
locale = "en-US",
|
locale = "en-US",
|
||||||
timezone = "Europe/Moscow",
|
timezone = "Europe/Moscow",
|
||||||
)
|
)
|
||||||
|
|
@ -71,6 +71,7 @@ class SignedRequestPayloadTest {
|
||||||
Android
|
Android
|
||||||
14
|
14
|
||||||
5.40.0
|
5.40.0
|
||||||
|
Tangem/5.40.0 (Pixel 8; Android 14)
|
||||||
en-US
|
en-US
|
||||||
Europe/Moscow
|
Europe/Moscow
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
|
|
@ -78,15 +79,15 @@ class SignedRequestPayloadTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `canonicalize replaces null fields with empty string`() {
|
fun `canonicalize replaces null attestationToken with empty string`() {
|
||||||
val metadata = DeviceMetadata(
|
val metadata = DeviceMetadata(
|
||||||
deviceModel = null,
|
deviceModel = "Pixel 8",
|
||||||
os = "Android",
|
os = "Android",
|
||||||
osVersion = null,
|
osVersion = "14",
|
||||||
appVersion = null,
|
appVersion = "5.40.0",
|
||||||
userAgent = null,
|
userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)",
|
||||||
locale = null,
|
locale = "en-US",
|
||||||
timezone = null,
|
timezone = "Europe/Moscow",
|
||||||
)
|
)
|
||||||
val payload = RegisterPayload(
|
val payload = RegisterPayload(
|
||||||
devicePublicKey = "pub",
|
devicePublicKey = "pub",
|
||||||
|
|
@ -97,8 +98,10 @@ class SignedRequestPayloadTest {
|
||||||
|
|
||||||
val bytes = signedRequestPayload.canonicalize(payload)
|
val bytes = signedRequestPayload.canonicalize(payload)
|
||||||
|
|
||||||
// 8 newlines separate 9 logical slots; all but `devicePublicKey`, `nonce`, and `os` are empty.
|
// The null attestationToken collapses to an empty slot between `nonce` and `deviceModel`.
|
||||||
assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo("pub\nnonce-1\n\n\nAndroid\n\n\n\n")
|
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
|
@Test
|
||||||
|
|
@ -110,7 +113,7 @@ class SignedRequestPayloadTest {
|
||||||
os = "Android",
|
os = "Android",
|
||||||
osVersion = "14",
|
osVersion = "14",
|
||||||
appVersion = "5.40.0",
|
appVersion = "5.40.0",
|
||||||
userAgent = null,
|
userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)",
|
||||||
locale = "en-US",
|
locale = "en-US",
|
||||||
timezone = "Europe/Moscow",
|
timezone = "Europe/Moscow",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue