diff --git a/app/auth/AccessScopes.scala b/app/auth/AccessScopes.scala new file mode 100644 index 0000000..16f6cea --- /dev/null +++ b/app/auth/AccessScopes.scala @@ -0,0 +1,10 @@ +package auth + +import com.gu.identity.auth.AccessScope + +object AccessScopes { + + case object UserReadSelfSecure extends AccessScope { + val name = "guardian.identity-api.user.read.self.secure" + } +} diff --git a/app/auth/AuthorisedAction.scala b/app/auth/AuthorisedAction.scala new file mode 100644 index 0000000..048e53f --- /dev/null +++ b/app/auth/AuthorisedAction.scala @@ -0,0 +1,40 @@ +package auth + +import com.gu.identity.auth.* +import play.api.mvc.* +import play.api.mvc.Results.* + +import scala.concurrent.{ExecutionContext, Future} + +/** An action that requires a valid access token with the given required access scopes. If the token is valid, the + * request is passed to the next action in the chain. + * + * If the request doesn't have an Authorization header or the bearer token in the header isn't a valid access token, + * the action returns a 401 Unauthorized response. If the token is valid but doesn't have the required scopes, the + * action returns a 403 Forbidden response. + */ +class AuthorisedAction(oktaAuthService: OktaAuthService, requiredScopes: List[AccessScope])(implicit + val executionContext: ExecutionContext +) extends ActionBuilder[RequestWithClaims, AnyContent] + with ActionRefiner[Request, RequestWithClaims] { + + def parser: BodyParser[AnyContent] = BodyParsers.utils.ignore(AnyContentAsEmpty: AnyContent) + + protected def refine[A](request: Request[A]): Future[Either[Result, RequestWithClaims[A]]] = { + Helpers.fetchBearerTokenFromAuthHeader(request.headers.get) match { + case Left(_) => Future.successful(Left(Unauthorized("Request has no Authorization header"))) + case Right(token) => + oktaAuthService + .validateAccessToken(AccessToken(token), requiredScopes) + .redeem( + { + case OktaValidationException(err: ValidationError) => + Left(new Status(err.suggestedHttpResponseCode)(err.message)) + case err => Left(InternalServerError(err.getMessage)) + }, + claims => Right(RequestWithClaims(claims, request)) + ) + .unsafeToFuture() + } + } +} diff --git a/app/auth/RequestWithClaims.scala b/app/auth/RequestWithClaims.scala new file mode 100644 index 0000000..d8e9afc --- /dev/null +++ b/app/auth/RequestWithClaims.scala @@ -0,0 +1,6 @@ +package auth + +import com.gu.identity.auth.DefaultAccessClaims +import play.api.mvc.{Request, WrappedRequest} + +class RequestWithClaims[A](val claims: DefaultAccessClaims, request: Request[A]) extends WrappedRequest[A](request) diff --git a/app/controllers/UserController.scala b/app/controllers/UserController.scala new file mode 100644 index 0000000..bcad892 --- /dev/null +++ b/app/controllers/UserController.scala @@ -0,0 +1,15 @@ +package controllers + +import auth.AccessScopes.UserReadSelfSecure +import auth.AuthorisedAction +import com.gu.identity.auth.AccessScope +import play.api.* +import play.api.mvc.* + +class UserController( + val controllerComponents: ControllerComponents, + authorisedAction: List[AccessScope] => AuthorisedAction +) extends BaseController { + + def me(): Action[AnyContent] = authorisedAction(List(UserReadSelfSecure))(NotImplemented("TODO: implement me")) +} diff --git a/app/load/AppComponents.scala b/app/load/AppComponents.scala index bb8e0a7..31b4d01 100644 --- a/app/load/AppComponents.scala +++ b/app/load/AppComponents.scala @@ -1,21 +1,31 @@ package load +import auth.AuthorisedAction +import com.gu.identity.auth.{OktaAudience, OktaAuthService, OktaIssuerUrl, OktaTokenValidationConfig} +import controllers.{HealthCheckController, UserController} import logging.RequestLoggingFilter import play.api.ApplicationLoader.Context +import play.api.BuiltInComponentsFromContext import play.api.mvc.EssentialFilter -import play.api.{BuiltInComponentsFromContext, Configuration} import play.filters.HttpFiltersComponents import router.Routes -import software.amazon.awssdk.regions.Region -import software.amazon.awssdk.services.ssm.SsmClient -import software.amazon.awssdk.services.ssm.model.GetParameterRequest - -import scala.util.Using class AppComponents(context: Context) extends BuiltInComponentsFromContext(context) with HttpFiltersComponents { override def httpFilters: Seq[EssentialFilter] = super.httpFilters :+ new RequestLoggingFilter(materializer) - lazy val healthCheckController = new controllers.HealthCheckController(controllerComponents) - lazy val router: Routes = new Routes(httpErrorHandler, healthCheckController) + private lazy val authService = OktaAuthService( + OktaTokenValidationConfig( + issuerUrl = OktaIssuerUrl(context.initialConfiguration.get[String]("idProvider.issuer")), + audience = Some(OktaAudience(context.initialConfiguration.get[String]("idProvider.audience"))), + clientId = None, + ) + ) + + private lazy val authorisedAction = new AuthorisedAction(authService, _) + + private lazy val healthCheckController = new HealthCheckController(controllerComponents) + private lazy val userController = new UserController(controllerComponents, authorisedAction) + + lazy val router: Routes = new Routes(httpErrorHandler, healthCheckController, userController) } diff --git a/build.sbt b/build.sbt index 7d6d022..8b3c851 100644 --- a/build.sbt +++ b/build.sbt @@ -18,6 +18,14 @@ lazy val root = (project in file(".")) libraryDependencies ++= Seq( "net.logstash.logback" % "logstash-logback-encoder" % "7.4", ("com.gu" %% "simple-configuration-ssm" % "1.6.4").cross(CrossVersion.for3Use2_13), + /* Using Scala 2.13 version of identity-auth-play until a Scala 3 version has been released: + * https://trello.com/c/5kOc41kD/4669-release-scala-3-version-of-identity-libraries */ + ("com.gu.identity" %% "identity-auth-core" % "4.20") + .cross(CrossVersion.for3Use2_13) + exclude ("org.scala-lang.modules", "scala-xml_2.13") + exclude ("org.scala-lang.modules", "scala-parser-combinators_2.13") + exclude ("com.fasterxml.jackson.module", "jackson-module-scala_2.13") + exclude ("com.typesafe", "ssl-config-core_2.13"), "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.1" % Test, ), dependencyOverrides ++= Seq( diff --git a/conf/CODE.conf b/conf/CODE.conf index 87e60ec..a762449 100644 --- a/conf/CODE.conf +++ b/conf/CODE.conf @@ -1 +1,6 @@ include "application.conf" + +idProvider { + issuer = "https://profile.code.dev-theguardian.com/oauth2/aus3v9gla95Toj0EE0x7" + audience = "https://profile.code.dev-theguardian.com/" +} diff --git a/conf/DEV.conf b/conf/DEV.conf index 87e60ec..a762449 100644 --- a/conf/DEV.conf +++ b/conf/DEV.conf @@ -1 +1,6 @@ include "application.conf" + +idProvider { + issuer = "https://profile.code.dev-theguardian.com/oauth2/aus3v9gla95Toj0EE0x7" + audience = "https://profile.code.dev-theguardian.com/" +} diff --git a/conf/PROD.conf b/conf/PROD.conf index 87e60ec..ddd5084 100644 --- a/conf/PROD.conf +++ b/conf/PROD.conf @@ -1 +1,6 @@ include "application.conf" + +idProvider { + issuer = "https://profile.theguardian.com/oauth2/aus3xgj525jYQRowl417" + audience = "https://profile.theguardian.com/" +} diff --git a/conf/routes b/conf/routes index e770852..e3f08f6 100644 --- a/conf/routes +++ b/conf/routes @@ -5,3 +5,5 @@ +anyhost GET /healthcheck controllers.HealthCheckController.healthCheck() + +GET /user/me controllers.UserController.me() diff --git a/test/auth/AuthorisedActionSpec.scala b/test/auth/AuthorisedActionSpec.scala new file mode 100644 index 0000000..4e7dc79 --- /dev/null +++ b/test/auth/AuthorisedActionSpec.scala @@ -0,0 +1,73 @@ +package auth + +import cats.effect.IO +import com.gu.identity.auth.* +import org.mockito.Mockito.when +import org.scalatest.matchers.should.Matchers.shouldBe +import org.scalatestplus.mockito.MockitoSugar.mock +import org.scalatestplus.play.* +import play.api.mvc.* +import play.api.mvc.Results.* +import play.api.test.* +import play.api.test.Helpers.* + +import scala.concurrent.ExecutionContext.Implicits.global + +class AuthorisedActionSpec extends PlaySpec { + + "refine" should { + + val requiredScopes = List(new AccessScope { + override def name: String = "requiredScope" + }) + + "return 401 when there is no Authorization header" in { + val authService = mock[OktaAuthService] + val action = new AuthorisedAction(authService, requiredScopes) + val request = FakeRequest(GET, "/") + val result = action(Ok)(request) + status(result) shouldBe UNAUTHORIZED + contentType(result) shouldBe Some("text/plain") + contentAsString(result) shouldBe "Request has no Authorization header" + } + + "return 401 when the token is invalid" in { + val authService = mock[OktaAuthService] + when(authService.validateAccessToken(AccessToken("invalidToken"), requiredScopes)) + .thenReturn(IO.raiseError(OktaValidationException(InvalidOrExpiredToken))) + val action = new AuthorisedAction(authService, requiredScopes) + val request = FakeRequest(GET, "/").withHeaders("Authorization" -> "Bearer invalidToken") + val result = action(Ok)(request) + status(result) shouldBe UNAUTHORIZED + contentType(result) shouldBe Some("text/plain") + contentAsString(result) shouldBe "Token is invalid or expired" + } + + "return 403 when the token is valid but doesn't have the required scopes" in { + val authService = mock[OktaAuthService] + when(authService.validateAccessToken(AccessToken("validToken"), requiredScopes)) + .thenReturn(IO.raiseError(OktaValidationException(MissingRequiredScope(requiredScopes)))) + val action = new AuthorisedAction(authService, requiredScopes) + val request = FakeRequest(GET, "/").withHeaders("Authorization" -> "Bearer validToken") + val result = action(Ok)(request) + status(result) shouldBe FORBIDDEN + contentType(result) shouldBe Some("text/plain") + contentAsString(result) shouldBe "Token is missing required scope(s): requiredScope" + } + + "return 200 when the token is valid and has the required scopes" in { + val requiredScopes = List(new AccessScope { + override def name: String = "requiredScope" + }) + val authService = mock[OktaAuthService] + when(authService.validateAccessToken(AccessToken("validToken"), requiredScopes)) + .thenReturn( + IO.pure(DefaultAccessClaims(primaryEmailAddress = "a@b.com", identityId = "I43", username = None)) + ) + val action = new AuthorisedAction(authService, requiredScopes) + val request = FakeRequest(GET, "/").withHeaders("Authorization" -> "Bearer validToken") + val result = action(Ok)(request) + status(result) shouldBe OK + } + } +} diff --git a/test/controllers/HealthCheckControllerSpec.scala b/test/controllers/HealthCheckControllerSpec.scala index 6a38b84..ac56b95 100644 --- a/test/controllers/HealthCheckControllerSpec.scala +++ b/test/controllers/HealthCheckControllerSpec.scala @@ -1,6 +1,7 @@ package controllers import load.AppComponents +import org.scalatest.matchers.should.Matchers.shouldBe import org.scalatestplus.play.* import org.scalatestplus.play.components.OneAppPerTestWithComponents import play.api.BuiltInComponents @@ -18,17 +19,17 @@ class HealthCheckControllerSpec extends PlaySpec with OneAppPerTestWithComponent "run health check from a new instance of controller" in { val controller = new HealthCheckController(stubControllerComponents()) val healthCheck = controller.healthCheck().apply(FakeRequest(GET, path)) - status(healthCheck) mustBe OK - contentType(healthCheck) mustBe Some("text/plain") - contentAsString(healthCheck) must be("OK") + status(healthCheck) shouldBe OK + contentType(healthCheck) shouldBe Some("text/plain") + contentAsString(healthCheck) shouldBe "OK" } "run health check from the router" in { val request = FakeRequest(GET, path) val healthCheck = route(app, request).get - status(healthCheck) mustBe OK - contentType(healthCheck) mustBe Some("text/plain") - contentAsString(healthCheck) must be("OK") + status(healthCheck) shouldBe OK + contentType(healthCheck) shouldBe Some("text/plain") + contentAsString(healthCheck) shouldBe "OK" } } } diff --git a/test/logging/LogEntrySpec.scala b/test/logging/LogEntrySpec.scala index 7f14738..9d3b3e8 100644 --- a/test/logging/LogEntrySpec.scala +++ b/test/logging/LogEntrySpec.scala @@ -1,5 +1,6 @@ package logging +import org.scalatest.matchers.should.Matchers.shouldBe import org.scalatestplus.play.* import play.api.mvc.Results.Ok import play.api.test.* @@ -11,10 +12,10 @@ class LogEntrySpec extends PlaySpec { val request = FakeRequest(GET, "/").withHeaders(REFERER -> "Referrer") val entry = LogEntry.requestAndResponse(request, response = Ok.withHeaders(CONTENT_LENGTH -> "11"), duration = 7) "give correct message" in { - entry.message mustBe """127.0.0.1 - "GET / HTTP/1.1" 200 11 "Referrer" 7ms""" + entry.message shouldBe """127.0.0.1 - "GET / HTTP/1.1" 200 11 "Referrer" 7ms""" } "give correct fields" in { - entry.otherFields mustBe Map( + entry.otherFields shouldBe Map( "type" -> "access", "origin" -> "127.0.0.1", "referrer" -> "Referrer", @@ -32,10 +33,10 @@ class LogEntrySpec extends PlaySpec { val request = FakeRequest(GET, "/").withHeaders(REFERER -> "Referrer") val entry = LogEntry.error(request, duration = 17) "give correct message" in { - entry.message mustBe """127.0.0.1 - "GET / HTTP/1.1" ERROR "Referrer" 17ms""" + entry.message shouldBe """127.0.0.1 - "GET / HTTP/1.1" ERROR "Referrer" 17ms""" } "give correct fields" in { - entry.otherFields mustBe Map( + entry.otherFields shouldBe Map( "type" -> "access", "origin" -> "127.0.0.1", "referrer" -> "Referrer",