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