Ktor: Second authentication method is ignored

Created on 17 Jun 2019  路  10Comments  路  Source: ktorio/ktor

Ktor Version

1.2.1

Ktor Engine Used (client or server and name)

Netty

JVM Version, Operating System and Relevant Context

JDK 8, Mac

Issue

I'm building a multi-layer custom authentication for my web service. I declare it like this:

// in Application.module()
install(Authentication) {
  appId(AUTH_APP_ID) { // this one is my custom one
    validate {
      if (AuthorizedApps.isReadAuthorized(it.appId)) // simplified logic
        AppIdPrincipal(AuthorizedApps.getAppName(it.appId)!!)
      else null
    }
  }
}

And routes look like this:

routing {
  authenticate(AUTH_APP_ID) {
    get("/info", controller<ServiceInformationController>()) // controller handles everything
  }
}

So... this works as expected. It was actually much easier than anticipated.

THE PROBLEM

Now I want to add another custom authentication, for example "AppSecret". Same as ID, just handles a different header (app_secret) along with this app_id header.

I won't paste the whole thing here (see the bottom for more info), but it's pretty much the same.. except for this part:

authenticate(AUTH_APP_ID, AUTH_APP_SECRET) { // observe: two authentication methods
  get("/info", controller<ServiceInformationController>())
}

I also tried the other approach, with nested authenticate blocks.

In both cases, the second authentication method is ignored.

The truth is.. yes, right now, I only need the one (secret), but let's say in the future I want to add another one, that I want to combine with the others.. for example JWT or something on top of AppID/AppSecret combo.


Why is my second authentication method completely ignored?
I debugged this, Ktor never even tries after the first auth method is successful.


Here's my custom authentication for reference (AppID):

package tokenizer.v1.security

import io.ktor.application.ApplicationCall
import io.ktor.application.call
import io.ktor.auth.Authentication
import io.ktor.auth.AuthenticationFunction
import io.ktor.auth.AuthenticationPipeline
import io.ktor.auth.AuthenticationProvider
import io.ktor.auth.Credential
import io.ktor.auth.Principal
import io.ktor.http.HttpStatusCode
import io.ktor.request.ApplicationRequest
import io.ktor.request.header
import io.ktor.response.respond
import tokenizer.v1.Configuration.Headers
import tokenizer.v1.security.AppIdAuthenticationProvider.Configuration

data class AppIdCredential(val appId: String) : Credential

data class AppIdPrincipal(val appName: String) : Principal

class AppIdAuthenticationProvider(configuration: Configuration) : AuthenticationProvider(configuration) {
  class Configuration(name: String?) : AuthenticationProvider.Configuration(name) {
    // provides a validation function that will check given `AppIdCredential` instance
    // and return `Principal` (or `null` if credential does not correspond to an authenticated principal)
    var authenticationFunction: AuthenticationFunction<AppIdCredential> = { null }

    fun validate(body: suspend ApplicationCall.(AppIdCredential) -> Principal?) {
      authenticationFunction = body
    }
  }

  val authenticationFunction = configuration.authenticationFunction
}

// installs an app ID authentication mechanism
fun Authentication.Configuration.appId(name: String? = null, configure: Configuration.() -> Unit) {
  val provider = AppIdAuthenticationProvider(Configuration(name).apply(configure))
  val authenticateFunction = provider.authenticationFunction

  provider.pipeline.intercept(AuthenticationPipeline.RequestAuthentication) { context ->
    val credential = call.request.appIdAuthenticationCredential()
    val principal = credential?.let { authenticateFunction(call, it) }

    val cause = when {
      credential == null -> call.respond(HttpStatusCode.Forbidden)
      principal == null -> call.respond(HttpStatusCode.Unauthorized)
      else -> null
    }

    if (cause != null) {
      // maybe respond with something? probably not...
    }

    principal?.let { context.principal(it) }
  }

  register(provider)
}

// retrieves App ID Credential for this `ApplicationRequest`
@Suppress("MoveVariableDeclarationIntoWhen")
fun ApplicationRequest.appIdAuthenticationCredential(): AppIdCredential? {
  val appId = header(Headers.APP_ID)
  // we need a valid app ID
  if (appId.isNullOrBlank()) return null
  // we have "something", so let's use this
  return AppIdCredential(appId)
}
design

All 10 comments

Also another thing I noticed - when my authentication fails, Ktor still invokes my route, thus invoking my Controller. This results in ResponseAlreadySentException: Response has already been sent. Is there any way to prevent this?

