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`).
*/
@POST("api/v1/auth/refresh")
@RequiresSessionAuth
@RequiresDpopProof
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(
apiConfigId = ApiConfig.ID.Express,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -70,6 +71,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.StakeKit,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -85,6 +87,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.P2PEthPool,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_90_SECONDS,
connectTimeoutSeconds = TIMEOUT_90_SECONDS,
@ -100,6 +103,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Express,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -109,6 +113,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = true,
sessionAuth = false,
)
}
@ -118,6 +123,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.YieldSupply,
applyTimeoutAnnotations = true,
sessionAuth = false,
)
}
@ -127,6 +133,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -142,6 +149,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPay,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -156,6 +164,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPay,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -170,6 +179,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPayAuth,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -179,6 +189,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.BlockAid,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -188,6 +199,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.SurveySparrow,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -197,6 +209,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.MoonPay,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -206,6 +219,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.News,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -215,6 +229,11 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Auth,
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(
apiConfigId = ApiConfig.ID.GaslessTxService,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = 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.tangem.core.analytics.api.AnalyticsErrorHandler
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.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
@ -25,6 +27,7 @@ import com.tangem.datasource.utils.addHeaders
import com.tangem.utils.JsonStringValuesExtractor
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Invocation
@ -32,6 +35,8 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Provider
import javax.inject.Singleton
/**
@ -55,6 +60,9 @@ internal class RetrofitApiBuilder @Inject constructor(
@ApplicationContext private val context: Context,
private val appLogsStore: AppLogsStore,
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()
@ -73,6 +81,10 @@ internal class RetrofitApiBuilder @Inject constructor(
*
* @param apiConfigId the ID of the API configuration to use
* @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 logsSaving whether to enable logs saving
*
@ -81,6 +93,7 @@ internal class RetrofitApiBuilder @Inject constructor(
inline fun <reified T> build(
apiConfigId: ApiConfig.ID,
applyTimeoutAnnotations: Boolean,
sessionAuth: Boolean,
timeouts: Timeouts? = null,
logsSaving: Boolean = true,
): T {
@ -94,6 +107,7 @@ internal class RetrofitApiBuilder @Inject constructor(
OkHttpClient.Builder()
.applyApiConfig(apiConfigId = apiConfigId, environmentConfig = environmentConfig)
.applyWireMockRedirect()
.applySessionAuth(sessionAuth)
.let {
if (applyTimeoutAnnotations) it.applyTimeoutAnnotations() else it
}
@ -108,6 +122,19 @@ internal class RetrofitApiBuilder @Inject constructor(
.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(
val callTimeoutSeconds: Long? = null,
val connectTimeoutSeconds: Long? = null,