Updated on 2026-08-14
This commit is contained in:
parent
6f82ba15b7
commit
80c206e13b
12 changed files with 468 additions and 51 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue