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

@ -40,6 +40,6 @@ interface AuthApi {
* family (SR-8). Sender-constraint is verified via the DPoP-proof header (`cnf.jkt`). * family (SR-8). Sender-constraint is verified via the DPoP-proof header (`cnf.jkt`).
*/ */
@POST("api/v1/auth/refresh") @POST("api/v1/auth/refresh")
@RequiresSessionAuth @RequiresDpopProof
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse> suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
} }

View file

@ -1,16 +0,0 @@
package com.tangem.datasource.api.auth
/**
* Marks a Retrofit endpoint as requiring an authenticated session (DPoP, see
* [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)).
*
* Read at runtime by the session-auth interceptor: only methods
* carrying this annotation receive `Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`
* headers; unannotated methods (e.g. public nonce endpoints) pass through unchanged.
*
* Mirrors the per-operation `security` blocks in the backend OpenAPI contract; follows the
* same on-method annotation pattern as `@ReadTimeout` / `@ConnectTimeout`.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresSessionAuth

View file

@ -0,0 +1,56 @@
package com.tangem.datasource.api.auth
/**
* Marks a Retrofit endpoint as needing a DPoP proof header ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449))
* but **not** automatic refresh-on-401.
*
* Read at runtime by the DPoP authorization interceptor: methods carrying this annotation
* (or the umbrella [RequiresSessionAuth]) receive `Authorization: DPoP <access-token>` (when
* available) + `DPoP: <proof-jwt>` headers.
*
* Use this on endpoints that are themselves part of the refresh flow e.g. `/auth/refresh`
* to prevent the session authenticator from re-entering refresh on a 401 (which would deadlock
* the single-flight refresh mutex).
*
* For ordinary session-protected endpoints, prefer the combined [RequiresSessionAuth].
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresDpopProof
/**
* Marks a Retrofit endpoint as eligible for automatic session-token refresh on 401/403.
*
* Read at runtime by the session authenticator: methods carrying this annotation (or the
* umbrella [RequiresSessionAuth]) trigger `SessionTokenRefresher.refresh()` + a single retry
* with new tokens when the server responds with 401/403.
*
* Important: this annotation alone does **not** instruct the DPoP interceptor to add headers
* on the initial outgoing request. The retry built by the session authenticator after a
* successful refresh, however, always carries fresh `Authorization` / `DPoP` headers that
* happens regardless of which annotation gated the refresh.
*
* Rare in isolation proof and refresh-on-401 almost always travel together. Prefer the
* combined [RequiresSessionAuth] unless you have a concrete reason to omit proof on send.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresSessionRefresh
/**
* Marks a Retrofit endpoint as fully session-protected ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)).
*
* Combines [RequiresDpopProof] (outgoing `Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`
* headers via the DPoP authorization interceptor) and [RequiresSessionRefresh] (automatic refresh +
* single retry on 401/403 via the session authenticator).
*
* Default choice for normal session-protected endpoints. Use the two specialised annotations only
* when you need exactly one of the behaviours typically `@RequiresDpopProof` on endpoints inside
* the refresh flow itself (`/auth/refresh`) to prevent recursion.
*
* Mirrors the per-operation `security` blocks in the backend OpenAPI contract; follows the same
* on-method annotation pattern as `@ReadTimeout` / `@ConnectTimeout`.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresSessionAuth

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.auth.qualifier
import javax.inject.Qualifier
/**
* Marks the OkHttp [okhttp3.Interceptor] that attaches Tangem Auth Service session credentials
* (`Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`) to outgoing requests. The actual
* binding lives in `libs:auth` so this module does not depend on the auth library; Hilt assembles
* the binding at the `:app` level.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SessionAuthInterceptor
/**
* Marks the OkHttp [okhttp3.Authenticator] that rotates session tokens on 401/403 responses.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SessionAuthAuthenticator

View file

@ -61,6 +61,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Express, apiConfigId = ApiConfig.ID.Express,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
) )
} }
@ -70,6 +71,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.StakeKit, apiConfigId = ApiConfig.ID.StakeKit,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts( timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS, callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS, connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -85,6 +87,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.P2PEthPool, apiConfigId = ApiConfig.ID.P2PEthPool,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts( timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_90_SECONDS, callTimeoutSeconds = TIMEOUT_90_SECONDS,
connectTimeoutSeconds = TIMEOUT_90_SECONDS, connectTimeoutSeconds = TIMEOUT_90_SECONDS,
@ -100,6 +103,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Express, apiConfigId = ApiConfig.ID.Express,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
) )
} }
@ -109,6 +113,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech, apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = true, applyTimeoutAnnotations = true,
sessionAuth = false,
) )
} }
@ -118,6 +123,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.YieldSupply, apiConfigId = ApiConfig.ID.YieldSupply,
applyTimeoutAnnotations = true, applyTimeoutAnnotations = true,
sessionAuth = false,
) )
} }
@ -127,6 +133,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech, apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts( timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS, callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS, connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -142,6 +149,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPay, apiConfigId = ApiConfig.ID.TangemPay,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts( timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS, callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS, connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -156,6 +164,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPay, apiConfigId = ApiConfig.ID.TangemPay,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts( timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS, callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS, connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -170,6 +179,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPayAuth, apiConfigId = ApiConfig.ID.TangemPayAuth,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
) )
} }
@ -179,6 +189,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.BlockAid, apiConfigId = ApiConfig.ID.BlockAid,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
) )
} }
@ -188,6 +199,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.SurveySparrow, apiConfigId = ApiConfig.ID.SurveySparrow,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
) )
} }
@ -197,6 +209,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.MoonPay, apiConfigId = ApiConfig.ID.MoonPay,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
) )
} }
@ -206,6 +219,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.News, apiConfigId = ApiConfig.ID.News,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
) )
} }
@ -215,6 +229,11 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Auth, apiConfigId = ApiConfig.ID.Auth,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
// Per-method annotations (`@RequiresDpopProof`, `@RequiresSessionAuth`) gate the hooks
// installed here. `/refresh` carries `@RequiresDpopProof` only, so the Authenticator
// skips it on 401 — no recursion into the refresher's mutex. Future session-protected
// endpoints (e.g. /wallet) will carry `@RequiresSessionAuth` and benefit from refresh-on-401.
sessionAuth = true,
) )
} }
@ -224,6 +243,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build( return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.GaslessTxService, apiConfigId = ApiConfig.ID.GaslessTxService,
applyTimeoutAnnotations = false, applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts( timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS, callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS, connectTimeoutSeconds = TIMEOUT_60_SECONDS,

View file

@ -5,6 +5,8 @@ import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.BuildConfig import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.auth.qualifier.SessionAuthAuthenticator
import com.tangem.datasource.api.auth.qualifier.SessionAuthInterceptor
import com.tangem.datasource.api.common.SwitchEnvironmentInterceptor import com.tangem.datasource.api.common.SwitchEnvironmentInterceptor
import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs import com.tangem.datasource.api.common.config.ApiConfigs
@ -25,6 +27,7 @@ import com.tangem.datasource.utils.addHeaders
import com.tangem.utils.JsonStringValuesExtractor import com.tangem.utils.JsonStringValuesExtractor
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Invocation import retrofit2.Invocation
@ -32,6 +35,8 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named
import javax.inject.Provider
import javax.inject.Singleton import javax.inject.Singleton
/** /**
@ -55,6 +60,9 @@ internal class RetrofitApiBuilder @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val appLogsStore: AppLogsStore, private val appLogsStore: AppLogsStore,
private val environmentConfig: EnvironmentConfig, private val environmentConfig: EnvironmentConfig,
@SessionAuthInterceptor private val sessionAuthInterceptor: Provider<Interceptor>,
@SessionAuthAuthenticator private val sessionAuthenticator: Provider<Authenticator>,
@Named("isBackendAuthenticationEnabled") private val isBackendAuthEnabled: Provider<Boolean>,
) { ) {
private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls() private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls()
@ -73,6 +81,10 @@ internal class RetrofitApiBuilder @Inject constructor(
* *
* @param apiConfigId the ID of the API configuration to use * @param apiConfigId the ID of the API configuration to use
* @param applyTimeoutAnnotations whether to apply timeout annotations to the requests. See [ReadTimeout], etc. * @param applyTimeoutAnnotations whether to apply timeout annotations to the requests. See [ReadTimeout], etc.
* @param sessionAuth when `true`, installs the DPoP `Interceptor` and 401/403
* `Authenticator` from `libs:auth`. Per-method annotations
* (`@RequiresDpopProof`, `@RequiresSessionRefresh`,
* `@RequiresSessionAuth`) gate which methods opt into each hook
* @param timeouts optional timeouts for the requests * @param timeouts optional timeouts for the requests
* @param logsSaving whether to enable logs saving * @param logsSaving whether to enable logs saving
* *
@ -81,6 +93,7 @@ internal class RetrofitApiBuilder @Inject constructor(
inline fun <reified T> build( inline fun <reified T> build(
apiConfigId: ApiConfig.ID, apiConfigId: ApiConfig.ID,
applyTimeoutAnnotations: Boolean, applyTimeoutAnnotations: Boolean,
sessionAuth: Boolean,
timeouts: Timeouts? = null, timeouts: Timeouts? = null,
logsSaving: Boolean = true, logsSaving: Boolean = true,
): T { ): T {
@ -94,6 +107,7 @@ internal class RetrofitApiBuilder @Inject constructor(
OkHttpClient.Builder() OkHttpClient.Builder()
.applyApiConfig(apiConfigId = apiConfigId, environmentConfig = environmentConfig) .applyApiConfig(apiConfigId = apiConfigId, environmentConfig = environmentConfig)
.applyWireMockRedirect() .applyWireMockRedirect()
.applySessionAuth(sessionAuth)
.let { .let {
if (applyTimeoutAnnotations) it.applyTimeoutAnnotations() else it if (applyTimeoutAnnotations) it.applyTimeoutAnnotations() else it
} }
@ -108,6 +122,19 @@ internal class RetrofitApiBuilder @Inject constructor(
.create(T::class.java) .create(T::class.java)
} }
@PublishedApi
internal fun OkHttpClient.Builder.applySessionAuth(condition: Boolean): OkHttpClient.Builder {
// Belt-and-suspenders: callers opt in via the `sessionAuth` flag, but if the backend-auth
// feature toggle is OFF we skip installing the hooks entirely (avoids wiring up DPoP
// header generation and 401 retry logic on builds where auth isn't live yet).
if (condition && isBackendAuthEnabled.get()) {
addInterceptor(sessionAuthInterceptor.get())
authenticator(sessionAuthenticator.get())
}
return this
}
data class Timeouts( data class Timeouts(
val callTimeoutSeconds: Long? = null, val callTimeoutSeconds: Long? = null,
val connectTimeoutSeconds: Long? = null, val connectTimeoutSeconds: Long? = null,

View file

@ -5,6 +5,8 @@ import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.tangem.common.services.secure.SecureStorage import com.tangem.common.services.secure.SecureStorage
import com.tangem.datasource.api.auth.AuthApi 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.datasource.di.NetworkMoshi
import com.tangem.lib.auth.AuthFeatureToggles import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.lib.auth.devicekey.DeviceKeyManager
@ -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.DefaultDpopProofFactory
import com.tangem.lib.auth.dpop.internal.DisabledDpopProofFactory import com.tangem.lib.auth.dpop.internal.DisabledDpopProofFactory
import com.tangem.lib.auth.http.DpopAuthorizationInterceptor 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.AuthNonceDecryptor
import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor
import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor
@ -35,6 +38,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Interceptor
import java.security.KeyStore import java.security.KeyStore
import javax.inject.Named import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@ -43,6 +48,16 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
internal object AuthModule { 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 @Provides
@Singleton @Singleton
fun provideDeviceKeyManager( fun provideDeviceKeyManager(
@ -150,8 +165,15 @@ internal object AuthModule {
@Provides @Provides
@Singleton @Singleton
fun provideDpopAuthorizationInterceptor( @SessionAuthInterceptor
store: SessionTokensStore, fun provideDpopAuthorizationInterceptor(store: SessionTokensStore, proofFactory: DpopProofFactory): Interceptor {
proofFactory: DpopProofFactory, return DpopAuthorizationInterceptor(store, proofFactory)
): DpopAuthorizationInterceptor = 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 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.RequiresSessionAuth
import com.tangem.lib.auth.dpop.DpopProofFactory import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.lib.auth.session.SessionTokensStore import com.tangem.lib.auth.session.SessionTokensStore
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import retrofit2.Invocation
/** /**
* Adds [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP headers to requests whose * 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. * - `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 * - `DPoP: <proof-jwt>` freshly generated for every annotated request; `ath` claim is set if
* the access token is present. * the access token is present.
@ -32,7 +31,7 @@ class DpopAuthorizationInterceptor(
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request() 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 } val accessToken = runBlocking { store.get().getOrNull()?.accessToken }
if (accessToken == null) { if (accessToken == null) {
@ -43,7 +42,7 @@ class DpopAuthorizationInterceptor(
} }
val proof = runBlocking { val proof = runBlocking {
proofFactory.create(original.method, original.url.toString(), accessToken) proofFactory.create(original.method, original.htuUrl(), accessToken)
}.getOrNull() }.getOrNull()
if (proof == null) { if (proof == null) {
@ -51,20 +50,6 @@ class DpopAuthorizationInterceptor(
return chain.proceed(original) return chain.proceed(original)
} }
return chain.proceed( return chain.proceed(original.withDpopHeaders(accessToken, proof))
original.newBuilder()
.header(HEADER_AUTHORIZATION, "$DPOP_SCHEME $accessToken")
.header(HEADER_DPOP, proof)
.build(),
)
}
private fun Request.requiresSessionAuth(): Boolean =
tag(Invocation::class.java)?.method()?.isAnnotationPresent(RequiresSessionAuth::class.java) == true
private companion object {
const val HEADER_AUTHORIZATION = "Authorization"
const val HEADER_DPOP = "DPoP"
const val DPOP_SCHEME = "DPoP"
} }
} }

View file

@ -0,0 +1,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.None
import arrow.core.Some import arrow.core.Some
import com.google.common.truth.Truth.assertThat 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.RequiresSessionAuth
import com.tangem.datasource.api.auth.RequiresSessionRefresh
import com.tangem.lib.auth.dpop.DpopProofFactory import com.tangem.lib.auth.dpop.DpopProofFactory
import com.tangem.lib.auth.session.SessionTokens import com.tangem.lib.auth.session.SessionTokens
import com.tangem.lib.auth.session.SessionTokensStore import com.tangem.lib.auth.session.SessionTokensStore
@ -47,12 +49,12 @@ class DpopAuthorizationInterceptorTest {
) )
@Test @Test
fun `annotated request gets Authorization and DPoP headers`() { fun `@RequiresDpopProof method gets Authorization and DPoP headers`() {
coEvery { store.get() } returns Some(storedTokens) coEvery { store.get() } returns Some(storedTokens)
coEvery { proofFactory.create(any(), any(), "old-access") } returns Some("proof-jwt") coEvery { proofFactory.create(any(), any(), "old-access") } returns Some("proof-jwt")
val proceeded = slot<Request>() val proceeded = slot<Request>()
val chain = chain(request(annotated = true), proceeded) val chain = chain(request(dpop = true), proceeded)
interceptor.intercept(chain) interceptor.intercept(chain)
@ -60,12 +62,38 @@ class DpopAuthorizationInterceptorTest {
assertThat(proceeded.captured.header("DPoP")).isEqualTo("proof-jwt") 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 @Test
fun `annotated request without access token passes through unmodified`() { fun `annotated request without access token passes through unmodified`() {
coEvery { store.get() } returns None coEvery { store.get() } returns None
val proceeded = slot<Request>() val proceeded = slot<Request>()
val chain = chain(request(annotated = true), proceeded) val chain = chain(request(dpop = true), proceeded)
interceptor.intercept(chain) interceptor.intercept(chain)
@ -76,9 +104,8 @@ class DpopAuthorizationInterceptorTest {
@Test @Test
fun `unannotated request passes through unchanged — proof factory never invoked`() { fun `unannotated request passes through unchanged — proof factory never invoked`() {
val original = request(annotated = false)
val proceeded = slot<Request>() val proceeded = slot<Request>()
val chain = chain(original, proceeded) val chain = chain(request(), proceeded)
interceptor.intercept(chain) interceptor.intercept(chain)
@ -105,7 +132,7 @@ class DpopAuthorizationInterceptorTest {
coEvery { proofFactory.create(any(), any(), any()) } returns None coEvery { proofFactory.create(any(), any(), any()) } returns None
val proceeded = slot<Request>() val proceeded = slot<Request>()
val chain = chain(request(annotated = true), proceeded) val chain = chain(request(dpop = true), proceeded)
interceptor.intercept(chain) interceptor.intercept(chain)
@ -113,15 +140,21 @@ class DpopAuthorizationInterceptorTest {
assertThat(proceeded.captured.header("DPoP")).isNull() 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") 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() return builder.build()
} }
private fun invocationWithAnnotation(annotated: Boolean): Invocation { private fun invocationWith(dpop: Boolean, sessionRefresh: Boolean, sessionAuth: Boolean): Invocation {
val method = mockk<Method>() 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>() val invocation = mockk<Invocation>()
every { invocation.method() } returns method every { invocation.method() } returns method
return invocation 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
}
}