From 5ed229f6ed57a731115cfdb22c0b7b43f6e285f4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 15:36:20 +0400 Subject: [PATCH] Updated on 2026-08-14 --- libs/auth/build.gradle.kts | 8 + .../java/com/tangem/lib/auth/di/AuthModule.kt | 63 +++++++ .../tangem/lib/auth/dpop/DpopProofFactory.kt | 25 +++ .../dpop/internal/DefaultDpopProofFactory.kt | 101 +++++++++++ .../dpop/internal/DisabledDpopProofFactory.kt | 10 ++ .../auth/http/DpopAuthorizationInterceptor.kt | 70 ++++++++ .../tangem/lib/auth/session/SessionTokens.kt | 31 ++++ .../lib/auth/session/SessionTokensStore.kt | 20 +++ .../internal/DefaultSessionTokensStore.kt | 62 +++++++ .../internal/DisabledSessionTokensStore.kt | 19 ++ .../internal/SessionTokensConverter.kt | 30 ++++ .../internal/DefaultDpopProofFactoryTest.kt | 170 ++++++++++++++++++ .../http/DpopAuthorizationInterceptorTest.kt | 142 +++++++++++++++ .../internal/DefaultSessionTokensStoreTest.kt | 97 ++++++++++ .../internal/SessionTokensConverterTest.kt | 62 +++++++ 15 files changed, 910 insertions(+) create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt create mode 100644 libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt create mode 100644 libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt create mode 100644 libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt create mode 100644 libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt diff --git a/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts index 9cdc3747e2..7187124c7a 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) alias(deps.plugins.hilt.android) id("configuration") } @@ -17,10 +18,12 @@ tasks.withType().configureEach { dependencies { /** Core */ implementation(projects.core.configToggles) + implementation(projects.core.datasource) implementation(projects.core.utils) /** Tangem libraries */ implementation(tangemDeps.card.core) + implementation(tangemDeps.card.android) /** Firebase */ implementation(platform(deps.firebase.bom)) @@ -28,6 +31,11 @@ dependencies { /** Other */ implementation(deps.arrow.core) + implementation(deps.kotlin.datetime) + implementation(deps.kotlin.serialization) + implementation(deps.moshi) + implementation(deps.okHttp) + implementation(deps.retrofit) /** DI */ implementation(deps.hilt.android) diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt index 716b126163..ba1d0742c7 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt @@ -1,19 +1,34 @@ package com.tangem.lib.auth.di +import android.content.Context 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.devicekey.DeviceKeyManager import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager 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.internal.DefaultAuthNonceDecryptor 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.logging.TangemLogger import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json import java.security.KeyStore import javax.inject.Named import javax.inject.Singleton @@ -58,4 +73,52 @@ internal object AuthModule { 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) } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt new file mode 100644 index 0000000000..92d9ba6154 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/DpopProofFactory.kt @@ -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 +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt new file mode 100644 index 0000000000..d46fe19fd4 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt @@ -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 = + 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 + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt new file mode 100644 index 0000000000..eb2242eaa5 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt @@ -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 = None +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt b/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt new file mode 100644 index 0000000000..9db1b87cd1 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt @@ -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 ` — present if [SessionTokensStore] holds an access token. + * - `DPoP: ` — 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" + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt new file mode 100644 index 0000000000..1eb627ee59 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokens.kt @@ -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 ` 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, +) \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt new file mode 100644 index 0000000000..6aa8fea604 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokensStore.kt @@ -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 + + /** Atomically replaces the stored tokens. */ + suspend fun save(tokens: SessionTokens) + + /** Removes stored tokens. */ + suspend fun clear() +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt new file mode 100644 index 0000000000..cb2a7c8940 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStore.kt @@ -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 by lazy { + moshi.adapter(TokenApiResponse::class.java) + } + + override suspend fun get(): Option = 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" + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt new file mode 100644 index 0000000000..f5b83d9231 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledSessionTokensStore.kt @@ -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 = None + + override suspend fun save(tokens: SessionTokens) = Unit + + override suspend fun clear() = Unit +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt new file mode 100644 index 0000000000..7dc85dd12d --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SessionTokensConverter.kt @@ -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 { + + 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, + ) +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt new file mode 100644 index 0000000000..8dee6ba5f9 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt @@ -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() + val flags = secondArg() + 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 `.` — 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 + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt new file mode 100644 index 0000000000..d96da75296 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt @@ -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() + 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() + 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() + 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() + 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() + 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() + every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns annotated + val invocation = mockk() + every { invocation.method() } returns method + return invocation + } + + private fun chain(request: Request, captureSlot: CapturingSlot): Interceptor.Chain { + val response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("ok") + .build() + val chain = mockk() + every { chain.request() } returns request + every { chain.proceed(capture(captureSlot)) } returns response + return chain + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt new file mode 100644 index 0000000000..e603fa8d27 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokensStoreTest.kt @@ -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(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(relaxed = true) + val captured = slot() + 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(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(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(relaxed = true) + + val store = DefaultSessionTokensStore(storage, moshi, dispatchers) + store.clear() + + verify { storage.delete("session_tokens") } + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt new file mode 100644 index 0000000000..a7adc7b1dd --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SessionTokensConverterTest.kt @@ -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) + } +} \ No newline at end of file