Skip to content

Commit

Permalink
🔒 deprecate BearerTokenInterceptor and provide tests and docs instead (…
Browse files Browse the repository at this point in the history
…#4068)

* deprecate BearerTokenInterceptor and provide tests and docs instead

* add to the left pane

* Update docs/source/advanced/authentication.mdx

Co-authored-by: Benoit Lubek <[email protected]>

* Update docs/source/advanced/authentication.mdx

Co-authored-by: Benoit Lubek <[email protected]>

* Update docs/source/advanced/authentication.mdx

Co-authored-by: Benoit Lubek <[email protected]>

Co-authored-by: Benoit Lubek <[email protected]>
  • Loading branch information
martinbonnin and BoD authored Apr 28, 2022
1 parent 8d05ac2 commit 6f0b798
Show file tree
Hide file tree
Showing 9 changed files with 481 additions and 361 deletions.
9 changes: 8 additions & 1 deletion apollo-runtime/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ kotlin {
}
}

val commonTest by getting {
dependencies {
implementation(projects.apolloMockserver)
implementation(projects.apolloTestingSupport)
}
}

val jvmMain by getting {
dependencies {
api(groovy.util.Eval.x(project, "x.dep.okHttp.okHttp"))
Expand Down Expand Up @@ -63,4 +70,4 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java) {
kotlinOptions {
allWarningsAsErrors = true
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
@file:Suppress("DEPRECATION")

package com.apollographql.apollo3.network.http

import com.apollographql.apollo3.annotations.ApolloDeprecatedSince
import com.apollographql.apollo3.api.http.HttpRequest
import com.apollographql.apollo3.api.http.HttpResponse
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

@Deprecated("BearerTokenInterceptor was provided as an example but is too simple for most use cases." +
"Define your own interceptor or take a look at https://www.apollographql.com/docs/kotlin/advanced/interceptors-http" +
" for more details.")
@ApolloDeprecatedSince(ApolloDeprecatedSince.Version.v3_2_3)
class BearerTokenInterceptor(private val tokenProvider: TokenProvider) : HttpInterceptor {
private val mutex = Mutex()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package com.apollographql.apollo3.network.http

import com.apollographql.apollo3.annotations.ApolloDeprecatedSince

@Deprecated("BearerTokenInterceptor was provided as an example but is too simple for most use cases." +
"Define your own interceptor or take a look at https://www.apollographql.com/docs/kotlin/advanced/interceptors-http" +
" for more details.")
@ApolloDeprecatedSince(ApolloDeprecatedSince.Version.v3_2_3)
interface TokenProvider {
suspend fun currentToken(): String
suspend fun refreshToken(previousToken: String): String
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package com.apollographql.apollo3.network

import com.apollographql.apollo3.api.http.HttpRequest
import com.apollographql.apollo3.api.http.HttpResponse
import com.apollographql.apollo3.mpp.currentTimeMillis
import com.apollographql.apollo3.network.AuthorizationInterceptor.TokenProvider
import com.apollographql.apollo3.network.http.HttpInterceptor
import com.apollographql.apollo3.network.http.HttpInterceptorChain
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

/**
* An [HttpInterceptor] that handles authentication
*
* This is provided as is and will most likely need to be adapted to different backend requirements.
*
* There is surprising amount of details that can differ between different implementation.
*
* This [AuthorizationInterceptor] assumes the tokens form a chain where each new token
* is computed based on the previous token value and invalidates all previous ones.
*
* @param tokenProvider a [TokenProvider] that gets tokens from the preferences or from the network
* @param maxSize The maximum number of links to keep. This is theoretically needed in case some requests are
* **very** slow and the token has been refreshed (by other concurrent requests) multiple times
* when the initial request receives the 401.
*
* In practice, this is very unlikely to happen and a max size of 1 should be enough for most
* scenarios
*/
class AuthorizationInterceptor(private val tokenProvider: TokenProvider, private val maxSize: Int = 1) : HttpInterceptor {
private val mutex = Mutex()

class Token(val value: String, val expiresEpochSecond: Long)

interface TokenProvider {
/**
* Load the token from preferences the first time a token is required.
*
* This function is never called concurrently. Implementation do not need to lock.
*
* @return the token from the preferences or null if no token was ever generated
*/
suspend fun loadToken(): Token?

/**
* Refreshes an existing token. This is called when a token is expired or a 401
* error is received.
*
* This function is never called concurrently. Implementation do not need to lock.
*
* Any exception thrown will bubble up to the caller
*
* @param oldToken the previous token or null if there was none
* @return a new token from the oldToken
*/
suspend fun refreshToken(oldToken: String?): Token
}

/**
* A token link in the chain
*/
class TokenLink(
val oldValue: String?,
val newValue: String,
val expiresEpochSecond: Long,
var next: TokenLink?,
)

private var head: TokenLink? = null
private var tail: TokenLink? = null
private var listSize: Int = 0

override suspend fun intercept(request: HttpRequest, chain: HttpInterceptorChain): HttpResponse {
val tokenValue = mutex.withLock {
if (tail == null) {
var token = tokenProvider.loadToken()
if (token == null) {
token = tokenProvider.refreshToken(null)
}
tail = TokenLink(
oldValue = null,
newValue = token.value,
expiresEpochSecond = token.expiresEpochSecond,
next = null
)
head = tail
listSize++
}

val link = tail!!

// Start refreshing tokens 2 seconds before they actually expire to account for
// network time
val margin = 2
if (currentTimeMillis() / 1000 + margin - link.expiresEpochSecond >= 0) {
// This token will soon expire, get a new one
val token = tokenProvider.refreshToken(link.newValue)

insert(
TokenLink(
oldValue = link.newValue,
newValue = token.value,
expiresEpochSecond = token.expiresEpochSecond,
next = null
)
)
}

tail!!.newValue
}

val response = chain.proceed(request.newBuilder().addHeader("Authorization", "Bearer $tokenValue").build())

return if (response.statusCode == 401) {
val newTokenValue: String = mutex.withLock {
var cur = head
while (cur != null) {
if (cur.oldValue == tokenValue) {
// follow the chain up to the new token
while (cur!!.next != null) {
cur = cur.next
}
// we have found a valid new token for this old token
return@withLock cur.newValue
}
cur = cur.next
}

// we haven't found a link for this old value, get a new token
val token = tokenProvider.refreshToken(tokenValue)
insert(
TokenLink(
oldValue = tokenValue,
newValue = token.value,
expiresEpochSecond = token.expiresEpochSecond,
next = null
)
)

token.value
}
chain.proceed(request.newBuilder().addHeader("Authorization", "Bearer $newTokenValue").build())
} else {
response
}
}

/**
* Insert a new link.
*
* Assumes the list is not empty
*/
private fun insert(tokenLink: TokenLink) {
tail!!.next = tokenLink
tail = tokenLink
listSize++

// Trim the list if needed
while (listSize > maxSize) {
head = head!!.next
listSize--
}
}
}
Loading

0 comments on commit 6f0b798

Please sign in to comment.