Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ dependencies {
testImplementation "io.mockk:mockk:$mockk_version"
testImplementation "io.mockk:mockk-android:$mockk_version"
testImplementation "androidx.arch.core:core-testing:$android_arch_version"
// The Android stub of org.json returns defaults; the token authenticator parses error bodies with it.
testImplementation "org.json:json:20240303"
}

private def setupBranchConfigFields(buildType) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package org.openedx.app.data.networking

import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
import org.openedx.core.config.Config
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.instance.InstanceProvider
import org.openedx.core.presentation.global.AppData

class HeadersInterceptor(
private val appData: AppData,
private val config: Config,
private val preferencesManager: CorePreferences,
private val instanceProvider: InstanceProvider,
) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response = chain.run {
Expand All @@ -18,7 +21,8 @@ class HeadersInterceptor(
.newBuilder().apply {
val token = preferencesManager.accessToken

if (token.isNotEmpty()) {
// The token belongs to the selected site; never send it to another host.
if (token.isNotEmpty() && targetsActiveSite(request().url.toString())) {
addHeader("Authorization", "${config.getAccessTokenType()} $token")
}

Expand All @@ -29,4 +33,16 @@ class HeadersInterceptor(
}.build()
)
}

private fun targetsActiveSite(url: String): Boolean {
val siteUrl = instanceProvider.currentInstance?.apiHostUrl ?: config.getApiHostURL()
val site = siteUrl.toHttpUrlOrNull()
val request = url.toHttpUrlOrNull()
return when {
// An unparseable configured host can't be compared; keep the previous behavior.
site == null -> true
request == null -> false
else -> request.isSameOrigin(site)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package org.openedx.app.data.networking

import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.openedx.core.instance.InstanceProvider

/**
* Which instance a request was sent for, attached as an OkHttp tag when the request starts.
* Token refresh reads it, so a response that arrives after a switch is still handled for the
* site that sent it.
*/
data class RequestInstance(val instanceKey: String?)

/**
* Re-targets requests built against the app's base URL at the selected instance: scheme, host
* and port change, path and query stay. Requests to any other host (CDNs, third-party SDKs)
* and every request made while no instance is selected pass through untouched.
*/
class InstanceHostInterceptor(
appBaseUrl: String,
private val instanceProvider: InstanceProvider,
) : Interceptor {

private val appBase: HttpUrl? = appBaseUrl.toHttpUrlOrNull()

override fun intercept(chain: Interceptor.Chain): Response {
val instance = instanceProvider.currentInstance
val request = chain.request()
val rewritten = rewriteHostIfNeeded(request, instance?.apiHostUrl)
.newBuilder()
.tag(RequestInstance::class.java, RequestInstance(instance?.key))
.build()
return chain.proceed(rewritten)
}

internal fun rewriteHostIfNeeded(request: Request, instanceHostUrl: String?): Request {
val target = instanceHostUrl?.toHttpUrlOrNull()
val base = appBase
val url = request.url
if (target == null || base == null || !url.isSameOrigin(base) || url.isSameOrigin(target)) {
return request
}
val newUrl = url.newBuilder()
.scheme(target.scheme)
.host(target.host)
.port(target.port)
.build()
return request.newBuilder().url(newUrl).build()
}
}

internal fun HttpUrl.isSameOrigin(other: HttpUrl): Boolean {
return scheme == other.scheme && host == other.host && port == other.port
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,29 @@ import org.openedx.core.ApiConstants.TOKEN_TYPE_JWT
import org.openedx.core.BuildConfig
import org.openedx.core.config.Config
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.instance.InstanceProvider
import org.openedx.core.system.notifier.app.AppNotifier
import org.openedx.core.system.notifier.app.LogoutEvent
import org.openedx.core.utils.TimeUtils
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit

class OauthRefreshTokenAuthenticator(
private val config: Config,
private val preferencesManager: CorePreferences,
private val appNotifier: AppNotifier,
private val instanceProvider: InstanceProvider,
private val okHttpClient: OkHttpClient = defaultRefreshClient(),
) : Authenticator, Interceptor {

private val authApi: AuthApi
private val authApis = ConcurrentHashMap<String, AuthApi>()
private var lastTokenRefreshRequestTime = 0L

override fun intercept(chain: Interceptor.Chain): Response {
if (isTokenExpired()) {
if (isTokenExpired() && isForActiveInstance(chain.request())) {
val response = createUnauthorizedResponse(chain)
val request = authenticate(chain.connection()?.route(), response)

Expand All @@ -50,33 +54,41 @@ class OauthRefreshTokenAuthenticator(
return chain.proceed(chain.request())
}

init {
val okHttpClient = OkHttpClient.Builder().apply {
writeTimeout(timeout = 60, TimeUnit.SECONDS)
readTimeout(timeout = 60, TimeUnit.SECONDS)
if (BuildConfig.DEBUG) {
addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
}
}.build()
authApi = Retrofit.Builder()
.baseUrl(config.getApiHostURL())
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(Gson()))
.build()
.create(AuthApi::class.java)
/** Token endpoint of the selected instance, or of the app-level host when none is selected. */
private fun authApi(): AuthApi {
val baseUrl = instanceProvider.currentInstance?.apiHostUrl ?: config.getApiHostURL()
return authApis.getOrPut(baseUrl) {
Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(Gson()))
.build()
.create(AuthApi::class.java)
}
}

/**
* A 401 for a request sent before the learner switched sites says nothing about the site
* that is selected now: refreshing it, or logging out, would act on the wrong session.
*/
private fun isForActiveInstance(request: Request): Boolean {
val sentFor = request.tag(RequestInstance::class.java) ?: return true
return sentFor.instanceKey == instanceProvider.currentInstance?.key
}

@Suppress("ReturnCount")
@Synchronized
override fun authenticate(route: Route?, response: Response): Request? {
if (!isForActiveInstance(response.request)) return null

val accessToken = preferencesManager.accessToken
val refreshToken = preferencesManager.refreshToken

if (refreshToken.isEmpty()) return null

val errorCode = getErrorCode(response.peekBody(Long.MAX_VALUE).string()) ?: return null

return when (errorCode) {
val retry = when (errorCode) {
TOKEN_EXPIRED_ERROR_MESSAGE, JWT_TOKEN_EXPIRED -> {
handleTokenExpired(response, refreshToken, accessToken)
}
Expand All @@ -91,6 +103,9 @@ class OauthRefreshTokenAuthenticator(

else -> null
}
// Re-checked after the refresh: a switch during it must not retry this request with the
// new site's token.
return retry?.takeIf { isForActiveInstance(response.request) }
}

private fun handleDisabledUser(): Request? {
Expand Down Expand Up @@ -159,13 +174,17 @@ class OauthRefreshTokenAuthenticator(
private fun refreshAccessToken(refreshToken: String): AuthResponse? {
var authResponse: AuthResponse? = null
if (canRequestTokenRefresh()) {
val response = authApi.refreshAccessToken(
val instance = instanceProvider.currentInstance
val response = authApi().refreshAccessToken(
ApiConstants.TOKEN_TYPE_REFRESH,
config.getOAuthClientId(),
instance?.oAuthClientId ?: config.getOAuthClientId(),
refreshToken,
config.getAccessTokenType()
).execute()
authResponse = response.body()?.mapToDomain()
// The learner may have switched sites while the refresh was in flight; these tokens
// belong to the site that issued them, not to the one selected now.
if (instance?.key != instanceProvider.currentInstance?.key) return null
if (response.isSuccessful && authResponse != null) {
val newAccessToken = authResponse.accessToken ?: ""
val newRefreshToken = authResponse.refreshToken ?: ""
Expand Down Expand Up @@ -244,6 +263,14 @@ class OauthRefreshTokenAuthenticator(
companion object {
private const val HEADER_AUTHORIZATION = "Authorization"

private fun defaultRefreshClient(): OkHttpClient = OkHttpClient.Builder().apply {
writeTimeout(timeout = 60, TimeUnit.SECONDS)
readTimeout(timeout = 60, TimeUnit.SECONDS)
if (BuildConfig.DEBUG) {
addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
}
}.build()

private const val TOKEN_EXPIRED_ERROR_MESSAGE = "token_expired"
private const val TOKEN_NONEXISTENT_ERROR_MESSAGE = "token_nonexistent"
private const val TOKEN_INVALID_GRANT_ERROR_MESSAGE = "invalid_grant"
Expand Down
7 changes: 5 additions & 2 deletions app/src/main/java/org/openedx/app/di/NetworkingModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import org.openedx.app.data.api.NotificationsApi
import org.openedx.app.data.networking.AppUpgradeInterceptor
import org.openedx.app.data.networking.HandleErrorInterceptor
import org.openedx.app.data.networking.HeadersInterceptor
import org.openedx.app.data.networking.InstanceHostInterceptor
import org.openedx.app.data.networking.OauthRefreshTokenAuthenticator
import org.openedx.auth.data.api.AuthApi
import org.openedx.core.BuildConfig
Expand All @@ -22,13 +23,15 @@ import java.util.concurrent.TimeUnit

val networkingModule = module {

single { OauthRefreshTokenAuthenticator(get(), get(), get()) }
single { OauthRefreshTokenAuthenticator(get(), get(), get(), get()) }
single { InstanceHostInterceptor(get<Config>().getApiHostURL(), get()) }

single {
OkHttpClient.Builder().apply {
writeTimeout(60, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
addInterceptor(HeadersInterceptor(get(), get(), get()))
addInterceptor(get<InstanceHostInterceptor>())
addInterceptor(HeadersInterceptor(get(), get(), get(), get()))
if (BuildConfig.DEBUG) {
addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
}
Expand Down
Loading
Loading