Instead of a direct response, you need to register an auth challenge. For example, see BasicAuthenticationProvider. Notice it.complete() that completes the auth pipeline execution.

According to the current design, ktor executes every provider until the first produce a principal. I suspect that there could be use-cases for multiple successful auth providers, but we don't have any clear evidence of that. Could you please clarify in more details why do you need it? Why is the first successful not enough? Shouldn't these AppID/AppSecret combined into a single?

@cy6erGn0m Thanks for responding, this helps.

So for multiple auth providers -- right, at the moment I can combine this into one in the current case. The other case is when I also want to add bearer-like token auth (let's say I want to generate my own tokens and not use JWT).
In this case, I'd like to verify that AppID/AppSecret combo is valid (this is Auth#1) and then afterwards also verify that the given authentication token is also valid (this is Auth#2). Not sure if this is the right way to approach the problem though, pls comment if you have thoughts on it.

As for the auth challenges -- can you give me some more info about this process? Looking at the basic auth sample you sent me (it's the same one I based my implementation on), isn't that the UI prompt that you would get from the browser? I specifically didn't want this behavior, so that's why I removed it. What kind of challenge would I need to provide in my case?

Thanks so much for the help!

So it looks like you need some provider-combinator, something like and (or all) function that combines several providers into a single one with AND rule while, currently, it is OR.

Browser's prompt is caused by WWW-Authenticate header with basic auth challenge added to Unauthorized response by the following: call.respond(UnauthorizedResponse(HttpAuthHeader.basicAuthChallenge(realm, charset))). So the only you need is to respond with UnauthorizedResponse with no challenges.

So it looks like you need some provider-combinator, something like and (or all) function that combines several providers into a single one with AND rule while, currently, it is OR.

Yes, exactly. Right now it behaves like OR.

Browser's prompt is caused by WWW-Authenticate header with basic auth challenge added to Unauthorized response by the following: call.respond(UnauthorizedResponse(HttpAuthHeader.basicAuthChallenge(realm, charset))). So the only you need is to respond with UnauthorizedResponse with no challenges.

This worked out well, thank you!

So it looks like you need some provider-combinator, something like and (or all) function that combines several providers into a single one with AND rule while, currently, it is OR.

If the configurations behaves like OR why this test fails with expected:<200> but was:<401> for the second request? It works for different kinds of providers. Is there any rule that providers must be of different kinds.

     /**
     * If one of the providers fails the other one should be tried
     */
    @Test
    fun testMultipleConfigurations()  = withTestApplication {
        application.install(Authentication) {
            basic("first") { validate { c -> if (c.name == "first") UserIdPrincipal(c.name) else null } }
            basic("second") { validate { c -> if (c.name == "second") UserIdPrincipal(c.name) else null } }
        }

        application.routing {
            authenticate("first", "second") {
                route("/both-or") {
                    handle {
                        call.respondText("OK")
                    }
                }
            }
        }

        handleRequest(HttpMethod.Post, "/both-or") {
            addHeader(
                HttpHeaders.Authorization,
                HttpAuthHeader.Single("basic", Base64.getEncoder().encodeToString("first:password".toByteArray())).render()
            )
        }.let { call ->
            assertEquals(HttpStatusCode.OK.value, call.response.status()?.value)
        }

        handleRequest(HttpMethod.Post, "/both-or") {
            addHeader(
                HttpHeaders.Authorization,
                HttpAuthHeader.Single("basic", Base64.getEncoder().encodeToString("second:password".toByteArray())).render()
            )
        }.let { call ->
            assertEquals(HttpStatusCode.OK.value, call.response.status()?.value)
        }
    }

I would also suggest using similar syntax for AND authentication

authenticate("first") {
    authenticate("second") {
        route("/both-and") { ... }
    }
}

I was wondering if I do something wrong using the same idea @VeselyJan92

authenticate("first") {
    authenticate("second") {
        route("/both-and") { ... }
    }
}

even

authenticate("first", "second", ...) {
}

for me would be natural it should pass all authentications in a list

So this is not possible?

Common scenario in REST API (Like RolesAllowed), you have top level route(auth) and for some paths is ok, but some path let say DELETE you do some extra authz

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

Was this page helpful?
0 / 5 - 0 ratings

Related issues

seanf picture seanf  路  3Comments

baruchn picture baruchn  路  3Comments

ManifoldFR picture ManifoldFR  路  4Comments

lamba92 picture lamba92  路  3Comments

shinriyo picture shinriyo  路  4Comments