diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt index 870c0cc60a..e332009dff 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt @@ -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 } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt deleted file mode 100644 index 3ba07296b1..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/RequiresSessionAuth.kt +++ /dev/null @@ -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 ` + `DPoP: ` - * 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 \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/SessionAuthAnnotations.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/SessionAuthAnnotations.kt new file mode 100644 index 0000000000..4529e01502 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/SessionAuthAnnotations.kt @@ -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 ` (when + * available) + `DPoP: ` 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 ` + `DPoP: ` + * 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 \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/qualifier/SessionAuthQualifiers.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/qualifier/SessionAuthQualifiers.kt new file mode 100644 index 0000000000..41c8582f2e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/qualifier/SessionAuthQualifiers.kt @@ -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 ` + `DPoP: `) 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 \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 33ec143d5e..08c71a4cc5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -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, diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt b/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt index e662899b51..e2fc94b168 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt @@ -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, + @SessionAuthAuthenticator private val sessionAuthenticator: Provider, + @Named("isBackendAuthenticationEnabled") private val isBackendAuthEnabled: Provider, ) { private val configsBaseUrls: Map> = 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 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, diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt index b52f532ab4..e5b0572cd5 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthModule.kt @@ -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) + } } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt b/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt index 9db1b87cd1..17b931a593 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptor.kt @@ -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 ` — present if [SessionTokensStore] holds an access token. * - `DPoP: ` — 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)) } } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopHeaders.kt b/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopHeaders.kt new file mode 100644 index 0000000000..6b4d5b413a --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/http/DpopHeaders.kt @@ -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() || hasMethodAnnotation() + +/** + * `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() || hasMethodAnnotation() + +/** Returns a copy of this request with `Authorization: DPoP ` and `DPoP: ` 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 Request.hasMethodAnnotation(): Boolean = + tag(Invocation::class.java)?.method()?.isAnnotationPresent(A::class.java) == true \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/http/SessionAuthenticator.kt b/libs/auth/src/main/java/com/tangem/lib/auth/http/SessionAuthenticator.kt new file mode 100644 index 0000000000..8d8af019fa --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/http/SessionAuthenticator.kt @@ -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) + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt index d96da75296..66edfc68a8 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/http/DpopAuthorizationInterceptorTest.kt @@ -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() - 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() + 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() + 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() - 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() - 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() - 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() - 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() every { invocation.method() } returns method return invocation diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/http/SessionAuthenticatorTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/http/SessionAuthenticatorTest.kt new file mode 100644 index 0000000000..9a15a128e1 --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/http/SessionAuthenticatorTest.kt @@ -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() + 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() + every { invocation.method() } returns method + return invocation + } +} \ No newline at end of file