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`).
|
* 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>
|
||||||
}
|
}
|
||||||
|
|
@ -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(
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue