Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-29 15:36:20 +04:00
parent 0ee2f48898
commit 5ed229f6ed
15 changed files with 910 additions and 0 deletions

View file

@ -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)

View file

@ -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)
} }

View file

@ -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>
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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"
}
}

View file

@ -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>,
)

View file

@ -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()
}

View file

@ -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"
}
}

View file

@ -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
}

View file

@ -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,
)
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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") }
}
}

View file

@ -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)
}
}