Issue whilst unit testing the Authenticated flow of the `SecuredAction`


#1

Hey,

For a hobby project that I’m working on, I’m currently using Silhouette for the authentication and authorization.
I have created a simple setup with Silhouette based on this seed: https://github.com/sbrunk/play-silhouette-slick-seed/tree/master/app/models.

All the code seems to work fine when I test it by hand, meaning that I can successfully:

  • Register users
  • ‘Login’ as user (thus receiving the JWT-token)
  • Protect endpoints to be only accessible for authenticated users (through the JWT-token in combination with silhouette.SecuredAction)

However I would also like to be able to unit test my controllers, but once I do that, I bumped into an issue, which I’ll describe now.

The issue

After creating a simple unit test (based on the documentation presented over here: https://www.silhouette.rocks/docs/testing), I encountered an issue where
the Unauthorized flow seems to work as expected, however the Authenticated flow, keeps returning an Unauthenticated result, which (as expected) causes the test to fail.
Currently I’m unable to figure out what is going wrong, and was hoping that somebody over here could help me / give me a push in the right direction.

A possible cause for the issue that I could think of, is that it might be related to a missing / incorrect dependency override from the module, however I couldn’t figure out which dependency was incorrect, thus this is mainly speculation.

I’ve included some sample code to further illustrate/demonstrate the issue that I’m encountering.

It’s entirely possible that I’m forgetting something simple or doing something illogical, since I’m new to Scala and the Play Framework,
however right now I’m a bit clueless as where to search / what could be wrong.

If I forgot to include some relevant code or if something is unclear, please let me know, then I’ll update the post!

Kind regards,

Akatchi

Output from: sbt clean test

[info] SampleControllerTest:
[info] The `submit` action
[info] - must return Unauthorized when no authenticated identity is present inside the request
[info] - must return Ok if the authenticator and identity are found *** FAILED ***
[info]   401 was not equal to 200 (SampleControllerTest.scala:68)

JwtEnv

package authentication.utils

import authentication.user.User
import com.mohiva.play.silhouette.api.Env
import com.mohiva.play.silhouette.impl.authenticators.JWTAuthenticator

trait JwtEnv extends Env {
  type I = User
  type A = JWTAuthenticator
}

SilhouetteModule

class SilhouetteModule extends AbstractModule with ScalaModule {
  override def configure(): Unit = {
    bind[CacheLayer].to[PlayCacheLayer]
    bind[Silhouette[JwtEnv]].to[SilhouetteProvider[JwtEnv]]
    bind[UserService].to[UserServiceImpl]
    bind[UserDAO].to[UserDAOImpl]
    bind[EventBus].toInstance(EventBus())
    bind[Clock].toInstance(Clock())
    bind[DelegableAuthInfoDAO[PasswordInfo]].to[PasswordInfoDAO]
  }

  @Provides
  def provideIDGenerator(implicit ec: ExecutionContext): IDGenerator =
    new SecureRandomIDGenerator()

  @Provides
  def provideAuthInfoRepository(passwordInfoDAO: DelegableAuthInfoDAO[PasswordInfo])
                               (implicit ec: ExecutionContext): AuthInfoRepository =
    new DelegableAuthInfoRepository(passwordInfoDAO)

  @Provides
  def provideEnvironment(userService: UserService,
                         authenticatorService: AuthenticatorService[JWTAuthenticator],
                         eventBus: EventBus)
                        (implicit ec: ExecutionContext): Environment[JwtEnv] =
    Environment[JwtEnv](
      userService,
      authenticatorService,
      Seq(),
      eventBus
    )

  @Provides
  @Named("authenticator-crypter")
  def provideAuthenticatorCrypter(configuration: Configuration): Crypter = {
    val config = configuration.underlying.as[JcaCrypterSettings]("silhouette.authenticator.crypter")

    new JcaCrypter(config)
  }

  @Provides
  def provideAuthenticatorService(@Named("authenticator-crypter") crypter: Crypter,
                                  idGenerator: IDGenerator,
                                  configuration: Configuration,
                                  clock: Clock)
                                 (implicit ec: ExecutionContext): AuthenticatorService[JWTAuthenticator] = {

    val config = configuration.underlying.as[JWTAuthenticatorSettings]("silhouette.authenticator")
    val encoder = new CrypterAuthenticatorEncoder(crypter)

    new JWTAuthenticatorService(config, None, encoder, idGenerator, clock)
  }

  @Provides
  def providePasswordHasherRegistry(): PasswordHasherRegistry = {
    PasswordHasherRegistry(new BCryptSha256PasswordHasher())
  }

  @Provides
  def provideCredentialsProvider(authInfoRepository: AuthInfoRepository,
                                 passwordHasherRegistry: PasswordHasherRegistry)
                                (implicit ec: ExecutionContext): CredentialsProvider =
    new CredentialsProvider(authInfoRepository, passwordHasherRegistry)

  @Provides
  def provideHTTPLayer(client: WSClient)(implicit ec: ExecutionContext): HTTPLayer = new PlayHTTPLayer(client)

  @Provides
  def provideAvatarService(httpLayer: HTTPLayer): AvatarService = new GravatarService(httpLayer)
}

SampleController

import javax.inject.Inject

import authentication.utils.JwtEnv
import com.mohiva.play.silhouette.api.Silhouette
import play.api.i18n.I18nSupport
import play.api.libs.json.{JsValue, Json}
import play.api.mvc.{AbstractController, Action, ControllerComponents}

import scala.concurrent.{ExecutionContext, Future}

class SampleController @Inject()(cc: ControllerComponents,
                                 silhouette: Silhouette[JwtEnv])
                                (implicit ec: ExecutionContext)
  extends AbstractController(cc) with I18nSupport {

  def submit: Action[JsValue] = silhouette.SecuredAction.async(parse.json) { implicit request =>
    Future.successful(Ok(Json.obj("success" -> true, "message" -> "Hey there :)")))
  }
}

SampleControllerTest

import java.util.UUID
import java.util.concurrent.TimeUnit

import akka.util.Timeout
import authentication.user.User
import authentication.utils.JwtEnv
import com.mohiva.play.silhouette.api.LoginInfo
import com.mohiva.play.silhouette.test._
import org.scalatestplus.play.PlaySpec
import org.scalatestplus.play.guice.GuiceOneAppPerSuite
import play.api.libs.json.{JsValue, Json}
import play.api.mvc.{Result, Results}
import play.api.test.{FakeHeaders, FakeRequest}
import play.api.test.Helpers.{POST, contentAsJson}

import scala.concurrent.duration.Duration
import scala.concurrent.{Await, ExecutionContext, Future}

class SampleControllerTest extends PlaySpec with GuiceOneAppPerSuite with Results {
  implicit val ec: ExecutionContext = app.injector.instanceOf[ExecutionContext]

  val fakeHeaders: FakeHeaders = FakeHeaders(Seq(
    // We need to set the json headers, otherwise we'll get a HTML response (application/text)
    // which causes issues when comparing the body
    "Accept" -> "application/json; charset=utf-8",
    "Content-Type" -> "application/json; charset=utf-8",
  ))
  val identity = User(
    UUID.randomUUID(),
    LoginInfo("credentials", "sample_user@localhost"),
    "first_name",
    "last_name",
    "sample_user@localhost",
    None,
    activated = true
  )
  implicit val env: FakeEnvironment[JwtEnv] = new FakeEnvironment[JwtEnv](Seq(identity.loginInfo -> identity))

  "The `submit` action" must {
    val controller: SampleController = app.injector.instanceOf[SampleController]

    // Based off: https://www.silhouette.rocks/docs/testing#section-simulate-a-missing-identity
    "return Unauthorized when no authenticated identity is present inside the request" in {
      val fakeRequest: FakeRequest[JsValue] = FakeRequest(
        POST, "/sample", fakeHeaders, Json.parse("{}")
      ).withAuthenticator(LoginInfo("credentials", "other_sample_user@localhost"))

      val futureResult: Future[Result] = controller.submit(fakeRequest)
      val result: Result = Await.result(futureResult, Duration.Inf)
      val body: JsValue = contentAsJson(futureResult)(Timeout(2, TimeUnit.SECONDS))

      result.header.status mustBe Unauthorized.header.status
      body mustBe Json.obj("success" -> false, "message" -> "Authentication required")
    }

    // Based off: https://www.silhouette.rocks/docs/testing#section-simulate-an-authenticated-identity
    "return Ok if the authenticator and identity are found" in {
      val fakeRequest: FakeRequest[JsValue] = FakeRequest(
        POST, "/sample", fakeHeaders, Json.parse("{}")
      ).withAuthenticator[JwtEnv](identity.loginInfo)

      val futureResult: Future[Result] = controller.submit(fakeRequest)
      val result: Result = Await.result(futureResult, Duration.Inf)
      val body: JsValue = contentAsJson(futureResult)(Timeout(2, TimeUnit.SECONDS))

      result.header.status mustBe Ok.header.status
      body mustBe Json.obj("success" -> true, "message" -> "Hey there :)")
    }
  }
}

#2

During the test your application must be instantiated with the FakeEnvironment instance.

Please have a look at this test. This is a working test. Please also have a look at the AuthSpecification.scala