mirror of
https://github.com/bitwarden/android.git
synced 2026-10-21 18:42:48 -05:00
PM-24565: Syncronize token refreshes to avoid duplicate requests (#5662)
This commit is contained in:
1 parent
db18e8012a
commit
85bc76d0a6
9 files changed
+365
-402
No files matched your search
@@ -2,8 +2,7 @@ package com.bitwarden.network
|
||||
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.core.data.serializer.ZonedDateTimeSerializer
|
||||
import com.bitwarden.network.authenticator.RefreshAuthenticator
|
||||
import com.bitwarden.network.interceptor.AuthTokenInterceptor
|
||||
import com.bitwarden.network.interceptor.AuthTokenManager
|
||||
import com.bitwarden.network.interceptor.BaseUrlInterceptors
|
||||
import com.bitwarden.network.interceptor.HeadersInterceptor
|
||||
import com.bitwarden.network.model.BitwardenServiceClientConfig
|
||||
@@ -52,8 +51,7 @@ internal class BitwardenServiceClientImpl(
|
||||
private val bitwardenServiceClientConfig: BitwardenServiceClientConfig,
|
||||
) : BitwardenServiceClient {
|
||||
|
||||
private val refreshAuthenticator: RefreshAuthenticator = RefreshAuthenticator()
|
||||
private val authTokenInterceptor: AuthTokenInterceptor = AuthTokenInterceptor(
|
||||
private val authTokenManager: AuthTokenManager = AuthTokenManager(
|
||||
clock = bitwardenServiceClientConfig.clock,
|
||||
authTokenProvider = bitwardenServiceClientConfig.authTokenProvider,
|
||||
)
|
||||
@@ -75,7 +73,7 @@ internal class BitwardenServiceClientImpl(
|
||||
}
|
||||
private val retrofits: Retrofits by lazy {
|
||||
RetrofitsImpl(
|
||||
authTokenInterceptor = authTokenInterceptor,
|
||||
authTokenManager = authTokenManager,
|
||||
baseUrlInterceptors = BaseUrlInterceptors(
|
||||
baseUrlsProvider = bitwardenServiceClientConfig.baseUrlsProvider,
|
||||
),
|
||||
@@ -84,7 +82,6 @@ internal class BitwardenServiceClientImpl(
|
||||
clientName = bitwardenServiceClientConfig.clientData.clientName,
|
||||
clientVersion = bitwardenServiceClientConfig.clientData.clientVersion,
|
||||
),
|
||||
refreshAuthenticator = refreshAuthenticator,
|
||||
logHttpBody = bitwardenServiceClientConfig.enableHttpBodyLogging,
|
||||
certificateProvider = bitwardenServiceClientConfig.certificateProvider,
|
||||
json = clientJson,
|
||||
@@ -206,7 +203,6 @@ internal class BitwardenServiceClientImpl(
|
||||
}
|
||||
|
||||
override fun setRefreshTokenProvider(refreshTokenProvider: RefreshTokenProvider?) {
|
||||
refreshAuthenticator.refreshTokenProvider = refreshTokenProvider
|
||||
authTokenInterceptor.refreshTokenProvider = refreshTokenProvider
|
||||
authTokenManager.refreshTokenProvider = refreshTokenProvider
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package com.bitwarden.network.authenticator
|
||||
|
||||
import com.bitwarden.network.provider.RefreshTokenProvider
|
||||
import com.bitwarden.network.util.HEADER_KEY_AUTHORIZATION
|
||||
import com.bitwarden.network.util.HEADER_VALUE_BEARER_PREFIX
|
||||
import com.bitwarden.network.util.parseJwtTokenDataOrNull
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* An authenticator used to refresh the access token when a 401 is returned from an API. Upon
|
||||
* successfully getting a new access token, the original request is retried.
|
||||
*/
|
||||
internal class RefreshAuthenticator : Authenticator {
|
||||
|
||||
var refreshTokenProvider: RefreshTokenProvider? = null
|
||||
|
||||
override fun authenticate(
|
||||
route: Route?,
|
||||
response: Response,
|
||||
): Request? {
|
||||
if (response.shouldSkipAuthentication()) {
|
||||
// If the same request keeps failing, let's just let the 401 pass through.
|
||||
return null
|
||||
}
|
||||
val accessToken = requireNotNull(
|
||||
response
|
||||
.request
|
||||
.header(name = HEADER_KEY_AUTHORIZATION)
|
||||
?.substringAfter(delimiter = HEADER_VALUE_BEARER_PREFIX),
|
||||
)
|
||||
return when (val userId = parseJwtTokenDataOrNull(accessToken)?.userId) {
|
||||
null -> {
|
||||
// We are unable to get the user ID, let's just let the 401 pass through.
|
||||
null
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.d("Attempting to refresh token due to unauthorized")
|
||||
refreshTokenProvider
|
||||
?.refreshAccessTokenSynchronously(userId = userId)
|
||||
?.fold(
|
||||
onFailure = { null },
|
||||
onSuccess = { newAccessToken ->
|
||||
response
|
||||
.request
|
||||
.newBuilder()
|
||||
.header(
|
||||
name = HEADER_KEY_AUTHORIZATION,
|
||||
value = "$HEADER_VALUE_BEARER_PREFIX$newAccessToken",
|
||||
)
|
||||
.build()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Response.shouldSkipAuthentication(): Boolean = this.priorResponse != null
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package com.bitwarden.network.interceptor
|
||||
|
||||
import com.bitwarden.network.provider.RefreshTokenProvider
|
||||
import com.bitwarden.network.util.HEADER_KEY_AUTHORIZATION
|
||||
import com.bitwarden.network.util.HEADER_VALUE_BEARER_PREFIX
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
private const val MISSING_TOKEN_MESSAGE: String = "Auth token is missing!"
|
||||
private const val MISSING_PROVIDER_MESSAGE: String = "Refresh token provider is missing!"
|
||||
private const val EXPIRATION_OFFSET_MINUTES: Long = 5L
|
||||
|
||||
/**
|
||||
* Interceptor responsible for adding the auth token(Bearer) to API requests.
|
||||
*/
|
||||
internal class AuthTokenInterceptor(
|
||||
private val clock: Clock,
|
||||
private val authTokenProvider: AuthTokenProvider,
|
||||
) : Interceptor {
|
||||
var refreshTokenProvider: RefreshTokenProvider? = null
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val tokenData = authTokenProvider
|
||||
.getAuthTokenDataOrNull()
|
||||
?: throw IOException(IllegalStateException(MISSING_TOKEN_MESSAGE))
|
||||
val expirationTime = Instant
|
||||
.ofEpochSecond(tokenData.expiresAtSec)
|
||||
.minus(EXPIRATION_OFFSET_MINUTES, ChronoUnit.MINUTES)
|
||||
val token = if (clock.instant().isAfter(expirationTime)) {
|
||||
Timber.d("Attempting to refresh token due to expiration")
|
||||
refreshTokenProvider
|
||||
?.refreshAccessTokenSynchronously(userId = tokenData.userId)
|
||||
?.getOrElse { throw IOException(it) }
|
||||
?: throw IOException(IllegalStateException(MISSING_PROVIDER_MESSAGE))
|
||||
} else {
|
||||
tokenData.accessToken
|
||||
}
|
||||
val request = chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.addHeader(
|
||||
name = HEADER_KEY_AUTHORIZATION,
|
||||
value = "${HEADER_VALUE_BEARER_PREFIX}$token",
|
||||
)
|
||||
.build()
|
||||
return chain
|
||||
.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.bitwarden.network.interceptor
|
||||
|
||||
import com.bitwarden.network.provider.RefreshTokenProvider
|
||||
import com.bitwarden.network.util.HEADER_KEY_AUTHORIZATION
|
||||
import com.bitwarden.network.util.HEADER_VALUE_BEARER_PREFIX
|
||||
import com.bitwarden.network.util.parseJwtTokenDataOrNull
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
private const val MISSING_TOKEN_MESSAGE: String = "Auth token is missing!"
|
||||
private const val MISSING_PROVIDER_MESSAGE: String = "Refresh token provider is missing!"
|
||||
private const val EXPIRATION_OFFSET_MINUTES: Long = 5L
|
||||
|
||||
/**
|
||||
* Interceptor responsible for adding the auth token(Bearer) to API requests.
|
||||
*/
|
||||
internal class AuthTokenManager(
|
||||
private val clock: Clock,
|
||||
private val authTokenProvider: AuthTokenProvider,
|
||||
) : Authenticator, Interceptor {
|
||||
var refreshTokenProvider: RefreshTokenProvider? = null
|
||||
|
||||
override fun authenticate(
|
||||
route: Route?,
|
||||
response: Response,
|
||||
): Request? {
|
||||
synchronized(this) {
|
||||
if (response.shouldSkipAuthentication()) {
|
||||
// If the same request keeps failing, let's just let the 401 pass through.
|
||||
return null
|
||||
}
|
||||
val accessToken = requireNotNull(
|
||||
response
|
||||
.request
|
||||
.header(name = HEADER_KEY_AUTHORIZATION)
|
||||
?.substringAfter(delimiter = HEADER_VALUE_BEARER_PREFIX),
|
||||
)
|
||||
return when (val userId = parseJwtTokenDataOrNull(accessToken)?.userId) {
|
||||
null -> {
|
||||
// We are unable to get the user ID, let's just let the 401 pass through.
|
||||
null
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.d("Attempting to refresh token due to unauthorized")
|
||||
refreshTokenProvider
|
||||
?.refreshAccessTokenSynchronously(userId = userId)
|
||||
?.fold(
|
||||
onFailure = { null },
|
||||
onSuccess = { newAccessToken ->
|
||||
response
|
||||
.request
|
||||
.newBuilder()
|
||||
.header(
|
||||
name = HEADER_KEY_AUTHORIZATION,
|
||||
value = "$HEADER_VALUE_BEARER_PREFIX$newAccessToken",
|
||||
)
|
||||
.build()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = synchronized(this) {
|
||||
val tokenData = authTokenProvider
|
||||
.getAuthTokenDataOrNull()
|
||||
?: throw IOException(IllegalStateException(MISSING_TOKEN_MESSAGE))
|
||||
val expirationTime = Instant
|
||||
.ofEpochSecond(tokenData.expiresAtSec)
|
||||
.minus(EXPIRATION_OFFSET_MINUTES, ChronoUnit.MINUTES)
|
||||
if (clock.instant().isAfter(expirationTime)) {
|
||||
Timber.d("Attempting to refresh token due to expiration")
|
||||
refreshTokenProvider
|
||||
?.refreshAccessTokenSynchronously(userId = tokenData.userId)
|
||||
?.getOrElse { throw IOException(it) }
|
||||
?: throw IOException(IllegalStateException(MISSING_PROVIDER_MESSAGE))
|
||||
} else {
|
||||
tokenData.accessToken
|
||||
}
|
||||
}
|
||||
val request = chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.addHeader(
|
||||
name = HEADER_KEY_AUTHORIZATION,
|
||||
value = "${HEADER_VALUE_BEARER_PREFIX}$token",
|
||||
)
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun Response.shouldSkipAuthentication(): Boolean = this.priorResponse != null
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
package com.bitwarden.network.retrofit
|
||||
|
||||
import com.bitwarden.network.authenticator.RefreshAuthenticator
|
||||
import com.bitwarden.network.core.NetworkResultCallAdapterFactory
|
||||
import com.bitwarden.network.interceptor.AuthTokenInterceptor
|
||||
import com.bitwarden.network.interceptor.AuthTokenManager
|
||||
import com.bitwarden.network.interceptor.BaseUrlInterceptor
|
||||
import com.bitwarden.network.interceptor.BaseUrlInterceptors
|
||||
import com.bitwarden.network.interceptor.HeadersInterceptor
|
||||
@@ -27,10 +26,9 @@ import javax.net.ssl.X509TrustManager
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
internal class RetrofitsImpl(
|
||||
authTokenInterceptor: AuthTokenInterceptor,
|
||||
authTokenManager: AuthTokenManager,
|
||||
baseUrlInterceptors: BaseUrlInterceptors,
|
||||
headersInterceptor: HeadersInterceptor,
|
||||
refreshAuthenticator: RefreshAuthenticator?,
|
||||
json: Json,
|
||||
private val certificateProvider: CertificateProvider,
|
||||
private val logHttpBody: Boolean = false,
|
||||
@@ -105,10 +103,8 @@ internal class RetrofitsImpl(
|
||||
private val authenticatedOkHttpClient: OkHttpClient by lazy {
|
||||
baseOkHttpClient
|
||||
.newBuilder()
|
||||
.addInterceptor(authTokenInterceptor)
|
||||
.also { builder ->
|
||||
refreshAuthenticator?.let { builder.authenticator(it) }
|
||||
}
|
||||
.addInterceptor(authTokenManager)
|
||||
.authenticator(authTokenManager)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
package com.bitwarden.network.authenticator
|
||||
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.network.model.JwtTokenDataJson
|
||||
import com.bitwarden.network.provider.RefreshTokenProvider
|
||||
import com.bitwarden.network.util.parseJwtTokenDataOrNull
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RefreshAuthenticatorTests {
|
||||
private lateinit var authenticator: RefreshAuthenticator
|
||||
private val refreshTokenProvider: RefreshTokenProvider = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
authenticator = RefreshAuthenticator()
|
||||
authenticator.refreshTokenProvider = refreshTokenProvider
|
||||
|
||||
mockkStatic(::parseJwtTokenDataOrNull)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(::parseJwtTokenDataOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RefreshAuthenticator returns null if API has no authorization user ID`() {
|
||||
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns null
|
||||
|
||||
assertNull(authenticator.authenticate(null, RESPONSE_401))
|
||||
|
||||
verify(exactly = 0) {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RefreshAuthenticator returns null when refresh is failure`() {
|
||||
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns JTW_TOKEN
|
||||
every {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
assertNull(authenticator.authenticate(null, RESPONSE_401))
|
||||
|
||||
verify(exactly = 1) {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RefreshAuthenticator returns null when refreshTokenProvider is null`() {
|
||||
authenticator.refreshTokenProvider = null
|
||||
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns JTW_TOKEN
|
||||
assertNull(authenticator.authenticate(null, RESPONSE_401))
|
||||
verify(exactly = 0) {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RefreshAuthenticator returns updated request when refresh is success`() {
|
||||
val newAccessToken = "newAccessToken"
|
||||
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns JTW_TOKEN
|
||||
every {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
|
||||
} returns newAccessToken.asSuccess()
|
||||
|
||||
val authenticatedRequest = authenticator.authenticate(null, RESPONSE_401)
|
||||
|
||||
// The okhttp3 Request is not a data class and does not implement equals
|
||||
// so we are manually checking that the correct header is added.
|
||||
assertEquals(
|
||||
"Bearer $newAccessToken",
|
||||
authenticatedRequest!!.header("Authorization"),
|
||||
)
|
||||
verify(exactly = 1) {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val USER_ID = "2a135b23-e1fb-42c9-bec3-573857bc8181"
|
||||
|
||||
private val JTW_TOKEN = JwtTokenDataJson(
|
||||
userId = USER_ID,
|
||||
email = "test@bitwarden.com",
|
||||
isEmailVerified = true,
|
||||
name = "Bitwarden Tester",
|
||||
expirationAsEpochTime = 1697495714,
|
||||
hasPremium = false,
|
||||
authenticationMethodsReference = listOf("Application"),
|
||||
)
|
||||
|
||||
private const val JWT_ACCESS_TOKEN = "jwt"
|
||||
|
||||
private val RESPONSE_401 = Response.Builder()
|
||||
.code(401)
|
||||
.request(
|
||||
request = Request.Builder()
|
||||
.header(name = "Authorization", value = "Bearer $JWT_ACCESS_TOKEN")
|
||||
.url("https://www.bitwarden.com")
|
||||
.build(),
|
||||
)
|
||||
.protocol(Protocol.HTTP_2)
|
||||
.message("Unauthenticated")
|
||||
.build()
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
package com.bitwarden.network.interceptor
|
||||
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.network.model.AuthTokenData
|
||||
import com.bitwarden.network.provider.RefreshTokenProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import okhttp3.Request
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class AuthTokenInterceptorTest {
|
||||
private val mockAuthTokenProvider = mockk<AuthTokenProvider> {
|
||||
every { getAuthTokenDataOrNull() } returns null
|
||||
}
|
||||
private val interceptor: AuthTokenInterceptor = AuthTokenInterceptor(
|
||||
clock = FIXED_CLOCK,
|
||||
authTokenProvider = mockAuthTokenProvider,
|
||||
)
|
||||
private val request: Request = Request
|
||||
.Builder()
|
||||
.url("http://localhost")
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun `intercept should add the auth token when set`() {
|
||||
val authTokenData = AuthTokenData(
|
||||
userId = USER_ID,
|
||||
accessToken = ACCESS_TOKEN,
|
||||
expiresAtSec = FIXED_CLOCK.instant().epochSecond + 3600L,
|
||||
)
|
||||
every { mockAuthTokenProvider.getAuthTokenDataOrNull() } returns authTokenData
|
||||
|
||||
val response = interceptor.intercept(
|
||||
chain = FakeInterceptorChain(request = request),
|
||||
)
|
||||
assertEquals(
|
||||
"Bearer $ACCESS_TOKEN",
|
||||
response.request.header("Authorization"),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `intercept should throw an exception when auth token is expired and refreshTokenProvider is missing`() {
|
||||
val authTokenData = AuthTokenData(
|
||||
userId = USER_ID,
|
||||
accessToken = ACCESS_TOKEN,
|
||||
expiresAtSec = FIXED_CLOCK.instant().epochSecond - 3600L,
|
||||
)
|
||||
every { mockAuthTokenProvider.getAuthTokenDataOrNull() } returns authTokenData
|
||||
|
||||
val throwable = assertThrows(IOException::class.java) {
|
||||
interceptor.intercept(
|
||||
chain = FakeInterceptorChain(request = request),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
"Refresh token provider is missing!",
|
||||
throwable.cause?.message,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `intercept should throw an exception when auth token is expired and refreshAccessTokenSynchronously returns an error`() {
|
||||
val errorMessage = "Fail!"
|
||||
interceptor.refreshTokenProvider = object : RefreshTokenProvider {
|
||||
override fun refreshAccessTokenSynchronously(
|
||||
userId: String,
|
||||
): Result<String> = Throwable(errorMessage).asFailure()
|
||||
}
|
||||
val authTokenData = AuthTokenData(
|
||||
userId = USER_ID,
|
||||
accessToken = ACCESS_TOKEN,
|
||||
expiresAtSec = FIXED_CLOCK.instant().epochSecond - 3600L,
|
||||
)
|
||||
every { mockAuthTokenProvider.getAuthTokenDataOrNull() } returns authTokenData
|
||||
|
||||
val throwable = assertThrows(IOException::class.java) {
|
||||
interceptor.intercept(
|
||||
chain = FakeInterceptorChain(request = request),
|
||||
)
|
||||
}
|
||||
assertEquals(errorMessage, throwable.cause?.message)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `intercept should add the auth token when auth token is expired and refreshAccessTokenSynchronously returns new token`() {
|
||||
val token = "token"
|
||||
interceptor.refreshTokenProvider = object : RefreshTokenProvider {
|
||||
override fun refreshAccessTokenSynchronously(
|
||||
userId: String,
|
||||
): Result<String> = token.asSuccess()
|
||||
}
|
||||
val authTokenData = AuthTokenData(
|
||||
userId = USER_ID,
|
||||
accessToken = ACCESS_TOKEN,
|
||||
expiresAtSec = FIXED_CLOCK.instant().epochSecond - 3600L,
|
||||
)
|
||||
every { mockAuthTokenProvider.getAuthTokenDataOrNull() } returns authTokenData
|
||||
|
||||
val response = interceptor.intercept(
|
||||
chain = FakeInterceptorChain(request = request),
|
||||
)
|
||||
assertEquals(
|
||||
"Bearer $token",
|
||||
response.request.header("Authorization"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `intercept should throw an exception when an auth token data is missing`() {
|
||||
val throwable = assertThrows(IOException::class.java) {
|
||||
interceptor.intercept(
|
||||
chain = FakeInterceptorChain(request = request),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
"Auth token is missing!",
|
||||
throwable.cause?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val FIXED_CLOCK: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
private const val USER_ID: String = "user_id"
|
||||
private const val ACCESS_TOKEN: String = "access_token"
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.bitwarden.network.interceptor
|
||||
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.network.model.AuthTokenData
|
||||
import com.bitwarden.network.model.JwtTokenDataJson
|
||||
import com.bitwarden.network.provider.RefreshTokenProvider
|
||||
import com.bitwarden.network.util.parseJwtTokenDataOrNull
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.IOException
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class AuthTokenManagerTest {
|
||||
private val mockAuthTokenProvider = mockk<AuthTokenProvider> {
|
||||
every { getAuthTokenDataOrNull() } returns null
|
||||
}
|
||||
private val refreshTokenProvider: RefreshTokenProvider = mockk()
|
||||
private val authTokenManager: AuthTokenManager = AuthTokenManager(
|
||||
clock = FIXED_CLOCK,
|
||||
authTokenProvider = mockAuthTokenProvider,
|
||||
)
|
||||
private val request: Request = Request
|
||||
.Builder()
|
||||
.url("http://localhost")
|
||||
.build()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
authTokenManager.refreshTokenProvider = refreshTokenProvider
|
||||
mockkStatic(::parseJwtTokenDataOrNull)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(::parseJwtTokenDataOrNull)
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Authenticator {
|
||||
@Test
|
||||
fun `returns null if API has no authorization user ID`() {
|
||||
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns null
|
||||
|
||||
assertNull(authTokenManager.authenticate(null, RESPONSE_401))
|
||||
|
||||
verify(exactly = 0) {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when refresh is failure`() {
|
||||
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns JTW_TOKEN
|
||||
every {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
assertNull(authTokenManager.authenticate(null, RESPONSE_401))
|
||||
|
||||
verify(exactly = 1) {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when refreshTokenProvider is null`() {
|
||||
authTokenManager.refreshTokenProvider = null
|
||||
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns JTW_TOKEN
|
||||
assertNull(authTokenManager.authenticate(null, RESPONSE_401))
|
||||
verify(exactly = 0) {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns updated request when refresh is success`() {
|
||||
val newAccessToken = "newAccessToken"
|
||||
every { parseJwtTokenDataOrNull(JWT_ACCESS_TOKEN) } returns JTW_TOKEN
|
||||
every {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
|
||||
} returns newAccessToken.asSuccess()
|
||||
|
||||
val authenticatedRequest = authTokenManager.authenticate(null, RESPONSE_401)
|
||||
|
||||
// The okhttp3 Request is not a data class and does not implement equals
|
||||
// so we are manually checking that the correct header is added.
|
||||
assertEquals(
|
||||
"Bearer $newAccessToken",
|
||||
authenticatedRequest!!.header("Authorization"),
|
||||
)
|
||||
verify(exactly = 1) {
|
||||
refreshTokenProvider.refreshAccessTokenSynchronously(USER_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Interceptor {
|
||||
@Test
|
||||
fun `intercept should add the auth token when set`() {
|
||||
val authTokenData = AuthTokenData(
|
||||
userId = USER_ID,
|
||||
accessToken = ACCESS_TOKEN,
|
||||
expiresAtSec = FIXED_CLOCK.instant().epochSecond + 3600L,
|
||||
)
|
||||
every { mockAuthTokenProvider.getAuthTokenDataOrNull() } returns authTokenData
|
||||
|
||||
val response = authTokenManager.intercept(
|
||||
chain = FakeInterceptorChain(request = request),
|
||||
)
|
||||
assertEquals(
|
||||
"Bearer $ACCESS_TOKEN",
|
||||
response.request.header("Authorization"),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `intercept should throw an exception when auth token is expired and refreshTokenProvider is missing`() {
|
||||
val authTokenData = AuthTokenData(
|
||||
userId = USER_ID,
|
||||
accessToken = ACCESS_TOKEN,
|
||||
expiresAtSec = FIXED_CLOCK.instant().epochSecond - 3600L,
|
||||
)
|
||||
authTokenManager.refreshTokenProvider = null
|
||||
every { mockAuthTokenProvider.getAuthTokenDataOrNull() } returns authTokenData
|
||||
val throwable = assertThrows(IOException::class.java) {
|
||||
authTokenManager.intercept(
|
||||
chain = FakeInterceptorChain(request = request),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
"Refresh token provider is missing!",
|
||||
throwable.cause?.message,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `intercept should throw an exception when auth token is expired and refreshAccessTokenSynchronously returns an error`() {
|
||||
val errorMessage = "Fail!"
|
||||
authTokenManager.refreshTokenProvider = object : RefreshTokenProvider {
|
||||
override fun refreshAccessTokenSynchronously(
|
||||
userId: String,
|
||||
): Result<String> = Throwable(errorMessage).asFailure()
|
||||
}
|
||||
val authTokenData = AuthTokenData(
|
||||
userId = USER_ID,
|
||||
accessToken = ACCESS_TOKEN,
|
||||
expiresAtSec = FIXED_CLOCK.instant().epochSecond - 3600L,
|
||||
)
|
||||
every { mockAuthTokenProvider.getAuthTokenDataOrNull() } returns authTokenData
|
||||
|
||||
val throwable = assertThrows(IOException::class.java) {
|
||||
authTokenManager.intercept(
|
||||
chain = FakeInterceptorChain(request = request),
|
||||
)
|
||||
}
|
||||
assertEquals(errorMessage, throwable.cause?.message)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `intercept should add the auth token when auth token is expired and refreshAccessTokenSynchronously returns new token`() {
|
||||
val token = "token"
|
||||
authTokenManager.refreshTokenProvider = object : RefreshTokenProvider {
|
||||
override fun refreshAccessTokenSynchronously(
|
||||
userId: String,
|
||||
): Result<String> = token.asSuccess()
|
||||
}
|
||||
val authTokenData = AuthTokenData(
|
||||
userId = USER_ID,
|
||||
accessToken = ACCESS_TOKEN,
|
||||
expiresAtSec = FIXED_CLOCK.instant().epochSecond - 3600L,
|
||||
)
|
||||
every { mockAuthTokenProvider.getAuthTokenDataOrNull() } returns authTokenData
|
||||
|
||||
val response = authTokenManager.intercept(
|
||||
chain = FakeInterceptorChain(request = request),
|
||||
)
|
||||
assertEquals(
|
||||
"Bearer $token",
|
||||
response.request.header("Authorization"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `intercept should throw an exception when an auth token data is missing`() {
|
||||
val throwable = assertThrows(IOException::class.java) {
|
||||
authTokenManager.intercept(
|
||||
chain = FakeInterceptorChain(request = request),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
"Auth token is missing!",
|
||||
throwable.cause?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val FIXED_CLOCK: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
private const val USER_ID: String = "user_id"
|
||||
private const val ACCESS_TOKEN: String = "access_token"
|
||||
|
||||
private val JTW_TOKEN = JwtTokenDataJson(
|
||||
userId = USER_ID,
|
||||
email = "test@bitwarden.com",
|
||||
isEmailVerified = true,
|
||||
name = "Bitwarden Tester",
|
||||
expirationAsEpochTime = 1697495714,
|
||||
hasPremium = false,
|
||||
authenticationMethodsReference = listOf("Application"),
|
||||
)
|
||||
|
||||
private const val JWT_ACCESS_TOKEN = "jwt"
|
||||
|
||||
private val RESPONSE_401 = Response.Builder()
|
||||
.code(401)
|
||||
.request(
|
||||
request = Request.Builder()
|
||||
.header(name = "Authorization", value = "Bearer $JWT_ACCESS_TOKEN")
|
||||
.url("https://www.bitwarden.com")
|
||||
.build(),
|
||||
)
|
||||
.protocol(Protocol.HTTP_2)
|
||||
.message("Unauthenticated")
|
||||
.build()
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.bitwarden.network.retrofit
|
||||
|
||||
import com.bitwarden.network.authenticator.RefreshAuthenticator
|
||||
import com.bitwarden.network.interceptor.AuthTokenInterceptor
|
||||
import com.bitwarden.network.interceptor.AuthTokenManager
|
||||
import com.bitwarden.network.interceptor.BaseUrlInterceptors
|
||||
import com.bitwarden.network.interceptor.HeadersInterceptor
|
||||
import com.bitwarden.network.model.NetworkResult
|
||||
@@ -32,7 +31,8 @@ import retrofit2.create
|
||||
import retrofit2.http.GET
|
||||
|
||||
class RetrofitsTest {
|
||||
private val authTokenInterceptor = mockk<AuthTokenInterceptor> {
|
||||
private val authTokenManager = mockk<AuthTokenManager> {
|
||||
mockAuthenticate { isRefreshAuthenticatorCalled = true }
|
||||
mockIntercept { isAuthInterceptorCalled = true }
|
||||
}
|
||||
private val baseUrlInterceptors = mockk<BaseUrlInterceptors> {
|
||||
@@ -49,9 +49,6 @@ class RetrofitsTest {
|
||||
private val headersInterceptors = mockk<HeadersInterceptor> {
|
||||
mockIntercept { isHeadersInterceptorCalled = true }
|
||||
}
|
||||
private val refreshAuthenticator = mockk<RefreshAuthenticator> {
|
||||
mockAuthenticate { isRefreshAuthenticatorCalled = true }
|
||||
}
|
||||
private val json = Json
|
||||
private val server = MockWebServer()
|
||||
private val certificateProvider = mockk<CertificateProvider> {
|
||||
@@ -61,10 +58,9 @@ class RetrofitsTest {
|
||||
}
|
||||
|
||||
private val retrofits = RetrofitsImpl(
|
||||
authTokenInterceptor = authTokenInterceptor,
|
||||
authTokenManager = authTokenManager,
|
||||
baseUrlInterceptors = baseUrlInterceptors,
|
||||
headersInterceptor = headersInterceptors,
|
||||
refreshAuthenticator = refreshAuthenticator,
|
||||
certificateProvider = certificateProvider,
|
||||
json = json,
|
||||
)
|
||||
@@ -281,10 +277,9 @@ class RetrofitsTest {
|
||||
anyConstructed<OkHttpClient.Builder>().sslSocketFactory(any(), any())
|
||||
} returns mockk(relaxed = true)
|
||||
val retrofits = RetrofitsImpl(
|
||||
authTokenInterceptor = authTokenInterceptor,
|
||||
authTokenManager = authTokenManager,
|
||||
baseUrlInterceptors = baseUrlInterceptors,
|
||||
headersInterceptor = headersInterceptors,
|
||||
refreshAuthenticator = refreshAuthenticator,
|
||||
certificateProvider = certificateProvider,
|
||||
json = json,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user