Updated on 2026-08-14
This commit is contained in:
parent
0ee2f48898
commit
5ed229f6ed
15 changed files with 910 additions and 0 deletions
|
|
@ -2,6 +2,7 @@ plugins {
|
||||||
alias(deps.plugins.android.library)
|
alias(deps.plugins.android.library)
|
||||||
alias(deps.plugins.kotlin.android)
|
alias(deps.plugins.kotlin.android)
|
||||||
alias(deps.plugins.kotlin.kapt)
|
alias(deps.plugins.kotlin.kapt)
|
||||||
|
alias(deps.plugins.kotlin.serialization)
|
||||||
alias(deps.plugins.hilt.android)
|
alias(deps.plugins.hilt.android)
|
||||||
id("configuration")
|
id("configuration")
|
||||||
}
|
}
|
||||||
|
|
@ -17,10 +18,12 @@ tasks.withType<Test>().configureEach {
|
||||||
dependencies {
|
dependencies {
|
||||||
/** Core */
|
/** Core */
|
||||||
implementation(projects.core.configToggles)
|
implementation(projects.core.configToggles)
|
||||||
|
implementation(projects.core.datasource)
|
||||||
implementation(projects.core.utils)
|
implementation(projects.core.utils)
|
||||||
|
|
||||||
/** Tangem libraries */
|
/** Tangem libraries */
|
||||||
implementation(tangemDeps.card.core)
|
implementation(tangemDeps.card.core)
|
||||||
|
implementation(tangemDeps.card.android)
|
||||||
|
|
||||||
/** Firebase */
|
/** Firebase */
|
||||||
implementation(platform(deps.firebase.bom))
|
implementation(platform(deps.firebase.bom))
|
||||||
|
|
@ -28,6 +31,11 @@ dependencies {
|
||||||
|
|
||||||
/** Other */
|
/** Other */
|
||||||
implementation(deps.arrow.core)
|
implementation(deps.arrow.core)
|
||||||
|
implementation(deps.kotlin.datetime)
|
||||||
|
implementation(deps.kotlin.serialization)
|
||||||
|
implementation(deps.moshi)
|
||||||
|
implementation(deps.okHttp)
|
||||||
|
implementation(deps.retrofit)
|
||||||
|
|
||||||
/** DI */
|
/** DI */
|
||||||
implementation(deps.hilt.android)
|
implementation(deps.hilt.android)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
package com.tangem.lib.auth.di
|
package com.tangem.lib.auth.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.tangem.common.services.secure.SecureStorage
|
||||||
|
import com.tangem.datasource.di.NetworkMoshi
|
||||||
import com.tangem.lib.auth.AuthFeatureToggles
|
import com.tangem.lib.auth.AuthFeatureToggles
|
||||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||||
import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager
|
import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager
|
||||||
import com.tangem.lib.auth.devicekey.internal.DisabledDeviceKeyManager
|
import com.tangem.lib.auth.devicekey.internal.DisabledDeviceKeyManager
|
||||||
|
import com.tangem.lib.auth.dpop.DpopProofFactory
|
||||||
|
import com.tangem.lib.auth.dpop.internal.DefaultDpopProofFactory
|
||||||
|
import com.tangem.lib.auth.dpop.internal.DisabledDpopProofFactory
|
||||||
|
import com.tangem.lib.auth.http.DpopAuthorizationInterceptor
|
||||||
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
||||||
import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor
|
import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor
|
||||||
import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor
|
import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor
|
||||||
|
import com.tangem.lib.auth.session.SessionTokensStore
|
||||||
|
import com.tangem.lib.auth.session.internal.DefaultSessionTokensStore
|
||||||
|
import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore
|
||||||
|
import com.tangem.sdk.storage.AndroidSecureStorageV2
|
||||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
import com.tangem.utils.logging.TangemLogger
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import javax.inject.Named
|
import javax.inject.Named
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -58,4 +73,52 @@ internal object AuthModule {
|
||||||
DisabledAuthNonceDecryptor
|
DisabledAuthNonceDecryptor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSessionTokensStore(
|
||||||
|
authFeatureToggles: AuthFeatureToggles,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
@NetworkMoshi moshi: Moshi,
|
||||||
|
dispatchers: CoroutineDispatcherProvider,
|
||||||
|
): SessionTokensStore {
|
||||||
|
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledSessionTokensStore
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val storage: SecureStorage = AndroidSecureStorageV2(
|
||||||
|
appContext = context,
|
||||||
|
useStrongBox = false,
|
||||||
|
name = "tangem_session_tokens",
|
||||||
|
)
|
||||||
|
DefaultSessionTokensStore(storage, moshi, dispatchers)
|
||||||
|
}.getOrElse { e ->
|
||||||
|
TangemLogger.e("Failed to init DefaultSessionTokensStore, falling back to disabled store", e)
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(e)
|
||||||
|
DisabledSessionTokensStore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDpopProofFactory(
|
||||||
|
authFeatureToggles: AuthFeatureToggles,
|
||||||
|
deviceKeyManager: DeviceKeyManager,
|
||||||
|
dispatchers: CoroutineDispatcherProvider,
|
||||||
|
): DpopProofFactory {
|
||||||
|
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledDpopProofFactory
|
||||||
|
|
||||||
|
return DefaultDpopProofFactory(
|
||||||
|
deviceKeyManager = deviceKeyManager,
|
||||||
|
json = Json.Default,
|
||||||
|
clock = Clock.System,
|
||||||
|
dispatchers = dispatchers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDpopAuthorizationInterceptor(
|
||||||
|
store: SessionTokensStore,
|
||||||
|
proofFactory: DpopProofFactory,
|
||||||
|
): DpopAuthorizationInterceptor = DpopAuthorizationInterceptor(store, proofFactory)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.tangem.lib.auth.dpop
|
||||||
|
|
||||||
|
import arrow.core.Option
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP proofs (JWS) for outgoing
|
||||||
|
* HTTP requests. Each proof is bound to a single request — `htm` / `htu` / `ath` claims must
|
||||||
|
* not be reused, and `jti` is a fresh UUID per invocation.
|
||||||
|
*/
|
||||||
|
interface DpopProofFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a DPoP-proof for the given request.
|
||||||
|
*
|
||||||
|
* @param httpMethod uppercase HTTP method (e.g. `"POST"`).
|
||||||
|
* @param httpUri target URI **without** query and fragment (RFC 9449 §4.2).
|
||||||
|
* @param accessToken access token bound to this proof; when present, the SHA-256 hash
|
||||||
|
* is included as `ath` claim (RFC 9449 §4.3). Pass `null` for unauthenticated
|
||||||
|
* requests (initial registration, `/authenticate`) or `/refresh` where the access
|
||||||
|
* token has already expired (RFC 9449 §5).
|
||||||
|
* @return compact-serialised JWS suitable for the `DPoP:` header, or [arrow.core.None]
|
||||||
|
* when the device key is unavailable or signing fails.
|
||||||
|
*/
|
||||||
|
suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
package com.tangem.lib.auth.dpop.internal
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import arrow.core.None
|
||||||
|
import arrow.core.Option
|
||||||
|
import arrow.core.Some
|
||||||
|
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||||
|
import com.tangem.lib.auth.dpop.DpopProofFactory
|
||||||
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
internal class DefaultDpopProofFactory(
|
||||||
|
private val deviceKeyManager: DeviceKeyManager,
|
||||||
|
private val json: Json,
|
||||||
|
private val clock: Clock,
|
||||||
|
private val dispatchers: CoroutineDispatcherProvider,
|
||||||
|
) : DpopProofFactory {
|
||||||
|
|
||||||
|
override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String> =
|
||||||
|
withContext(dispatchers.default) {
|
||||||
|
val publicKey = deviceKeyManager.getPublicKey().getOrNull()
|
||||||
|
|
||||||
|
if (publicKey == null) {
|
||||||
|
TangemLogger.e("DPoP proof skipped: device key unavailable")
|
||||||
|
return@withContext None
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceKeyManager.getPublicKey() guarantees an uncompressed P-256 point
|
||||||
|
// (0x04 || X(32) || Y(32)) — see DefaultDeviceKeyManager.getPublicKeyBytes.
|
||||||
|
val x = publicKey.copyOfRange(fromIndex = 1, toIndex = 1 + COORDINATE_SIZE)
|
||||||
|
val y = publicKey.copyOfRange(fromIndex = 1 + COORDINATE_SIZE, toIndex = 1 + 2 * COORDINATE_SIZE)
|
||||||
|
|
||||||
|
val header: JsonObject = buildJsonObject {
|
||||||
|
put("alg", ES256_ALG)
|
||||||
|
put("typ", DPOP_TYP)
|
||||||
|
putJsonObject("jwk") {
|
||||||
|
put("kty", EC_KTY)
|
||||||
|
put("crv", P256_CRV)
|
||||||
|
put("x", x.base64UrlNoPad())
|
||||||
|
put("y", y.base64UrlNoPad())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val claims: JsonObject = buildJsonObject {
|
||||||
|
put("jti", UUID.randomUUID().toString())
|
||||||
|
put("iat", clock.now().epochSeconds)
|
||||||
|
put("htm", httpMethod.uppercase())
|
||||||
|
put("htu", stripQueryAndFragment(httpUri))
|
||||||
|
if (accessToken != null) {
|
||||||
|
put("ath", sha256(accessToken.toByteArray(Charsets.US_ASCII)).base64UrlNoPad())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val signingInput = json.encodeToString(JsonObject.serializer(), header)
|
||||||
|
.toByteArray(Charsets.UTF_8).base64UrlNoPad() +
|
||||||
|
"." +
|
||||||
|
json.encodeToString(JsonObject.serializer(), claims)
|
||||||
|
.toByteArray(Charsets.UTF_8).base64UrlNoPad()
|
||||||
|
|
||||||
|
val signature = try {
|
||||||
|
deviceKeyManager.sign(signingInput.toByteArray(Charsets.US_ASCII))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TangemLogger.e("Failed to sign DPoP proof", e)
|
||||||
|
return@withContext None
|
||||||
|
}
|
||||||
|
|
||||||
|
Some("$signingInput.${signature.base64UrlNoPad()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256(bytes: ByteArray): ByteArray {
|
||||||
|
return MessageDigest.getInstance(SHA_256).digest(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.base64UrlNoPad(): String =
|
||||||
|
Base64.encodeToString(this, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes query and fragment without touching scheme/authority/path encoding.
|
||||||
|
* `java.net.URI.path` would decode percent-encoded bytes (e.g. `%2F` → `/`), which would
|
||||||
|
* make the `htu` claim diverge from the wire URI and fail DPoP verification.
|
||||||
|
*/
|
||||||
|
private fun stripQueryAndFragment(uri: String): String = uri.substringBefore('#').substringBefore('?')
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ES256_ALG = "ES256"
|
||||||
|
const val DPOP_TYP = "dpop+jwt"
|
||||||
|
const val EC_KTY = "EC"
|
||||||
|
const val P256_CRV = "P-256"
|
||||||
|
const val SHA_256 = "SHA-256"
|
||||||
|
const val COORDINATE_SIZE = 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.tangem.lib.auth.dpop.internal
|
||||||
|
|
||||||
|
import arrow.core.None
|
||||||
|
import arrow.core.Option
|
||||||
|
import com.tangem.lib.auth.dpop.DpopProofFactory
|
||||||
|
|
||||||
|
internal object DisabledDpopProofFactory : DpopProofFactory {
|
||||||
|
|
||||||
|
override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String> = None
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.tangem.lib.auth.http
|
||||||
|
|
||||||
|
import com.tangem.datasource.api.auth.RequiresSessionAuth
|
||||||
|
import com.tangem.lib.auth.dpop.DpopProofFactory
|
||||||
|
import com.tangem.lib.auth.session.SessionTokensStore
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import retrofit2.Invocation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP headers to requests whose
|
||||||
|
* Retrofit method is marked with [RequiresSessionAuth]:
|
||||||
|
* - `Authorization: DPoP <access-token>` — present if [SessionTokensStore] holds an access token.
|
||||||
|
* - `DPoP: <proof-jwt>` — freshly generated for every annotated request; `ath` claim is set if
|
||||||
|
* the access token is present.
|
||||||
|
*
|
||||||
|
* Methods **without** the annotation pass through unchanged — keeps public endpoints
|
||||||
|
* (e.g. `/auth/nonce/auth`, `/auth/authenticate`) free of unnecessary proof generation.
|
||||||
|
*
|
||||||
|
* On unrecoverable proof-generation failures (e.g. device key unavailable) the request is passed
|
||||||
|
* through unmodified — the upstream HTTP layer will surface the resulting 401/403 and the
|
||||||
|
* `SessionAuthenticator` (if installed) will attempt recovery.
|
||||||
|
*/
|
||||||
|
class DpopAuthorizationInterceptor(
|
||||||
|
private val store: SessionTokensStore,
|
||||||
|
private val proofFactory: DpopProofFactory,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val original = chain.request()
|
||||||
|
|
||||||
|
if (!original.requiresSessionAuth()) return chain.proceed(original)
|
||||||
|
|
||||||
|
val accessToken = runBlocking { store.get().getOrNull()?.accessToken }
|
||||||
|
if (accessToken == null) {
|
||||||
|
// Annotated endpoint reached without a session — let the upstream HTTP layer surface
|
||||||
|
// the resulting 401 so `SessionAuthenticator` can drive recovery.
|
||||||
|
TangemLogger.e("Skipping DPoP headers: no access token in store")
|
||||||
|
return chain.proceed(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
val proof = runBlocking {
|
||||||
|
proofFactory.create(original.method, original.url.toString(), accessToken)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (proof == null) {
|
||||||
|
TangemLogger.e("DPoP proof generation failed; sending request without DPoP headers")
|
||||||
|
return chain.proceed(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain.proceed(
|
||||||
|
original.newBuilder()
|
||||||
|
.header(HEADER_AUTHORIZATION, "$DPOP_SCHEME $accessToken")
|
||||||
|
.header(HEADER_DPOP, proof)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Request.requiresSessionAuth(): Boolean =
|
||||||
|
tag(Invocation::class.java)?.method()?.isAnnotationPresent(RequiresSessionAuth::class.java) == true
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val HEADER_AUTHORIZATION = "Authorization"
|
||||||
|
const val HEADER_DPOP = "DPoP"
|
||||||
|
const val DPOP_SCHEME = "DPoP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.tangem.lib.auth.session
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT session tokens issued by the Tangem Auth Service — pure domain model.
|
||||||
|
*
|
||||||
|
* Persisted on the device via [SessionTokensStore]. The default implementation serialises a
|
||||||
|
* storage DTO mirroring the wire format (`TokenApiResponse`) into AES-256-GCM-encrypted local
|
||||||
|
* storage (`SecureStorage` / `AndroidSecureStorageV2`) with the master key residing in
|
||||||
|
* AndroidKeystore. Survives app process death but not user data wipe / app uninstall.
|
||||||
|
*
|
||||||
|
* The domain class itself carries no serialization annotations: it can grow with business
|
||||||
|
* helpers (`isAccessTokenExpired`, computed properties, etc.) without touching the on-disk
|
||||||
|
* format.
|
||||||
|
*
|
||||||
|
* @property accessToken short-lived signed JWT (verified via JWKS at API Gateway). Sent as
|
||||||
|
* `Authorization: DPoP <accessToken>` on every authenticated request.
|
||||||
|
* @property refreshToken opaque rotation token. `null` for ORANGE-tier sessions (require full
|
||||||
|
* re-authentication for every new access token — see SR-8 / token policy by trust tier).
|
||||||
|
* @property refreshTokenExpiresAt `null` if [refreshToken] is `null`.
|
||||||
|
* @property walletIds wallet ids bound to the device by the backend, mirrored from token claims
|
||||||
|
* to avoid parsing the JWT on the client.
|
||||||
|
*/
|
||||||
|
data class SessionTokens(
|
||||||
|
val accessToken: String,
|
||||||
|
val accessTokenExpiresAt: Instant,
|
||||||
|
val refreshToken: String?,
|
||||||
|
val refreshTokenExpiresAt: Instant?,
|
||||||
|
val walletIds: List<String>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.tangem.lib.auth.session
|
||||||
|
|
||||||
|
import arrow.core.Option
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent, hardware-backed storage of [SessionTokens].
|
||||||
|
*
|
||||||
|
* Implementations are expected to survive app process death but not user data wipe / app uninstall.
|
||||||
|
*/
|
||||||
|
interface SessionTokensStore {
|
||||||
|
|
||||||
|
/** Returns the currently stored tokens, or [arrow.core.None] if the device is not authenticated. */
|
||||||
|
suspend fun get(): Option<SessionTokens>
|
||||||
|
|
||||||
|
/** Atomically replaces the stored tokens. */
|
||||||
|
suspend fun save(tokens: SessionTokens)
|
||||||
|
|
||||||
|
/** Removes stored tokens. */
|
||||||
|
suspend fun clear()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
package com.tangem.lib.auth.session.internal
|
||||||
|
|
||||||
|
import arrow.core.None
|
||||||
|
import arrow.core.Option
|
||||||
|
import arrow.core.Some
|
||||||
|
import com.squareup.moshi.JsonAdapter
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.tangem.common.services.secure.SecureStorage
|
||||||
|
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||||
|
import com.tangem.lib.auth.session.SessionTokens
|
||||||
|
import com.tangem.lib.auth.session.SessionTokensStore
|
||||||
|
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores tokens in [SecureStorage] using the wire-format [TokenApiResponse] as the on-disk
|
||||||
|
* DTO — keeps the storage layout in lockstep with the Auth Service contract while
|
||||||
|
* isolating the [SessionTokens] domain model from serialization concerns.
|
||||||
|
*
|
||||||
|
* The underlying `AndroidSecureStorageV2` wraps `SharedPreferences` with an AES-256-GCM
|
||||||
|
* cipher whose key lives in AndroidKeystore, so token blobs are encrypted at rest and only
|
||||||
|
* decryptable on this device.
|
||||||
|
*/
|
||||||
|
internal class DefaultSessionTokensStore(
|
||||||
|
private val storage: SecureStorage,
|
||||||
|
private val moshi: Moshi,
|
||||||
|
private val dispatchers: CoroutineDispatcherProvider,
|
||||||
|
) : SessionTokensStore {
|
||||||
|
|
||||||
|
private val adapter: JsonAdapter<TokenApiResponse> by lazy {
|
||||||
|
moshi.adapter(TokenApiResponse::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(): Option<SessionTokens> = withContext(dispatchers.io) {
|
||||||
|
val payload = storage.getAsString(KEY) ?: return@withContext None
|
||||||
|
try {
|
||||||
|
val dto = adapter.fromJson(payload) ?: return@withContext None
|
||||||
|
Some(SessionTokensConverter.convertBack(dto))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TangemLogger.e("Failed to decode session tokens; clearing storage", e)
|
||||||
|
storage.delete(KEY)
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(tokens: SessionTokens) {
|
||||||
|
withContext(dispatchers.io) {
|
||||||
|
storage.store(KEY, adapter.toJson(SessionTokensConverter.convert(tokens)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear() {
|
||||||
|
withContext(dispatchers.io) {
|
||||||
|
storage.delete(KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val KEY = "session_tokens"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.tangem.lib.auth.session.internal
|
||||||
|
|
||||||
|
import arrow.core.None
|
||||||
|
import arrow.core.Option
|
||||||
|
import com.tangem.lib.auth.session.SessionTokens
|
||||||
|
import com.tangem.lib.auth.session.SessionTokensStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op fallback used when the backend-authentication feature toggle is off
|
||||||
|
* or the encrypted storage failed to initialise.
|
||||||
|
*/
|
||||||
|
internal object DisabledSessionTokensStore : SessionTokensStore {
|
||||||
|
|
||||||
|
override suspend fun get(): Option<SessionTokens> = None
|
||||||
|
|
||||||
|
override suspend fun save(tokens: SessionTokens) = Unit
|
||||||
|
|
||||||
|
override suspend fun clear() = Unit
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.tangem.lib.auth.session.internal
|
||||||
|
|
||||||
|
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||||
|
import com.tangem.lib.auth.session.SessionTokens
|
||||||
|
import com.tangem.utils.converter.TwoWayConverter
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps between the [SessionTokens] domain model and the [TokenApiResponse] wire/storage DTO.
|
||||||
|
* `convert` produces the on-disk / on-wire shape; `convertBack` parses ISO-8601 timestamps
|
||||||
|
* into [kotlinx.datetime.Instant].
|
||||||
|
*/
|
||||||
|
internal object SessionTokensConverter : TwoWayConverter<SessionTokens, TokenApiResponse> {
|
||||||
|
|
||||||
|
override fun convert(value: SessionTokens): TokenApiResponse = TokenApiResponse(
|
||||||
|
accessToken = value.accessToken,
|
||||||
|
accessTokenExpiresAt = value.accessTokenExpiresAt.toString(),
|
||||||
|
refreshToken = value.refreshToken,
|
||||||
|
refreshTokenExpiresAt = value.refreshTokenExpiresAt?.toString(),
|
||||||
|
walletIds = value.walletIds,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun convertBack(value: TokenApiResponse): SessionTokens = SessionTokens(
|
||||||
|
accessToken = value.accessToken,
|
||||||
|
accessTokenExpiresAt = Instant.parse(value.accessTokenExpiresAt),
|
||||||
|
refreshToken = value.refreshToken,
|
||||||
|
refreshTokenExpiresAt = value.refreshTokenExpiresAt?.let(Instant::parse),
|
||||||
|
walletIds = value.walletIds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
package com.tangem.lib.auth.dpop.internal
|
||||||
|
|
||||||
|
import arrow.core.None
|
||||||
|
import arrow.core.Some
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||||
|
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
class DefaultDpopProofFactoryTest {
|
||||||
|
|
||||||
|
private val dispatchers = TestingCoroutineDispatcherProvider()
|
||||||
|
private val deviceKeyManager: DeviceKeyManager = mockk()
|
||||||
|
private val json = Json.Default
|
||||||
|
|
||||||
|
// Fixed P-256 public key (uncompressed): 0x04 || X(32) || Y(32). Values are arbitrary but
|
||||||
|
// span both halves so any off-by-one slice mistake is caught.
|
||||||
|
private val devicePublicKey: ByteArray = byteArrayOf(0x04) +
|
||||||
|
ByteArray(COORDINATE_SIZE) { it.toByte() } +
|
||||||
|
ByteArray(COORDINATE_SIZE) { (it + COORDINATE_SIZE).toByte() }
|
||||||
|
|
||||||
|
private val signatureBytes: ByteArray = ByteArray(SIGNATURE_SIZE) { (it + 1).toByte() }
|
||||||
|
|
||||||
|
private val fixedInstant = Instant.fromEpochSeconds(1_700_000_000)
|
||||||
|
private val fixedJti = UUID.fromString("11111111-2222-3333-4444-555555555555")
|
||||||
|
|
||||||
|
private lateinit var factory: DefaultDpopProofFactory
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
// android.util.Base64 → java.util.Base64
|
||||||
|
mockkStatic(android.util.Base64::class)
|
||||||
|
every { android.util.Base64.encodeToString(any(), any()) } answers {
|
||||||
|
val bytes = firstArg<ByteArray>()
|
||||||
|
val flags = secondArg<Int>()
|
||||||
|
val padded = flags and android.util.Base64.NO_PADDING == 0
|
||||||
|
val encoder = if (flags and android.util.Base64.URL_SAFE != 0) {
|
||||||
|
if (padded) Base64.getUrlEncoder() else Base64.getUrlEncoder().withoutPadding()
|
||||||
|
} else {
|
||||||
|
Base64.getEncoder()
|
||||||
|
}
|
||||||
|
encoder.encodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
mockkStatic(UUID::class)
|
||||||
|
every { UUID.randomUUID() } returns fixedJti
|
||||||
|
|
||||||
|
coEvery { deviceKeyManager.getPublicKey() } returns Some(devicePublicKey)
|
||||||
|
coEvery { deviceKeyManager.sign(any()) } returns signatureBytes
|
||||||
|
|
||||||
|
factory = DefaultDpopProofFactory(
|
||||||
|
deviceKeyManager = deviceKeyManager,
|
||||||
|
json = json,
|
||||||
|
clock = object : Clock { override fun now(): Instant = fixedInstant },
|
||||||
|
dispatchers = dispatchers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun teardown() = unmockkAll()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create produces JWS with ath when access token is provided`() = runTest {
|
||||||
|
val token = "header.payload.signature"
|
||||||
|
val proof = factory.create("post", "https://example.com/api/v1/auth/refresh?ignored=1#frag", token)
|
||||||
|
.getOrNull()!!
|
||||||
|
|
||||||
|
val parts = proof.split('.')
|
||||||
|
assertThat(parts).hasSize(3)
|
||||||
|
|
||||||
|
val header = decodeJsonObject(parts[0])
|
||||||
|
assertThat(header["alg"]?.jsonPrimitive?.contentOrNull).isEqualTo("ES256")
|
||||||
|
assertThat(header["typ"]?.jsonPrimitive?.contentOrNull).isEqualTo("dpop+jwt")
|
||||||
|
val jwk = header["jwk"]!!.jsonObject
|
||||||
|
assertThat(jwk["kty"]?.jsonPrimitive?.contentOrNull).isEqualTo("EC")
|
||||||
|
assertThat(jwk["crv"]?.jsonPrimitive?.contentOrNull).isEqualTo("P-256")
|
||||||
|
assertThat(jwk["x"]?.jsonPrimitive?.contentOrNull)
|
||||||
|
.isEqualTo(base64UrlNoPad(devicePublicKey.sliceArray(1..COORDINATE_SIZE)))
|
||||||
|
assertThat(jwk["y"]?.jsonPrimitive?.contentOrNull)
|
||||||
|
.isEqualTo(base64UrlNoPad(devicePublicKey.sliceArray(COORDINATE_SIZE + 1..2 * COORDINATE_SIZE)))
|
||||||
|
|
||||||
|
val claims = decodeJsonObject(parts[1])
|
||||||
|
assertThat(claims["jti"]?.jsonPrimitive?.contentOrNull).isEqualTo(fixedJti.toString())
|
||||||
|
assertThat(claims["iat"]?.jsonPrimitive?.longOrNull).isEqualTo(fixedInstant.epochSeconds)
|
||||||
|
assertThat(claims["htm"]?.jsonPrimitive?.contentOrNull).isEqualTo("POST")
|
||||||
|
assertThat(claims["htu"]?.jsonPrimitive?.contentOrNull).isEqualTo("https://example.com/api/v1/auth/refresh")
|
||||||
|
assertThat(claims["ath"]?.jsonPrimitive?.contentOrNull)
|
||||||
|
.isEqualTo(base64UrlNoPad(sha256(token.toByteArray(Charsets.US_ASCII))))
|
||||||
|
|
||||||
|
assertThat(parts[2]).isEqualTo(base64UrlNoPad(signatureBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create omits ath when access token is null`() = runTest {
|
||||||
|
val proof = factory.create("POST", "https://example.com/refresh", null).getOrNull()!!
|
||||||
|
|
||||||
|
val claims = decodeJsonObject(proof.split('.')[1])
|
||||||
|
assertThat(claims.containsKey("ath")).isFalse()
|
||||||
|
assertThat(claims["htm"]?.jsonPrimitive?.contentOrNull).isEqualTo("POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `htu preserves percent-encoded characters in path`() = runTest {
|
||||||
|
// DPoP verification is byte-sensitive: %2F must NOT be decoded to / in htu.
|
||||||
|
val proof = factory.create("GET", "https://api.example.com/wallet%2F123/sub?x=1", null).getOrNull()!!
|
||||||
|
|
||||||
|
val claims = decodeJsonObject(proof.split('.')[1])
|
||||||
|
assertThat(claims["htu"]?.jsonPrimitive?.contentOrNull)
|
||||||
|
.isEqualTo("https://api.example.com/wallet%2F123/sub")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create returns None when device key unavailable`() = runTest {
|
||||||
|
coEvery { deviceKeyManager.getPublicKey() } returns None
|
||||||
|
|
||||||
|
val result = factory.create("POST", "https://example.com", null)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create signs the b64u-encoded header dot payload`() = runTest {
|
||||||
|
factory.create("GET", "https://example.com", null)
|
||||||
|
|
||||||
|
// The signing input is `<header_b64>.<claims_b64>` — verify it has a dot separator and
|
||||||
|
// a non-empty header section.
|
||||||
|
coVerify {
|
||||||
|
deviceKeyManager.sign(match { bytes ->
|
||||||
|
val text = String(bytes, Charsets.US_ASCII)
|
||||||
|
text.contains('.') && text.substringBefore('.').isNotEmpty()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeJsonObject(b64: String): JsonObject =
|
||||||
|
json.parseToJsonElement(String(Base64.getUrlDecoder().decode(b64), Charsets.UTF_8)).jsonObject
|
||||||
|
|
||||||
|
private fun base64UrlNoPad(bytes: ByteArray): String =
|
||||||
|
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||||
|
|
||||||
|
private fun sha256(bytes: ByteArray): ByteArray =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val COORDINATE_SIZE = 32
|
||||||
|
const val SIGNATURE_SIZE = 64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
package com.tangem.lib.auth.http
|
||||||
|
|
||||||
|
import arrow.core.None
|
||||||
|
import arrow.core.Some
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.datasource.api.auth.RequiresSessionAuth
|
||||||
|
import com.tangem.lib.auth.dpop.DpopProofFactory
|
||||||
|
import com.tangem.lib.auth.session.SessionTokens
|
||||||
|
import com.tangem.lib.auth.session.SessionTokensStore
|
||||||
|
import io.mockk.CapturingSlot
|
||||||
|
import io.mockk.clearMocks
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Protocol
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
import retrofit2.Invocation
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
class DpopAuthorizationInterceptorTest {
|
||||||
|
|
||||||
|
private val store: SessionTokensStore = mockk()
|
||||||
|
private val proofFactory: DpopProofFactory = mockk()
|
||||||
|
|
||||||
|
private val interceptor = DpopAuthorizationInterceptor(store, proofFactory)
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
clearMocks(store, proofFactory)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val storedTokens = SessionTokens(
|
||||||
|
accessToken = "old-access",
|
||||||
|
accessTokenExpiresAt = Instant.fromEpochSeconds(1_700_000_000),
|
||||||
|
refreshToken = "rt",
|
||||||
|
refreshTokenExpiresAt = Instant.fromEpochSeconds(1_700_003_600),
|
||||||
|
walletIds = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `annotated request gets Authorization and DPoP headers`() {
|
||||||
|
coEvery { store.get() } returns Some(storedTokens)
|
||||||
|
coEvery { proofFactory.create(any(), any(), "old-access") } returns Some("proof-jwt")
|
||||||
|
|
||||||
|
val proceeded = slot<Request>()
|
||||||
|
val chain = chain(request(annotated = true), proceeded)
|
||||||
|
|
||||||
|
interceptor.intercept(chain)
|
||||||
|
|
||||||
|
assertThat(proceeded.captured.header("Authorization")).isEqualTo("DPoP old-access")
|
||||||
|
assertThat(proceeded.captured.header("DPoP")).isEqualTo("proof-jwt")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `annotated request without access token passes through unmodified`() {
|
||||||
|
coEvery { store.get() } returns None
|
||||||
|
|
||||||
|
val proceeded = slot<Request>()
|
||||||
|
val chain = chain(request(annotated = true), proceeded)
|
||||||
|
|
||||||
|
interceptor.intercept(chain)
|
||||||
|
|
||||||
|
assertThat(proceeded.captured.header("Authorization")).isNull()
|
||||||
|
assertThat(proceeded.captured.header("DPoP")).isNull()
|
||||||
|
coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unannotated request passes through unchanged — proof factory never invoked`() {
|
||||||
|
val original = request(annotated = false)
|
||||||
|
val proceeded = slot<Request>()
|
||||||
|
val chain = chain(original, proceeded)
|
||||||
|
|
||||||
|
interceptor.intercept(chain)
|
||||||
|
|
||||||
|
assertThat(proceeded.captured.header("Authorization")).isNull()
|
||||||
|
assertThat(proceeded.captured.header("DPoP")).isNull()
|
||||||
|
coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `request without Invocation tag (not via Retrofit) is treated as unannotated`() {
|
||||||
|
val original = Request.Builder().url("https://example.com/api/v1/foo").build()
|
||||||
|
val proceeded = slot<Request>()
|
||||||
|
val chain = chain(original, proceeded)
|
||||||
|
|
||||||
|
interceptor.intercept(chain)
|
||||||
|
|
||||||
|
assertThat(proceeded.captured.header("DPoP")).isNull()
|
||||||
|
coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `proof generation failure on annotated request passes through without headers`() {
|
||||||
|
coEvery { store.get() } returns Some(storedTokens)
|
||||||
|
coEvery { proofFactory.create(any(), any(), any()) } returns None
|
||||||
|
|
||||||
|
val proceeded = slot<Request>()
|
||||||
|
val chain = chain(request(annotated = true), proceeded)
|
||||||
|
|
||||||
|
interceptor.intercept(chain)
|
||||||
|
|
||||||
|
assertThat(proceeded.captured.header("Authorization")).isNull()
|
||||||
|
assertThat(proceeded.captured.header("DPoP")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(annotated: Boolean): Request {
|
||||||
|
val builder = Request.Builder().url("https://example.com/api/v1/foo")
|
||||||
|
builder.tag(Invocation::class.java, invocationWithAnnotation(annotated))
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invocationWithAnnotation(annotated: Boolean): Invocation {
|
||||||
|
val method = mockk<Method>()
|
||||||
|
every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns annotated
|
||||||
|
val invocation = mockk<Invocation>()
|
||||||
|
every { invocation.method() } returns method
|
||||||
|
return invocation
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chain(request: Request, captureSlot: CapturingSlot<Request>): Interceptor.Chain {
|
||||||
|
val response = Response.Builder()
|
||||||
|
.request(request)
|
||||||
|
.protocol(Protocol.HTTP_1_1)
|
||||||
|
.code(200)
|
||||||
|
.message("ok")
|
||||||
|
.build()
|
||||||
|
val chain = mockk<Interceptor.Chain>()
|
||||||
|
every { chain.request() } returns request
|
||||||
|
every { chain.proceed(capture(captureSlot)) } returns response
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package com.tangem.lib.auth.session.internal
|
||||||
|
|
||||||
|
import arrow.core.None
|
||||||
|
import arrow.core.Some
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.tangem.common.services.secure.SecureStorage
|
||||||
|
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||||
|
import com.tangem.lib.auth.session.SessionTokens
|
||||||
|
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
class DefaultSessionTokensStoreTest {
|
||||||
|
|
||||||
|
private val dispatchers = TestingCoroutineDispatcherProvider()
|
||||||
|
private val moshi = Moshi.Builder().build()
|
||||||
|
private val adapter = moshi.adapter(TokenApiResponse::class.java)
|
||||||
|
|
||||||
|
private val sampleDto = TokenApiResponse(
|
||||||
|
accessToken = "acc",
|
||||||
|
accessTokenExpiresAt = "2023-11-14T22:13:20Z",
|
||||||
|
refreshToken = "rt",
|
||||||
|
refreshTokenExpiresAt = "2023-11-14T23:13:20Z",
|
||||||
|
walletIds = listOf("w1", "w2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val sampleDomain = SessionTokens(
|
||||||
|
accessToken = "acc",
|
||||||
|
accessTokenExpiresAt = Instant.parse("2023-11-14T22:13:20Z"),
|
||||||
|
refreshToken = "rt",
|
||||||
|
refreshTokenExpiresAt = Instant.parse("2023-11-14T23:13:20Z"),
|
||||||
|
walletIds = listOf("w1", "w2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get returns None when nothing stored`() = runTest {
|
||||||
|
val storage = mockk<SecureStorage>(relaxed = true)
|
||||||
|
every { storage.getAsString("session_tokens") } returns null
|
||||||
|
|
||||||
|
val store = DefaultSessionTokensStore(storage, moshi, dispatchers)
|
||||||
|
|
||||||
|
assertThat(store.get()).isEqualTo(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `save round-trips through TokenApiResponse adapter`() = runTest {
|
||||||
|
val storage = mockk<SecureStorage>(relaxed = true)
|
||||||
|
val captured = slot<String>()
|
||||||
|
every { storage.store(eq("session_tokens"), capture(captured)) } returns Unit
|
||||||
|
|
||||||
|
val store = DefaultSessionTokensStore(storage, moshi, dispatchers)
|
||||||
|
store.save(sampleDomain)
|
||||||
|
|
||||||
|
verify { storage.store("session_tokens", any()) }
|
||||||
|
val decoded = adapter.fromJson(captured.captured)
|
||||||
|
assertThat(decoded).isEqualTo(sampleDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get decodes TokenApiResponse and maps to domain`() = runTest {
|
||||||
|
val storage = mockk<SecureStorage>(relaxed = true)
|
||||||
|
every { storage.getAsString("session_tokens") } returns adapter.toJson(sampleDto)
|
||||||
|
|
||||||
|
val store = DefaultSessionTokensStore(storage, moshi, dispatchers)
|
||||||
|
|
||||||
|
assertThat(store.get()).isEqualTo(Some(sampleDomain))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get returns None and clears corrupted entry`() = runTest {
|
||||||
|
val storage = mockk<SecureStorage>(relaxed = true)
|
||||||
|
every { storage.getAsString("session_tokens") } returns "{not json"
|
||||||
|
|
||||||
|
val store = DefaultSessionTokensStore(storage, moshi, dispatchers)
|
||||||
|
|
||||||
|
assertThat(store.get()).isEqualTo(None)
|
||||||
|
verify { storage.delete("session_tokens") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clear removes the entry`() = runTest {
|
||||||
|
val storage = mockk<SecureStorage>(relaxed = true)
|
||||||
|
|
||||||
|
val store = DefaultSessionTokensStore(storage, moshi, dispatchers)
|
||||||
|
store.clear()
|
||||||
|
|
||||||
|
verify { storage.delete("session_tokens") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
package com.tangem.lib.auth.session.internal
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||||
|
import com.tangem.lib.auth.session.SessionTokens
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
class SessionTokensConverterTest {
|
||||||
|
|
||||||
|
private val domain = SessionTokens(
|
||||||
|
accessToken = "acc",
|
||||||
|
accessTokenExpiresAt = Instant.parse("2023-11-14T22:13:20Z"),
|
||||||
|
refreshToken = "rt",
|
||||||
|
refreshTokenExpiresAt = Instant.parse("2023-11-14T23:13:20Z"),
|
||||||
|
walletIds = listOf("w1", "w2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val dto = TokenApiResponse(
|
||||||
|
accessToken = "acc",
|
||||||
|
accessTokenExpiresAt = "2023-11-14T22:13:20Z",
|
||||||
|
refreshToken = "rt",
|
||||||
|
refreshTokenExpiresAt = "2023-11-14T23:13:20Z",
|
||||||
|
walletIds = listOf("w1", "w2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `convert maps domain to DTO with ISO-8601 timestamps`() {
|
||||||
|
assertThat(SessionTokensConverter.convert(domain)).isEqualTo(dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `convertBack maps DTO to domain with parsed Instant timestamps`() {
|
||||||
|
assertThat(SessionTokensConverter.convertBack(dto)).isEqualTo(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `convert round-trip preserves domain value`() {
|
||||||
|
val roundTripped = SessionTokensConverter.convertBack(SessionTokensConverter.convert(domain))
|
||||||
|
assertThat(roundTripped).isEqualTo(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null refresh token survives both directions`() {
|
||||||
|
val orangeTier = domain.copy(refreshToken = null, refreshTokenExpiresAt = null)
|
||||||
|
val orangeDto = dto.copy(refreshToken = null, refreshTokenExpiresAt = null)
|
||||||
|
|
||||||
|
assertThat(SessionTokensConverter.convert(orangeTier)).isEqualTo(orangeDto)
|
||||||
|
assertThat(SessionTokensConverter.convertBack(orangeDto)).isEqualTo(orangeTier)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty walletIds list survives both directions`() {
|
||||||
|
val noWallets = domain.copy(walletIds = emptyList())
|
||||||
|
val noWalletsDto = dto.copy(walletIds = emptyList())
|
||||||
|
|
||||||
|
assertThat(SessionTokensConverter.convert(noWallets)).isEqualTo(noWalletsDto)
|
||||||
|
assertThat(SessionTokensConverter.convertBack(noWalletsDto)).isEqualTo(noWallets)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue