Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-02 17:03:25 +04:00
parent 6f82ba15b7
commit 80c206e13b
12 changed files with 468 additions and 51 deletions

View file

@ -5,6 +5,8 @@ import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.squareup.moshi.Moshi
import com.tangem.common.services.secure.SecureStorage
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.auth.qualifier.SessionAuthAuthenticator
import com.tangem.datasource.api.auth.qualifier.SessionAuthInterceptor
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager
@ -14,6 +16,7 @@ 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.http.SessionAuthenticator
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor
import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor
@ -35,6 +38,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Interceptor
import java.security.KeyStore
import javax.inject.Named
import javax.inject.Singleton
@ -43,6 +48,16 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
internal object AuthModule {
/**
* Exposes the backend-authentication feature toggle as a plain `Boolean` so that callers
* in `core:datasource` (which can't depend on `libs:auth` for layering reasons) can gate
* session-auth wiring without importing [AuthFeatureToggles].
*/
@Provides
@Named("isBackendAuthenticationEnabled")
fun provideIsBackendAuthenticationEnabled(authFeatureToggles: AuthFeatureToggles): Boolean =
authFeatureToggles.isBackendAuthenticationEnabled
@Provides
@Singleton
fun provideDeviceKeyManager(
@ -150,8 +165,15 @@ internal object AuthModule {
@Provides
@Singleton
fun provideDpopAuthorizationInterceptor(
store: SessionTokensStore,
proofFactory: DpopProofFactory,
): DpopAuthorizationInterceptor = DpopAuthorizationInterceptor(store, proofFactory)
@SessionAuthInterceptor
fun provideDpopAuthorizationInterceptor(store: SessionTokensStore, proofFactory: DpopProofFactory): Interceptor {
return DpopAuthorizationInterceptor(store, proofFactory)
}
@Provides
@Singleton
@SessionAuthAuthenticator
fun provideSessionAuthenticator(refresher: SessionTokenRefresher, proofFactory: DpopProofFactory): Authenticator {
return SessionAuthenticator(refresher, proofFactory)
}
}

View file

@ -1,18 +1,17 @@
package com.tangem.lib.auth.http
import com.tangem.datasource.api.auth.RequiresDpopProof
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]:
* Retrofit method is marked with [RequiresDpopProof] or the umbrella [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.
@ -32,7 +31,7 @@ class DpopAuthorizationInterceptor(
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
if (!original.requiresSessionAuth()) return chain.proceed(original)
if (!original.requiresDpopProof()) return chain.proceed(original)
val accessToken = runBlocking { store.get().getOrNull()?.accessToken }
if (accessToken == null) {
@ -43,7 +42,7 @@ class DpopAuthorizationInterceptor(
}
val proof = runBlocking {
proofFactory.create(original.method, original.url.toString(), accessToken)
proofFactory.create(original.method, original.htuUrl(), accessToken)
}.getOrNull()
if (proof == null) {
@ -51,20 +50,6 @@ class DpopAuthorizationInterceptor(
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"
return chain.proceed(original.withDpopHeaders(accessToken, proof))
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.lib.auth.http
import com.tangem.datasource.api.auth.RequiresDpopProof
import com.tangem.datasource.api.auth.RequiresSessionAuth
import com.tangem.datasource.api.auth.RequiresSessionRefresh
import okhttp3.Request
import retrofit2.Invocation
internal const val HEADER_AUTHORIZATION = "Authorization"
internal const val HEADER_DPOP = "DPoP"
internal const val DPOP_SCHEME = "DPoP"
/**
* `true` when the Retrofit method behind this request opts into outgoing DPoP proof headers
* either explicitly via [RequiresDpopProof] or transitively via the umbrella [RequiresSessionAuth].
*/
internal fun Request.requiresDpopProof(): Boolean =
hasMethodAnnotation<RequiresDpopProof>() || hasMethodAnnotation<RequiresSessionAuth>()
/**
* `true` when the Retrofit method behind this request opts into automatic session-token refresh
* on 401/403 either explicitly via [RequiresSessionRefresh] or transitively via [RequiresSessionAuth].
*/
internal fun Request.requiresSessionRefresh(): Boolean =
hasMethodAnnotation<RequiresSessionRefresh>() || hasMethodAnnotation<RequiresSessionAuth>()
/** Returns a copy of this request with `Authorization: DPoP <token>` and `DPoP: <proof>` headers set. */
internal fun Request.withDpopHeaders(accessToken: String, proof: String): Request = newBuilder()
.header(HEADER_AUTHORIZATION, "$DPOP_SCHEME $accessToken")
.header(HEADER_DPOP, proof)
.build()
/**
* Target URI for the DPoP `htu` claim full URL stripped of query and fragment per RFC 9449 §4.2.
* Callers must pass this (not the raw `url.toString()`) to `DpopProofFactory.create` so the contract
* is honoured at the call site rather than relying on defensive stripping inside any one factory impl.
*/
internal fun Request.htuUrl(): String = url.toString().substringBefore('#').substringBefore('?')
private inline fun <reified A : Annotation> Request.hasMethodAnnotation(): Boolean =
tag(Invocation::class.java)?.method()?.isAnnotationPresent(A::class.java) == true

View file

@ -0,0 +1,54 @@
package com.tangem.lib.auth.http
import arrow.core.getOrElse
import com.tangem.datasource.api.auth.RequiresSessionRefresh
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.lib.auth.session.SessionTokenRefresher
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
/**
* OkHttp [Authenticator] that reacts to 401/403 by rotating session tokens via
* [SessionTokenRefresher] and retrying the original request with a fresh DPoP proof.
*
* Returns `null` (giving up) when:
* - the response code is not 401/403;
* - the Retrofit method is **not** annotated with [RequiresSessionRefresh] (or the umbrella
* [RequiresSessionAuth]) keeps public endpoints and refresh-flow endpoints themselves
* (annotated with `@RequiresDpopProof` only) from triggering token rotation on incidental 401s;
* - the request was already retried once (`response.priorResponse != null`);
* - the refresher fails (revoked session, network error, etc.).
*
* This guarantees at most one retry per call site OkHttp will not loop on persistent 401s.
*/
class SessionAuthenticator(
private val refresher: SessionTokenRefresher,
private val proofFactory: DpopProofFactory,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (response.code != Code.UNAUTHORIZED.numericCode && response.code != Code.FORBIDDEN.numericCode) return null
if (response.priorResponse != null) return null
if (!response.request.requiresSessionRefresh()) return null
val refreshed = runBlocking { refresher.refresh() }.getOrElse { error ->
TangemLogger.e("Session refresh failed ($error); surfacing original ${response.code}")
return null
}
val request = response.request
val proof = runBlocking {
proofFactory.create(request.method, request.htuUrl(), refreshed.accessToken)
}.getOrElse {
TangemLogger.e("DPoP proof generation failed after refresh; cannot retry request")
return null
}
return request.withDpopHeaders(refreshed.accessToken, proof)
}
}

View file

@ -3,7 +3,9 @@ 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.RequiresDpopProof
import com.tangem.datasource.api.auth.RequiresSessionAuth
import com.tangem.datasource.api.auth.RequiresSessionRefresh
import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.lib.auth.session.SessionTokens
import com.tangem.lib.auth.session.SessionTokensStore
@ -47,12 +49,12 @@ class DpopAuthorizationInterceptorTest {
)
@Test
fun `annotated request gets Authorization and DPoP headers`() {
fun `@RequiresDpopProof method 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)
val chain = chain(request(dpop = true), proceeded)
interceptor.intercept(chain)
@ -60,12 +62,38 @@ class DpopAuthorizationInterceptorTest {
assertThat(proceeded.captured.header("DPoP")).isEqualTo("proof-jwt")
}
@Test
fun `@RequiresSessionAuth (umbrella) method gets headers — covers proof path transitively`() {
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(sessionAuth = true), proceeded)
interceptor.intercept(chain)
assertThat(proceeded.captured.header("Authorization")).isEqualTo("DPoP old-access")
assertThat(proceeded.captured.header("DPoP")).isEqualTo("proof-jwt")
}
@Test
fun `@RequiresSessionRefresh-only method does NOT get DPoP headers`() {
val proceeded = slot<Request>()
val chain = chain(request(sessionRefresh = 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 `annotated request without access token passes through unmodified`() {
coEvery { store.get() } returns None
val proceeded = slot<Request>()
val chain = chain(request(annotated = true), proceeded)
val chain = chain(request(dpop = true), proceeded)
interceptor.intercept(chain)
@ -76,9 +104,8 @@ class DpopAuthorizationInterceptorTest {
@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)
val chain = chain(request(), proceeded)
interceptor.intercept(chain)
@ -105,7 +132,7 @@ class DpopAuthorizationInterceptorTest {
coEvery { proofFactory.create(any(), any(), any()) } returns None
val proceeded = slot<Request>()
val chain = chain(request(annotated = true), proceeded)
val chain = chain(request(dpop = true), proceeded)
interceptor.intercept(chain)
@ -113,15 +140,21 @@ class DpopAuthorizationInterceptorTest {
assertThat(proceeded.captured.header("DPoP")).isNull()
}
private fun request(annotated: Boolean): Request {
private fun request(
dpop: Boolean = false,
sessionRefresh: Boolean = false,
sessionAuth: Boolean = false,
): Request {
val builder = Request.Builder().url("https://example.com/api/v1/foo")
builder.tag(Invocation::class.java, invocationWithAnnotation(annotated))
builder.tag(Invocation::class.java, invocationWith(dpop, sessionRefresh, sessionAuth))
return builder.build()
}
private fun invocationWithAnnotation(annotated: Boolean): Invocation {
private fun invocationWith(dpop: Boolean, sessionRefresh: Boolean, sessionAuth: Boolean): Invocation {
val method = mockk<Method>()
every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns annotated
every { method.isAnnotationPresent(RequiresDpopProof::class.java) } returns dpop
every { method.isAnnotationPresent(RequiresSessionRefresh::class.java) } returns sessionRefresh
every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns sessionAuth
val invocation = mockk<Invocation>()
every { invocation.method() } returns method
return invocation

View file

@ -0,0 +1,175 @@
package com.tangem.lib.auth.http
import arrow.core.None
import arrow.core.Some
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.auth.RequiresDpopProof
import com.tangem.datasource.api.auth.RequiresSessionAuth
import com.tangem.datasource.api.auth.RequiresSessionRefresh
import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.lib.auth.session.AuthError
import com.tangem.lib.auth.session.SessionRefreshError
import com.tangem.lib.auth.session.SessionTokenRefresher
import com.tangem.lib.auth.session.SessionTokens
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.datetime.Instant
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
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 SessionAuthenticatorTest {
private val refresher: SessionTokenRefresher = mockk()
private val proofFactory: DpopProofFactory = mockk()
private val authenticator = SessionAuthenticator(refresher, proofFactory)
private val refreshedTokens = SessionTokens(
accessToken = "new-access",
accessTokenExpiresAt = Instant.fromEpochSeconds(1_700_000_000),
refreshToken = "rt-2",
refreshTokenExpiresAt = Instant.fromEpochSeconds(1_700_003_600),
walletIds = emptyList(),
)
@Test
fun `401 on @RequiresSessionRefresh triggers refresh and retries with new headers`() {
coEvery { refresher.refresh() } returns refreshedTokens.right()
coEvery { proofFactory.create(any(), any(), "new-access") } returns Some("fresh-proof")
val retried = authenticator.authenticate(
route = null,
response = response(code = 401, sessionRefresh = true),
)
assertThat(retried).isNotNull()
assertThat(retried!!.header("Authorization")).isEqualTo("DPoP new-access")
assertThat(retried.header("DPoP")).isEqualTo("fresh-proof")
}
@Test
fun `401 on @RequiresSessionAuth (umbrella) triggers refresh — covers refresh path transitively`() {
coEvery { refresher.refresh() } returns refreshedTokens.right()
coEvery { proofFactory.create(any(), any(), "new-access") } returns Some("fresh-proof")
val retried = authenticator.authenticate(
route = null,
response = response(code = 401, sessionAuth = true),
)
assertThat(retried).isNotNull()
}
@Test
fun `401 on @RequiresDpopProof-only method does NOT trigger refresh — prevents recursion`() {
val retried = authenticator.authenticate(
route = null,
response = response(code = 401, dpop = true),
)
assertThat(retried).isNull()
}
@Test
fun `403 also triggers refresh`() {
coEvery { refresher.refresh() } returns refreshedTokens.right()
coEvery { proofFactory.create(any(), any(), "new-access") } returns Some("fresh-proof")
val retried = authenticator.authenticate(
route = null,
response = response(code = 403, sessionRefresh = true),
)
assertThat(retried).isNotNull()
}
@Test
fun `other 4xx codes are passed through`() {
val retried = authenticator.authenticate(
route = null,
response = response(code = 404, sessionRefresh = true),
)
assertThat(retried).isNull()
}
@Test
fun `prior response present means we already retried — give up`() {
val first = response(code = 401, sessionRefresh = true)
val second = response(code = 401, sessionRefresh = true, priorResponse = first)
val retried = authenticator.authenticate(route = null, response = second)
assertThat(retried).isNull()
}
@Test
fun `unannotated request 401 is passed through without refresh`() {
val retried = authenticator.authenticate(route = null, response = response(code = 401))
assertThat(retried).isNull()
}
@Test
fun `refresh failure gives up`() {
coEvery { refresher.refresh() } returns SessionRefreshError.Api(AuthError.NetworkError).left()
val retried = authenticator.authenticate(
route = null,
response = response(code = 401, sessionRefresh = true),
)
assertThat(retried).isNull()
}
@Test
fun `proof generation None result gives up`() {
coEvery { refresher.refresh() } returns refreshedTokens.right()
coEvery { proofFactory.create(any(), any(), any()) } returns None
val retried = authenticator.authenticate(
route = null,
response = response(code = 401, sessionRefresh = true),
)
assertThat(retried).isNull()
}
private fun response(
code: Int,
dpop: Boolean = false,
sessionRefresh: Boolean = false,
sessionAuth: Boolean = false,
priorResponse: Response? = null,
): Response {
val builder = Request.Builder().url("https://example.com/api/v1/foo")
builder.tag(Invocation::class.java, invocationWith(dpop, sessionRefresh, sessionAuth))
val request = builder.build()
return Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(code)
.message("test")
.apply { if (priorResponse != null) priorResponse(priorResponse) }
.build()
}
private fun invocationWith(dpop: Boolean, sessionRefresh: Boolean, sessionAuth: Boolean): Invocation {
val method = mockk<Method>()
every { method.isAnnotationPresent(RequiresDpopProof::class.java) } returns dpop
every { method.isAnnotationPresent(RequiresSessionRefresh::class.java) } returns sessionRefresh
every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns sessionAuth
val invocation = mockk<Invocation>()
every { invocation.method() } returns method
return invocation
}
}