Ktor: HTTP-client auth with Bearer token

Created on 24 Jun 2020  路  13Comments  路  Source: ktorio/ktor

Subsystem
Client/Server, particular relevant ktor module(s) if applicable.
ktor HTTP-client, auth

Is your feature request related to a problem? Please describe.
Currently, Ktor HTTP-client supports only 2 types of providers: Basic and Digest. But in a real-life project is a standard to use Bearer token pair - access and refresh. So it will be great to add support of Bearer tokens to multiplatform Ktor's HTTP-client.
So the problem itself can be split into 4 sub-tasks:

  1. Persist token pair in a safe place
  2. Add them to auth headers
  3. In case when the request with access token returns 401,
    3.1. pause all present requests
    3.2 refresh it with refresh token.. and so on, regular flow.
    3.3 restart interrupted requests from 3.1

Describe the solution you'd like
Create BearerAuthConfig, BearerAuthProvider like its Basic and Digest versions. Update Auth class to be able to save, load and refresh tokens. Something like this:

fun provide(): HttpClient = HttpClient {
        install(Auth) {
            bearer {
                refresh = TODO("lambda function that get new access token and return a string"),
                revoke = TODO("lambda function that removes tokens")
            }
        }
    }

Motivation to include to ktor
Add support of Bearer tokens for clients.

feature triaged

Most helpful comment

Thanks for the feature request!
What you suggest is actually 2 features:

  1. Bearer (jwt) support in HttpClient
  2. Automatic token refresh.

The first one should be added as a part of Auth feature and we'll be working on that.
And the second one (token renewal) is currently a bit out of our scope but we'll keep our eye on it in future.

All 13 comments

Thanks for the feature request!
What you suggest is actually 2 features:

  1. Bearer (jwt) support in HttpClient
  2. Automatic token refresh.

The first one should be added as a part of Auth feature and we'll be working on that.
And the second one (token renewal) is currently a bit out of our scope but we'll keep our eye on it in future.

Thank you for a quick response,
I personally think that Bearer support without refresh functionality is pretty useless and un-safe (to make access token work long enough, access token's lifetime should be extended to a week at least. According to my knowledge access token should be valid for 5-10 min). So, will be much better, if these two features will be delivered together.
If there is a way to help you in solving these tasks, please let me know.

Thank you for offering your help!
We will appreciate if you make a PR with your changes. Please tell if you need any help or guidance from us.

@Ololoshechkin,
As I understand, the whole job must happen inside the install function of Auth class:
we check if response status is Unauthorized and a current provider is BearerAuthProvider (that must be created)
if this condition was fulfilled, then run a blocking function to load a new token
after token received, continue with the original request

so I see it like this:

scope.feature(HttpSend)!!.intercept { origin, context ->
                if (origin.response.status != HttpStatusCode.Unauthorized) return@intercept origin
                if (origin.request.attributes.contains(circuitBreaker)) return@intercept origin

                var call = origin
                val candidateProviders = HashSet(feature.providers).apply { removeAll(feature.alwaysSend) }
                while (call.response.status == HttpStatusCode.Unauthorized) {
                    val headerValue = call.response.headers[HttpHeaders.WWWAuthenticate] ?: return@intercept call
                    val authHeader = parseAuthorizationHeader(headerValue) ?: return@intercept call
                    val provider = candidateProviders.find { it.isApplicable(authHeader) } ?: return@intercept call

                    when(provider) {
                        is BearerAuthProvider -> {
                            // try to refresh token, token should be stored somewhere, so `addRequestHeaders` function can load it and update auth header
                            val token = provider.refreshToken()
                            println("tokens=$token")
                            // if token is null, return 401 response to ask to login with username/pwd
                            if (token == null) return@intercept call
                        }
                    }

                    candidateProviders.remove(provider)

                    val request = HttpRequestBuilder()
                    request.takeFromWithExecutionContext(context)
                    provider.addRequestHeaders(request) // load token from a storage, insert auth header
                    request.attributes.put(circuitBreaker, Unit)

                    call = execute(request)
                }
                return@intercept call
            }

What do you think about this solution?

@Savrov ,
Thanks for the solution you provided!
I am mostly fine with it but what would happen if for some reason call.response.status would be always Unauthorized? I guess it worth limiting the number of attempts to prevent client from being forever frozen.

that's why there is a block:

 // if token is null, return 401 response to ask to login with username/pwd
 if (token == null) return@intercept call

It should work in a desired way, cause if its not - all other types (digest, basic) will fall to recursion as well. Sadly, I can not test it, cause when I'm running this simple code:

suspend fun main(args: Array<String>) {
    val client = HttpClientProvider.provide()
    GlobalScope.launch(Dispatchers.IO) {
        val response: HttpResponse = client.get("https://google.com")
        println("response=$response")
    }.join()
}

it fails with an exception

Exception in thread "DefaultDispatcher-worker-1" org.apache.http.ConnectionClosedException: Connection closed unexpectedly
at org.apache.http.nio.protocol.HttpAsyncRequestExecutor.closed(HttpAsyncRequestExecutor.java:146)
at org.apache.http.impl.nio.client.InternalIODispatch.onClosed(InternalIODispatch.java:71)
at org.apache.http.impl.nio.client.InternalIODispatch.onClosed(InternalIODispatch.java:39)
at org.apache.http.impl.nio.reactor.AbstractIODispatch.disconnected(AbstractIODispatch.java:100)
at org.apache.http.impl.nio.reactor.BaseIOReactor.sessionClosed(BaseIOReactor.java:277)
at org.apache.http.impl.nio.reactor.AbstractIOReactor.processClosedSessions(AbstractIOReactor.java:449)
at org.apache.http.impl.nio.reactor.AbstractIOReactor.hardShutdown(AbstractIOReactor.java:590)
at org.apache.http.impl.nio.reactor.AbstractIOReactor.execute(AbstractIOReactor.java:305)
at org.apache.http.impl.nio.reactor.BaseIOReactor.execute(BaseIOReactor.java:104)
at org.apache.http.impl.nio.reactor.AbstractMultiworkerIOReactor$Worker.run(AbstractMultiworkerIOReactor.java:591)
at java.lang.Thread.run(Thread.java:748)

And this is a problem only of my local build, for example, ktor version 1.3.2 has no problems with a provided snippet.
Yeah, and it fails even w/o Auth feature used. So I suggest it happens due to unrelated error to my "bearer feature". If you have any ideas, I'll be glad to hear one)

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.

Released in 1.4.0

@e5l I'm on ktor and ktor-auth 1.5.2 and I don't see this feature. Was it removed? Even looking in the source code I don't see it.

@e5l this has not been released yet. The PR was merged to the vbr/bearer-auth branch, pending finalizing the API before releasing.

Sorry, didn't notice.

Hi all,

Do we have any progress for this feature?
I am looking forward to this.

Thanks.

Merged in main. Will be available in Ktor 1.6.0

Was this page helpful?
0 / 5 - 0 ratings