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,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import com.google.firebase.crashlytics.FirebaseCrashlytics
|
|||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.common.services.secure.SecureStorage
|
||||
import com.tangem.datasource.api.auth.AuthApi
|
||||
import com.tangem.datasource.api.auth.qualifier.SessionAuthAuthenticator
|
||||
import com.tangem.datasource.api.auth.qualifier.SessionAuthInterceptor
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.lib.auth.AuthFeatureToggles
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||
|
|
@ -14,6 +16,7 @@ import com.tangem.lib.auth.dpop.DpopProofFactory
|
|||
import com.tangem.lib.auth.dpop.internal.DefaultDpopProofFactory
|
||||
import com.tangem.lib.auth.dpop.internal.DisabledDpopProofFactory
|
||||
import com.tangem.lib.auth.http.DpopAuthorizationInterceptor
|
||||
import com.tangem.lib.auth.http.SessionAuthenticator
|
||||
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
||||
import com.tangem.lib.auth.nonce.internal.DefaultAuthNonceDecryptor
|
||||
import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor
|
||||
|
|
@ -35,6 +38,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Interceptor
|
||||
import java.security.KeyStore
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -43,6 +48,16 @@ import javax.inject.Singleton
|
|||
@InstallIn(SingletonComponent::class)
|
||||
internal object AuthModule {
|
||||
|
||||
/**
|
||||
* Exposes the backend-authentication feature toggle as a plain `Boolean` so that callers
|
||||
* in `core:datasource` (which can't depend on `libs:auth` for layering reasons) can gate
|
||||
* session-auth wiring without importing [AuthFeatureToggles].
|
||||
*/
|
||||
@Provides
|
||||
@Named("isBackendAuthenticationEnabled")
|
||||
fun provideIsBackendAuthenticationEnabled(authFeatureToggles: AuthFeatureToggles): Boolean =
|
||||
authFeatureToggles.isBackendAuthenticationEnabled
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeviceKeyManager(
|
||||
|
|
@ -150,8 +165,15 @@ internal object AuthModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDpopAuthorizationInterceptor(
|
||||
store: SessionTokensStore,
|
||||
proofFactory: DpopProofFactory,
|
||||
): DpopAuthorizationInterceptor = DpopAuthorizationInterceptor(store, proofFactory)
|
||||
@SessionAuthInterceptor
|
||||
fun provideDpopAuthorizationInterceptor(store: SessionTokensStore, proofFactory: DpopProofFactory): Interceptor {
|
||||
return DpopAuthorizationInterceptor(store, proofFactory)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@SessionAuthAuthenticator
|
||||
fun provideSessionAuthenticator(refresher: SessionTokenRefresher, proofFactory: DpopProofFactory): Authenticator {
|
||||
return SessionAuthenticator(refresher, proofFactory)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
package com.tangem.lib.auth.http
|
||||
|
||||
import com.tangem.datasource.api.auth.RequiresDpopProof
|
||||
import com.tangem.datasource.api.auth.RequiresSessionAuth
|
||||
import com.tangem.lib.auth.dpop.DpopProofFactory
|
||||
import com.tangem.lib.auth.session.SessionTokensStore
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import retrofit2.Invocation
|
||||
|
||||
/**
|
||||
* Adds [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449) DPoP headers to requests whose
|
||||
* Retrofit method is marked with [RequiresSessionAuth]:
|
||||
* Retrofit method is marked with [RequiresDpopProof] or the umbrella [RequiresSessionAuth]:
|
||||
* - `Authorization: DPoP <access-token>` — present if [SessionTokensStore] holds an access token.
|
||||
* - `DPoP: <proof-jwt>` — freshly generated for every annotated request; `ath` claim is set if
|
||||
* the access token is present.
|
||||
|
|
@ -32,7 +31,7 @@ class DpopAuthorizationInterceptor(
|
|||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
|
||||
if (!original.requiresSessionAuth()) return chain.proceed(original)
|
||||
if (!original.requiresDpopProof()) return chain.proceed(original)
|
||||
|
||||
val accessToken = runBlocking { store.get().getOrNull()?.accessToken }
|
||||
if (accessToken == null) {
|
||||
|
|
@ -43,7 +42,7 @@ class DpopAuthorizationInterceptor(
|
|||
}
|
||||
|
||||
val proof = runBlocking {
|
||||
proofFactory.create(original.method, original.url.toString(), accessToken)
|
||||
proofFactory.create(original.method, original.htuUrl(), accessToken)
|
||||
}.getOrNull()
|
||||
|
||||
if (proof == null) {
|
||||
|
|
@ -51,20 +50,6 @@ class DpopAuthorizationInterceptor(
|
|||
return chain.proceed(original)
|
||||
}
|
||||
|
||||
return chain.proceed(
|
||||
original.newBuilder()
|
||||
.header(HEADER_AUTHORIZATION, "$DPOP_SCHEME $accessToken")
|
||||
.header(HEADER_DPOP, proof)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Request.requiresSessionAuth(): Boolean =
|
||||
tag(Invocation::class.java)?.method()?.isAnnotationPresent(RequiresSessionAuth::class.java) == true
|
||||
|
||||
private companion object {
|
||||
const val HEADER_AUTHORIZATION = "Authorization"
|
||||
const val HEADER_DPOP = "DPoP"
|
||||
const val DPOP_SCHEME = "DPoP"
|
||||
return chain.proceed(original.withDpopHeaders(accessToken, proof))
|
||||
}
|
||||
}
|
||||
|
|
@ -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.Some
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.api.auth.RequiresDpopProof
|
||||
import com.tangem.datasource.api.auth.RequiresSessionAuth
|
||||
import com.tangem.datasource.api.auth.RequiresSessionRefresh
|
||||
import com.tangem.lib.auth.dpop.DpopProofFactory
|
||||
import com.tangem.lib.auth.session.SessionTokens
|
||||
import com.tangem.lib.auth.session.SessionTokensStore
|
||||
|
|
@ -47,12 +49,12 @@ class DpopAuthorizationInterceptorTest {
|
|||
)
|
||||
|
||||
@Test
|
||||
fun `annotated request gets Authorization and DPoP headers`() {
|
||||
fun `@RequiresDpopProof method gets Authorization and DPoP headers`() {
|
||||
coEvery { store.get() } returns Some(storedTokens)
|
||||
coEvery { proofFactory.create(any(), any(), "old-access") } returns Some("proof-jwt")
|
||||
|
||||
val proceeded = slot<Request>()
|
||||
val chain = chain(request(annotated = true), proceeded)
|
||||
val chain = chain(request(dpop = true), proceeded)
|
||||
|
||||
interceptor.intercept(chain)
|
||||
|
||||
|
|
@ -60,12 +62,38 @@ class DpopAuthorizationInterceptorTest {
|
|||
assertThat(proceeded.captured.header("DPoP")).isEqualTo("proof-jwt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `@RequiresSessionAuth (umbrella) method gets headers — covers proof path transitively`() {
|
||||
coEvery { store.get() } returns Some(storedTokens)
|
||||
coEvery { proofFactory.create(any(), any(), "old-access") } returns Some("proof-jwt")
|
||||
|
||||
val proceeded = slot<Request>()
|
||||
val chain = chain(request(sessionAuth = true), proceeded)
|
||||
|
||||
interceptor.intercept(chain)
|
||||
|
||||
assertThat(proceeded.captured.header("Authorization")).isEqualTo("DPoP old-access")
|
||||
assertThat(proceeded.captured.header("DPoP")).isEqualTo("proof-jwt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `@RequiresSessionRefresh-only method does NOT get DPoP headers`() {
|
||||
val proceeded = slot<Request>()
|
||||
val chain = chain(request(sessionRefresh = true), proceeded)
|
||||
|
||||
interceptor.intercept(chain)
|
||||
|
||||
assertThat(proceeded.captured.header("Authorization")).isNull()
|
||||
assertThat(proceeded.captured.header("DPoP")).isNull()
|
||||
coVerify(exactly = 0) { proofFactory.create(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotated request without access token passes through unmodified`() {
|
||||
coEvery { store.get() } returns None
|
||||
|
||||
val proceeded = slot<Request>()
|
||||
val chain = chain(request(annotated = true), proceeded)
|
||||
val chain = chain(request(dpop = true), proceeded)
|
||||
|
||||
interceptor.intercept(chain)
|
||||
|
||||
|
|
@ -76,9 +104,8 @@ class DpopAuthorizationInterceptorTest {
|
|||
|
||||
@Test
|
||||
fun `unannotated request passes through unchanged — proof factory never invoked`() {
|
||||
val original = request(annotated = false)
|
||||
val proceeded = slot<Request>()
|
||||
val chain = chain(original, proceeded)
|
||||
val chain = chain(request(), proceeded)
|
||||
|
||||
interceptor.intercept(chain)
|
||||
|
||||
|
|
@ -105,7 +132,7 @@ class DpopAuthorizationInterceptorTest {
|
|||
coEvery { proofFactory.create(any(), any(), any()) } returns None
|
||||
|
||||
val proceeded = slot<Request>()
|
||||
val chain = chain(request(annotated = true), proceeded)
|
||||
val chain = chain(request(dpop = true), proceeded)
|
||||
|
||||
interceptor.intercept(chain)
|
||||
|
||||
|
|
@ -113,15 +140,21 @@ class DpopAuthorizationInterceptorTest {
|
|||
assertThat(proceeded.captured.header("DPoP")).isNull()
|
||||
}
|
||||
|
||||
private fun request(annotated: Boolean): Request {
|
||||
private fun request(
|
||||
dpop: Boolean = false,
|
||||
sessionRefresh: Boolean = false,
|
||||
sessionAuth: Boolean = false,
|
||||
): Request {
|
||||
val builder = Request.Builder().url("https://example.com/api/v1/foo")
|
||||
builder.tag(Invocation::class.java, invocationWithAnnotation(annotated))
|
||||
builder.tag(Invocation::class.java, invocationWith(dpop, sessionRefresh, sessionAuth))
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun invocationWithAnnotation(annotated: Boolean): Invocation {
|
||||
private fun invocationWith(dpop: Boolean, sessionRefresh: Boolean, sessionAuth: Boolean): Invocation {
|
||||
val method = mockk<Method>()
|
||||
every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns annotated
|
||||
every { method.isAnnotationPresent(RequiresDpopProof::class.java) } returns dpop
|
||||
every { method.isAnnotationPresent(RequiresSessionRefresh::class.java) } returns sessionRefresh
|
||||
every { method.isAnnotationPresent(RequiresSessionAuth::class.java) } returns sessionAuth
|
||||
val invocation = mockk<Invocation>()
|
||||
every { invocation.method() } returns method
|
||||
return invocation
|
||||
|
|
|
|||
|
|
@ -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