From c30984add366846450c43243638a8f3563c55076 Mon Sep 17 00:00:00 2001 From: Tobias Binna Date: Tue, 11 Nov 2025 16:25:06 +0800 Subject: [PATCH] Migrate to Scala 3 Closes #93 --- README.md | 4 +- build.sbt | 25 +++-- .../play/api/models/AtlassianHostUser.scala | 30 ++--- .../connect/play/actions/JwtExtractor.scala | 9 +- .../AssociateAtlassianHostUserAction.scala | 106 ++++++++++-------- ...ricallySignedAtlassianHostUserAction.scala | 70 +++++++----- .../jwt/ForgeJWSVerificationKeySelector.scala | 26 +++-- .../jwt/ForgeRemoteJWKSourceProvider.scala | 2 +- .../auth/jwt/symmetric/JwtGenerator.scala | 80 ++++++++----- .../controllers/LifecycleController.scala | 27 ++--- .../connect/play/models/Implicits.scala | 2 - .../connect/play/actions/JwtActionSpec.scala | 7 +- .../play/actions/JwtExtractorSpec.scala | 1 - .../auth/jwt/symmetric/JwtGeneratorSpec.scala | 4 +- ...mmetricJwtAuthenticationProviderSpec.scala | 73 +++++++----- .../connect/play/events/EventBusSpec.scala | 24 ++-- ...PlayWsAtlassianConnectHttpClientSpec.scala | 1 - .../play/services/LifecycleServiceSpec.scala | 2 +- project/Dependencies.scala | 4 +- project/build.properties | 2 +- 20 files changed, 281 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index 2031987..dedd0da 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Atlassian Connect Play [![Continuous integration](https://github.com/toolsplus/atlassian-connect-play/actions/workflows/ci.yml/badge.svg)](https://github.com/toolsplus/atlassian-connect-play/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/toolsplus/atlassian-connect-play/branch/master/graph/badge.svg)](https://codecov.io/gh/toolsplus/atlassian-connect-play) -[![Maven Central](https://img.shields.io/maven-central/v/io.toolsplus/atlassian-connect-play-core_2.13.svg)](https://maven-badges.herokuapp.com/maven-central/io.toolsplus/atlassian-connect-play-core_2.13) +[![Maven Central](https://img.shields.io/maven-central/v/io.toolsplus/atlassian-connect-play-core_3.svg)](https://maven-badges.herokuapp.com/maven-central/io.toolsplus/atlassian-connect-play-core_3) This project contains a [Play Scala](https://www.playframework.com/) based @@ -12,7 +12,7 @@ It serves as a starter for building Atlassian Connect add-ons for JIRA and Confl ## Quick start -atlassian-connect-play is published to Maven Central for Scala 2.13 and Play 2.8.x, +atlassian-connect-play is published to Maven Central for Scala 3 and Play 3, so you can just add the following to your build: libraryDependencies += "io.toolsplus" %% "atlassian-connect-play" % "x.x.x" diff --git a/build.sbt b/build.sbt index 167d9c1..93d99ef 100644 --- a/build.sbt +++ b/build.sbt @@ -3,12 +3,17 @@ import xerial.sbt.Sonatype.sonatypeCentralHost val commonSettings = Seq( organization := "io.toolsplus", - scalaVersion := "2.13.16", + scalaVersion := "3.3.6", versionScheme := Some("early-semver"), resolvers ++= Seq( Resolver.typesafeRepo("releases"), Resolver.jcenterRepo - ) + ), + scalacOptions ++= { + Seq( + "-Xmax-inlines:128" + ) + } ) val scoverageSettings = Seq( @@ -19,7 +24,8 @@ lazy val publishSettings = Seq( releasePublishArtifactsAction := PgpKeys.publishSigned.value, homepage := Some(url("https://github.com/toolsplus/atlassian-connect-play")), licenses := Seq( - "Apache 2.0" -> url("https://www.apache.org/licenses/LICENSE-2.0")), + "Apache 2.0" -> url("https://www.apache.org/licenses/LICENSE-2.0") + ), publishMavenStyle := true, Test / publishArtifact := false, pomIncludeRepository := { _ => @@ -35,10 +41,12 @@ lazy val publishSettings = Seq( ) ), developers := List( - Developer("tbinna", - "Tobias Binna", - "tobias.binna@toolsplus.io", - url("https://twitter.com/tbinna")) + Developer( + "tbinna", + "Tobias Binna", + "tobias.binna@toolsplus.io", + url("https://twitter.com/tbinna") + ) ) ) @@ -48,7 +56,8 @@ lazy val noPublishSettings = Seq( publishLocal := {}, publishArtifact := false, publishTo := Some( - Resolver.file("Unused transient repository", file("target/dummyrepo"))) + Resolver.file("Unused transient repository", file("target/dummyrepo")) + ) ) releaseProcess := Seq[ReleaseStep]( diff --git a/modules/api/src/main/scala/io/toolsplus/atlassian/connect/play/api/models/AtlassianHostUser.scala b/modules/api/src/main/scala/io/toolsplus/atlassian/connect/play/api/models/AtlassianHostUser.scala index aaf0048..0965f08 100644 --- a/modules/api/src/main/scala/io/toolsplus/atlassian/connect/play/api/models/AtlassianHostUser.scala +++ b/modules/api/src/main/scala/io/toolsplus/atlassian/connect/play/api/models/AtlassianHostUser.scala @@ -1,21 +1,21 @@ package io.toolsplus.atlassian.connect.play.api.models -/** - * Authentication principal for requests coming from an Atlassian host +/** Authentication principal for requests coming from an Atlassian host * application in which the add-on is installed. */ trait AtlassianHostUser { /** Host from which the request originated. * - * @return Host associated with this operation. + * @return + * Host associated with this operation. */ def host: AtlassianHost - /** - * Atlassian Account ID of the user on whose behalf a request was made. + /** Atlassian Account ID of the user on whose behalf a request was made. * - * @return Atlassian Account ID + * @return + * Atlassian Account ID */ def userAccountId: Option[String] } @@ -24,17 +24,17 @@ object AtlassianHostUser { object Implicits { - import scala.language.implicitConversions - - /** - * Implicitly convert an instance of [[AtlassianHostUser]] to an - * instance of [[AtlassianHost]]. + /** Implicitly convert an instance of [[AtlassianHostUser]] to an instance + * of [[AtlassianHost]]. * - * @param hostUser Atlassian host user instance. - * @return Underlying Atlassian host instance. + * @param hostUser + * Atlassian host user instance. + * @return + * Underlying Atlassian host instance. */ - implicit def hostUserToHost( - implicit hostUser: AtlassianHostUser): AtlassianHost = + implicit def hostUserToHost(implicit + hostUser: AtlassianHostUser + ): AtlassianHost = hostUser.host } diff --git a/modules/core/app/io/toolsplus/atlassian/connect/play/actions/JwtExtractor.scala b/modules/core/app/io/toolsplus/atlassian/connect/play/actions/JwtExtractor.scala index f96cb1c..43edb78 100644 --- a/modules/core/app/io/toolsplus/atlassian/connect/play/actions/JwtExtractor.scala +++ b/modules/core/app/io/toolsplus/atlassian/connect/play/actions/JwtExtractor.scala @@ -1,7 +1,7 @@ package io.toolsplus.atlassian.connect.play.actions import io.toolsplus.atlassian.connect.play.auth.jwt -import io.toolsplus.atlassian.connect.play.auth.jwt.{CanonicalPlayHttpRequest, JwtCredentials, symmetric} +import io.toolsplus.atlassian.connect.play.auth.jwt.JwtCredentials import play.api.http.HeaderNames import play.api.mvc.Request @@ -16,11 +16,14 @@ object JwtExtractor { request.headers .get(HeaderNames.AUTHORIZATION) .filter(header => - header.nonEmpty && header.startsWith(AuthorizationHeaderPrefix)) + header.nonEmpty && header.startsWith(AuthorizationHeaderPrefix) + ) .map(_.substring(AuthorizationHeaderPrefix.length).trim) } - private def extractJwtFromParameter[A](request: Request[A]): Option[String] = { + private def extractJwtFromParameter[A]( + request: Request[A] + ): Option[String] = { request.getQueryString(QueryParameterName).filter(_.nonEmpty) } diff --git a/modules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/AssociateAtlassianHostUserAction.scala b/modules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/AssociateAtlassianHostUserAction.scala index ff31f47..a571363 100644 --- a/modules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/AssociateAtlassianHostUserAction.scala +++ b/modules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/AssociateAtlassianHostUserAction.scala @@ -28,10 +28,12 @@ trait AbstractAssociateAtlassianHostUserActionRefiner[R[A] <: WrappedRequest[A]] def hostSearchResultToActionResult[A]( maybeHost: Option[AtlassianHost], - request: ForgeRemoteContextRequest[A]): Either[Result, R[A]] + request: ForgeRemoteContextRequest[A] + ): Either[Result, R[A]] override def refine[A]( - request: ForgeRemoteContextRequest[A]): Future[Either[Result, R[A]]] = { + request: ForgeRemoteContextRequest[A] + ): Future[Either[Result, R[A]]] = { val installationId = request.context.invocationContext.app.installationId forgeInstallationRepository .findByInstallationId(installationId) @@ -42,7 +44,8 @@ trait AbstractAssociateAtlassianHostUserActionRefiner[R[A] <: WrappedRequest[A]] .map(hostSearchResultToActionResult(_, request)) case None => logger.error( - s"Failed to associate Connect host to Forge Remote Compute invocation: No host mapping for installation id $installationId found") + s"Failed to associate Connect host to Forge Remote Compute invocation: No host mapping for installation id $installationId found" + ) Future.successful(Left(BadRequest(s"Missing Connect mapping"))) }) } @@ -50,59 +53,65 @@ trait AbstractAssociateAtlassianHostUserActionRefiner[R[A] <: WrappedRequest[A]] case class ForgeRemoteAssociateAtlassianHostUserRequest[A]( hostUser: AtlassianHostUser, - request: ForgeRemoteContextRequest[A]) - extends AtlassianHostUserRequest[A](request) + request: ForgeRemoteContextRequest[A] +) extends AtlassianHostUserRequest[A](request) -/** - * Action that associates an Atlassian host to an existing Forge Remote context and fails if no host - * could be found. +/** Action that associates an Atlassian host to an existing Forge Remote context + * and fails if no host could be found. * - * Extracts the installation id from the given Forge Remote context and tries to find the host associated - * with the installation. If no Atlassian host could be found, this action will return a 400 Bad Request result. + * Extracts the installation id from the given Forge Remote context and tries + * to find the host associated with the installation. If no Atlassian host + * could be found, this action will return a 400 Bad Request result. */ -case class AssociateAtlassianHostUserActionRefiner @Inject()( +case class AssociateAtlassianHostUserActionRefiner @Inject() ( override val hostRepository: AtlassianHostRepository, - override val forgeInstallationRepository: ForgeInstallationRepository)( - override implicit val executionContext: ExecutionContext) + override val forgeInstallationRepository: ForgeInstallationRepository +)(override implicit val executionContext: ExecutionContext) extends AbstractAssociateAtlassianHostUserActionRefiner[ - ForgeRemoteAssociateAtlassianHostUserRequest] { + ForgeRemoteAssociateAtlassianHostUserRequest + ] { override val logger: Logger = Logger( - classOf[AssociateAtlassianHostUserActionRefiner]) + classOf[AssociateAtlassianHostUserActionRefiner] + ) override def hostSearchResultToActionResult[A]( maybeHost: Option[AtlassianHost], - request: ForgeRemoteContextRequest[A]) - : Either[Result, ForgeRemoteAssociateAtlassianHostUserRequest[A]] = + request: ForgeRemoteContextRequest[A] + ): Either[Result, ForgeRemoteAssociateAtlassianHostUserRequest[A]] = maybeHost match { case Some(host) => Right( ForgeRemoteAssociateAtlassianHostUserRequest( DefaultAtlassianHostUser( host, - request.context.invocationContext.principal), - request)) + request.context.invocationContext.principal + ), + request + ) + ) case None => val installationId = request.context.invocationContext.app.installationId logger.error( - s"Failed to associate Connect host to Forge Remote Compute invocation: No host for installation id $installationId found") + s"Failed to associate Connect host to Forge Remote Compute invocation: No host for installation id $installationId found" + ) Left(BadRequest(s"Missing Connect installation")) } object Implicits { - import scala.language.implicitConversions - - /** - * Implicitly convert an instance of [[ForgeRemoteAssociateAtlassianHostUserRequest]] to an - * instance of AtlassianHostUser. + /** Implicitly convert an instance of + * [[ForgeRemoteAssociateAtlassianHostUserRequest]] to an instance of + * AtlassianHostUser. * - * @param request Forge Remote associated host user request instance. - * @return Atlassian host user instance extracted from request. + * @param request + * Forge Remote associated host user request instance. + * @return + * Atlassian host user instance extracted from request. */ - implicit def requestToHostUser( - implicit request: ForgeRemoteAssociateAtlassianHostUserRequest[_]) - : AtlassianHostUser = + implicit def requestToHostUser(implicit + request: ForgeRemoteAssociateAtlassianHostUserRequest[_] + ): AtlassianHostUser = request.hostUser } @@ -111,33 +120,40 @@ case class AssociateAtlassianHostUserActionRefiner @Inject()( case class ForgeRemoteAssociateMaybeAtlassianHostUserRequest[A]( hostUser: Option[AtlassianHostUser], - request: ForgeRemoteContextRequest[A]) - extends WrappedRequest[A](request) + request: ForgeRemoteContextRequest[A] +) extends WrappedRequest[A](request) -/** - * Action that attempts to associate an Atlassian host to an existing Forge Remote context. +/** Action that attempts to associate an Atlassian host to an existing Forge + * Remote context. * - * Extracts the installation id from the given Forge Remote context and tries to find the host associated - * with the installation. If no Atlassian host could be found, this action will succeed with a None option value. + * Extracts the installation id from the given Forge Remote context and tries + * to find the host associated with the installation. If no Atlassian host + * could be found, this action will succeed with a None option value. */ -case class AssociateMaybeAtlassianHostUserActionRefiner @Inject()( +case class AssociateMaybeAtlassianHostUserActionRefiner @Inject() ( override val hostRepository: AtlassianHostRepository, - override val forgeInstallationRepository: ForgeInstallationRepository)( - override implicit val executionContext: ExecutionContext) + override val forgeInstallationRepository: ForgeInstallationRepository +)(override implicit val executionContext: ExecutionContext) extends AbstractAssociateAtlassianHostUserActionRefiner[ - ForgeRemoteAssociateMaybeAtlassianHostUserRequest] { + ForgeRemoteAssociateMaybeAtlassianHostUserRequest + ] { override val logger: Logger = Logger( - classOf[AssociateMaybeAtlassianHostUserActionRefiner]) + classOf[AssociateMaybeAtlassianHostUserActionRefiner] + ) override def hostSearchResultToActionResult[A]( maybeHost: Option[AtlassianHost], - request: ForgeRemoteContextRequest[A]) - : Either[Result, ForgeRemoteAssociateMaybeAtlassianHostUserRequest[A]] = + request: ForgeRemoteContextRequest[A] + ): Either[Result, ForgeRemoteAssociateMaybeAtlassianHostUserRequest[A]] = Right( ForgeRemoteAssociateMaybeAtlassianHostUserRequest( maybeHost.map( DefaultAtlassianHostUser( _, - request.context.invocationContext.principal)), - request)) + request.context.invocationContext.principal + ) + ), + request + ) + ) } diff --git a/modules/core/app/io/toolsplus/atlassian/connect/play/actions/symmetric/SymmetricallySignedAtlassianHostUserAction.scala b/modules/core/app/io/toolsplus/atlassian/connect/play/actions/symmetric/SymmetricallySignedAtlassianHostUserAction.scala index 6efac5d..6fe3355 100644 --- a/modules/core/app/io/toolsplus/atlassian/connect/play/actions/symmetric/SymmetricallySignedAtlassianHostUserAction.scala +++ b/modules/core/app/io/toolsplus/atlassian/connect/play/actions/symmetric/SymmetricallySignedAtlassianHostUserAction.scala @@ -15,21 +15,25 @@ import play.api.mvc._ import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} -case class ConnectAtlassianHostUserRequest[A](hostUser: AtlassianHostUser, - request: JwtRequest[A]) - extends AtlassianHostUserRequest[A](request) +case class ConnectAtlassianHostUserRequest[A]( + hostUser: AtlassianHostUser, + request: JwtRequest[A] +) extends AtlassianHostUserRequest[A](request) case class SymmetricallySignedAtlassianHostUserActionRefiner( jwtAuthenticationProvider: SymmetricJwtAuthenticationProvider, - qshProvider: QshProvider)(implicit val executionContext: ExecutionContext) + qshProvider: QshProvider +)(implicit val executionContext: ExecutionContext) extends ActionRefiner[JwtRequest, ConnectAtlassianHostUserRequest] { - override def refine[A](request: JwtRequest[A]) - : Future[Either[Result, ConnectAtlassianHostUserRequest[A]]] = { + override def refine[A]( + request: JwtRequest[A] + ): Future[Either[Result, ConnectAtlassianHostUserRequest[A]]] = { val expectedQsh = qshProvider match { - case ContextQshProvider => ContextQshProvider.qsh + case ContextQshProvider => ContextQshProvider.qsh case CanonicalHttpRequestQshProvider => CanonicalHttpRequestQshProvider.qsh( - request.credentials.canonicalHttpRequest) + request.credentials.canonicalHttpRequest + ) } jwtAuthenticationProvider .authenticate(request.credentials, expectedQsh) @@ -39,48 +43,52 @@ case class SymmetricallySignedAtlassianHostUserActionRefiner( } } -class SymmetricallySignedAtlassianHostUserAction @Inject()( +class SymmetricallySignedAtlassianHostUserAction @Inject() ( bodyParser: BodyParsers.Default, jwtActionRefiner: JwtActionRefiner, - symmetricJwtAuthenticationProvider: SymmetricJwtAuthenticationProvider)( - implicit executionCtx: ExecutionContext) { + symmetricJwtAuthenticationProvider: SymmetricJwtAuthenticationProvider +)(implicit executionCtx: ExecutionContext) { - /** - * Creates an action builder that validates symmetrically signed JWT requests. Callers must specify - * how the query string hash claim should be verified. + /** Creates an action builder that validates symmetrically signed JWT + * requests. Callers must specify how the query string hash claim should be + * verified. * - * @param qshProvider Query string hash provider that specifies what kind of QSH the qsh claim contains - * @return Play action for symmetrically signed JWT requests + * @param qshProvider + * Query string hash provider that specifies what kind of QSH the qsh claim + * contains + * @return + * Play action for symmetrically signed JWT requests */ - def authenticateWith(qshProvider: QshProvider) - : ActionBuilder[ConnectAtlassianHostUserRequest, AnyContent] = + def authenticateWith( + qshProvider: QshProvider + ): ActionBuilder[ConnectAtlassianHostUserRequest, AnyContent] = new ActionBuilder[ConnectAtlassianHostUserRequest, AnyContent] { override val parser: BodyParsers.Default = bodyParser override val executionContext: ExecutionContext = executionCtx override def invokeBlock[A]( request: Request[A], - block: ConnectAtlassianHostUserRequest[A] => Future[Result]) - : Future[Result] = { + block: ConnectAtlassianHostUserRequest[A] => Future[Result] + ): Future[Result] = { (jwtActionRefiner andThen SymmetricallySignedAtlassianHostUserActionRefiner( symmetricJwtAuthenticationProvider, - qshProvider)).invokeBlock(request, block) + qshProvider + )).invokeBlock(request, block) } } object Implicits { - import scala.language.implicitConversions - - /** - * Implicitly convert an instance of [[ConnectAtlassianHostUserRequest]] to an - * instance of AtlassianHostUser. + /** Implicitly convert an instance of [[ConnectAtlassianHostUserRequest]] to + * an instance of AtlassianHostUser. * - * @param request Atlassian host user request instance. - * @return Atlassian host user instance extracted from request. + * @param request + * Atlassian host user request instance. + * @return + * Atlassian host user instance extracted from request. */ - implicit def hostUserRequestToHostUser( - implicit request: ConnectAtlassianHostUserRequest[_]) - : AtlassianHostUser = + implicit def hostUserRequestToHostUser(implicit + request: ConnectAtlassianHostUserRequest[_] + ): AtlassianHostUser = request.hostUser } diff --git a/modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeJWSVerificationKeySelector.scala b/modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeJWSVerificationKeySelector.scala index dba2ef0..74b288e 100644 --- a/modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeJWSVerificationKeySelector.scala +++ b/modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeJWSVerificationKeySelector.scala @@ -1,6 +1,5 @@ package io.toolsplus.atlassian.connect.play.auth.frc.jwt -import com.nimbusds.jose.jwk.source.JWKSource import com.nimbusds.jose.jwk.{JWKMatcher, JWKSelector, KeyConverter} import com.nimbusds.jose.proc.JWSKeySelector import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} @@ -12,20 +11,22 @@ import javax.crypto.SecretKey import javax.inject.Inject import scala.jdk.CollectionConverters._ -/** - * Forge Remote specific JWS key selector for verifying JWS objects, where the key candidates are - * retrieved from a request specific [[JWKSource JSON Web Key (JWK) source]]. +/** Forge Remote specific JWS key selector for verifying JWS objects, where the + * key candidates are retrieved from a request specific [[JWKSource]] JSON Web + * Key (JWK) source. * - * This implementation follows exactly the implementation in [[com.nimbusds.jose.proc.JWSVerificationKeySelector]] - * except that the key selector selects the [[JWKSource]] based on the associated [[ForgeInvocationContext]] - * security context. + * This implementation follows exactly the implementation in + * [[com.nimbusds.jose.proc.JWSVerificationKeySelector]] except that the key + * selector selects the [[JWKSource]] based on the associated + * [[ForgeInvocationContext]] security context. */ -class ForgeJWSVerificationKeySelector @Inject()( - jwkSourceProvider: ForgeRemoteJWKSourceProvider) - extends JWSKeySelector[ForgeInvocationContext] { +class ForgeJWSVerificationKeySelector @Inject() ( + jwkSourceProvider: ForgeRemoteJWKSourceProvider +) extends JWSKeySelector[ForgeInvocationContext] { override def selectJWSKeys( header: JWSHeader, - context: ForgeInvocationContext): util.List[_ <: Key] = { + context: ForgeInvocationContext + ): util.List[_ <: Key] = { if (header.getAlgorithm != JWSAlgorithm.RS256) { return Collections.emptyList() } @@ -41,7 +42,8 @@ class ForgeJWSVerificationKeySelector @Inject()( // skip asymmetric private keys .filter(key => key.isInstanceOf[PublicKey] || key - .isInstanceOf[SecretKey]) + .isInstanceOf[SecretKey] + ) .asJava case None => Collections.emptyList() } diff --git a/modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeRemoteJWKSourceProvider.scala b/modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeRemoteJWKSourceProvider.scala index 7a45637..feab649 100644 --- a/modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeRemoteJWKSourceProvider.scala +++ b/modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeRemoteJWKSourceProvider.scala @@ -4,7 +4,7 @@ import com.nimbusds.jose.jwk.source.{JWKSource, JWKSourceBuilder} import com.nimbusds.jose.util.DefaultResourceRetriever import io.toolsplus.atlassian.connect.play.models.AtlassianForgeProperties -import java.net.{URI, URL} +import java.net.URI import javax.inject.Inject import ForgeRemoteJWKSourceProvider._ diff --git a/modules/core/app/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/JwtGenerator.scala b/modules/core/app/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/JwtGenerator.scala index 0fa790c..d874caf 100644 --- a/modules/core/app/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/JwtGenerator.scala +++ b/modules/core/app/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/JwtGenerator.scala @@ -1,7 +1,10 @@ package io.toolsplus.atlassian.connect.play.auth.jwt.symmetric import cats.syntax.either._ -import io.toolsplus.atlassian.connect.play.api.models.{AppProperties, AtlassianHost} +import io.toolsplus.atlassian.connect.play.api.models.{ + AppProperties, + AtlassianHost +} import io.toolsplus.atlassian.connect.play.auth.jwt.CanonicalUriHttpRequest import io.toolsplus.atlassian.connect.play.auth.jwt.symmetric.JwtGenerator._ import io.toolsplus.atlassian.connect.play.models.AtlassianConnectProperties @@ -14,35 +17,43 @@ import java.time.Duration import java.time.temporal.ChronoUnit import javax.inject.Inject -/** - * JwtGenerator used to generated symmetrically signed JWTs to make requests from the - * app host to the Atlassian host. +/** JwtGenerator used to generated symmetrically signed JWTs to make requests + * from the app host to the Atlassian host. * - * @param appProperties App properties of this app - * @param atlassianConnectProperties Atlassian Connect properties of this app + * @param appProperties + * App properties of this app + * @param atlassianConnectProperties + * Atlassian Connect properties of this app */ -class JwtGenerator @Inject()( - appProperties: AppProperties, - atlassianConnectProperties: AtlassianConnectProperties) { +class JwtGenerator @Inject() ( + appProperties: AppProperties, + atlassianConnectProperties: AtlassianConnectProperties +) { private val logger = Logger(classOf[JwtGenerator]) - /** - * Generates a JWT for the given Atlassian host and request details. This token - * can be used to make requests to the host itself. + /** Generates a JWT for the given Atlassian host and request details. This + * token can be used to make requests to the host itself. * - * Note that JWTs to send requests to an Atlassian host need to include a query string - * hash (QSH) claim. To compute the QSH this generator needs to know the HTTP request - * method and URI (including query string parameters) at token creation time. + * Note that JWTs to send requests to an Atlassian host need to include a + * query string hash (QSH) claim. To compute the QSH this generator needs to + * know the HTTP request method and URI (including query string parameters) + * at token creation time. * - * @param httpMethod HTTP method of the intended host request - * @param uri URI of the intended host request - * @param host Atlassian host the request is targeting - * @return JWT token for the specific host request defined by the input parameters + * @param httpMethod + * HTTP method of the intended host request + * @param uri + * URI of the intended host request + * @param host + * Atlassian host the request is targeting + * @return + * JWT token for the specific host request defined by the input parameters */ - def createJwtToken(httpMethod: String, - uri: URI, - host: AtlassianHost): Either[JwtGeneratorError, RawJwt] = + def createJwtToken( + httpMethod: String, + uri: URI, + host: AtlassianHost + ): Either[JwtGeneratorError, RawJwt] = for { absoluteUri <- assertUriAbsolute(uri) uriToHost <- assertRequestToHost(absoluteUri, host) @@ -52,17 +63,21 @@ class JwtGenerator @Inject()( private def internalCreateJwtToken( httpMethod: String, uri: URI, - host: AtlassianHost): Either[JwtGeneratorError, RawJwt] = { + host: AtlassianHost + ): Either[JwtGeneratorError, RawJwt] = { val hostContextPath = Option(URI.create(host.baseUrl).getPath) val canonicalHttpRequest = CanonicalUriHttpRequest(httpMethod, uri, hostContextPath) logger.trace( - s"Generating JWT with canonical request: $canonicalHttpRequest") + s"Generating JWT with canonical request: $canonicalHttpRequest" + ) val queryHash = HttpRequestCanonicalizer.computeCanonicalRequestHash(canonicalHttpRequest) - val expireAfter = Duration.of(atlassianConnectProperties.jwtExpirationTime, - ChronoUnit.SECONDS) + val expireAfter = Duration.of( + atlassianConnectProperties.jwtExpirationTime, + ChronoUnit.SECONDS + ) for { sharedSecret <- assertSecretKeyLessThan256Bits(host.sharedSecret) @@ -74,7 +89,9 @@ class JwtGenerator @Inject()( } yield jwt } - private def assertSecretKeyLessThan256Bits(secretKey: String): Either[JwtGeneratorError, String] = + private def assertSecretKeyLessThan256Bits( + secretKey: String + ): Either[JwtGeneratorError, String] = if (secretKey.getBytes.length < (256 / 8)) Left(InvalidSecretKey) else Right(secretKey) @@ -84,7 +101,8 @@ class JwtGenerator @Inject()( private def assertRequestToHost( uri: URI, - host: AtlassianHost): Either[JwtGeneratorError, URI] = { + host: AtlassianHost + ): Either[JwtGeneratorError, URI] = { if (AtlassianHostUriResolver.isRequestToHost(uri, host)) Right(uri) else Left(BaseUrlMismatchError) } @@ -97,11 +115,11 @@ object JwtGenerator { def message: String } - final case object RelativeUriError extends JwtGeneratorError { + case object RelativeUriError extends JwtGeneratorError { override val message: String = "The given URI is not absolute" } - final case object BaseUrlMismatchError extends JwtGeneratorError { + case object BaseUrlMismatchError extends JwtGeneratorError { override val message: String = "The given URI is not under the base URL of the given host" } @@ -115,7 +133,7 @@ object JwtGenerator { s"No Atlassian host found for the given URI $uri" } - final case object InvalidSecretKey extends JwtGeneratorError { + case object InvalidSecretKey extends JwtGeneratorError { override def message = "Secret key must be more than 256 bits" } diff --git a/modules/core/app/io/toolsplus/atlassian/connect/play/controllers/LifecycleController.scala b/modules/core/app/io/toolsplus/atlassian/connect/play/controllers/LifecycleController.scala index 23a76e8..dec0f2e 100644 --- a/modules/core/app/io/toolsplus/atlassian/connect/play/controllers/LifecycleController.scala +++ b/modules/core/app/io/toolsplus/atlassian/connect/play/controllers/LifecycleController.scala @@ -8,20 +8,19 @@ import io.toolsplus.atlassian.connect.play.auth.jwt.CanonicalHttpRequestQshProvi import io.toolsplus.atlassian.connect.play.models.{GenericEvent, InstalledEvent} import io.toolsplus.atlassian.connect.play.services._ import play.api.libs.circe.Circe -import play.api.mvc.{Action, InjectedController} +import play.api.mvc.{Action, BaseController, ControllerComponents} import scala.concurrent.ExecutionContext -/** - * Controller that handles the app install and uninstall lifecycle - * callbacks. +/** Controller that handles the app install and uninstall lifecycle callbacks. */ -class LifecycleController @Inject()( +class LifecycleController @Inject() ( + val controllerComponents: ControllerComponents, lifecycleService: LifecycleService, asymmetricallySignedAtlassianHostUserAction: AsymmetricallySignedAtlassianHostUserAction, appProperties: AppProperties, - implicit val executionContext: ExecutionContext) - extends InjectedController + implicit val executionContext: ExecutionContext +) extends BaseController with Circe { def installed: Action[InstalledEvent] = { @@ -30,14 +29,15 @@ class LifecycleController @Inject()( .async(circe.json[InstalledEvent]) { implicit request => lifecycleService.installed(request.body).value map { case Right(_) => Ok - case Left(e) => + case Left(e) => e match { case MissingAtlassianHostError => BadRequest case InvalidLifecycleEventTypeError => BadRequest case HostForbiddenError => Forbidden - case MissingJwtError => + case MissingJwtError => Unauthorized.withHeaders( - WWW_AUTHENTICATE -> s"""JWT realm="${appProperties.key}"""") + WWW_AUTHENTICATE -> s"""JWT realm="${appProperties.key}"""" + ) } } } @@ -49,14 +49,15 @@ class LifecycleController @Inject()( .async(circe.json[GenericEvent]) { implicit request => lifecycleService.uninstalled(request.body, request.hostUser).value map { case Right(_) => NoContent - case Left(e) => + case Left(e) => e match { case MissingAtlassianHostError => NoContent case InvalidLifecycleEventTypeError => BadRequest case HostForbiddenError => Forbidden - case MissingJwtError => + case MissingJwtError => Unauthorized.withHeaders( - WWW_AUTHENTICATE -> s"""JWT realm="${appProperties.key}"""") + WWW_AUTHENTICATE -> s"""JWT realm="${appProperties.key}"""" + ) } } } diff --git a/modules/core/app/io/toolsplus/atlassian/connect/play/models/Implicits.scala b/modules/core/app/io/toolsplus/atlassian/connect/play/models/Implicits.scala index 6921cd8..ec98984 100644 --- a/modules/core/app/io/toolsplus/atlassian/connect/play/models/Implicits.scala +++ b/modules/core/app/io/toolsplus/atlassian/connect/play/models/Implicits.scala @@ -7,8 +7,6 @@ import io.toolsplus.atlassian.connect.play.api.models.{ object Implicits { - import scala.language.implicitConversions - /** Implicitly convert an instance of [[LifecycleEvent]] to an instance of * AtlassianHost. * diff --git a/modules/core/test/io/toolsplus/atlassian/connect/play/actions/JwtActionSpec.scala b/modules/core/test/io/toolsplus/atlassian/connect/play/actions/JwtActionSpec.scala index 34ced7a..6e10203 100644 --- a/modules/core/test/io/toolsplus/atlassian/connect/play/actions/JwtActionSpec.scala +++ b/modules/core/test/io/toolsplus/atlassian/connect/play/actions/JwtActionSpec.scala @@ -22,20 +22,19 @@ class JwtActionSpec extends TestSpec with EitherValues { implicit val rawJwtNoShrink: Shrink[RawJwt] = Shrink.shrinkAny forAll(signedSymmetricJwtStringGen(), playRequestGen) { (rawJwt, request) => - val jwtHeader = HeaderNames.AUTHORIZATION -> s"${JwtExtractor.AuthorizationHeaderPrefix} $rawJwt" + val jwtHeader = + HeaderNames.AUTHORIZATION -> s"${JwtExtractor.AuthorizationHeaderPrefix} $rawJwt" val jwtRequest = request.withHeaders(jwtHeader) val jwtCredentials = jwt.JwtCredentials(rawJwt, CanonicalPlayHttpRequest(jwtRequest)) val result = await { jwtActionRefiner.refine(jwtRequest) } - result mustBe Right( - JwtRequest(jwtCredentials, jwtRequest)) + result mustBe Right(JwtRequest(jwtCredentials, jwtRequest)) } } "fail to refine request if it does not contain a token" in { - implicit val rawJwtNoShrink: Shrink[RawJwt] = Shrink.shrinkAny forAll(playRequestGen) { request => val result = await { jwtActionRefiner.refine(request) diff --git a/modules/core/test/io/toolsplus/atlassian/connect/play/actions/JwtExtractorSpec.scala b/modules/core/test/io/toolsplus/atlassian/connect/play/actions/JwtExtractorSpec.scala index d07a321..ecea45c 100644 --- a/modules/core/test/io/toolsplus/atlassian/connect/play/actions/JwtExtractorSpec.scala +++ b/modules/core/test/io/toolsplus/atlassian/connect/play/actions/JwtExtractorSpec.scala @@ -36,7 +36,6 @@ class JwtExtractorSpec extends TestSpec { } "return None if request does not contain a token" in { - implicit val rawJwtNoShrink: Shrink[RawJwt] = Shrink.shrinkAny forAll(playRequestGen) { request => JwtExtractor.extractJwt(request) mustBe None } diff --git a/modules/core/test/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/JwtGeneratorSpec.scala b/modules/core/test/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/JwtGeneratorSpec.scala index c68e3ce..9fb93a6 100644 --- a/modules/core/test/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/JwtGeneratorSpec.scala +++ b/modules/core/test/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/JwtGeneratorSpec.scala @@ -55,7 +55,7 @@ class JwtGeneratorSpec extends TestSpec with GuiceOneAppPerSuite { } "set token expiry based on configured 'jwtExpirationTime'" in { - tokenPropertyTest { jwt: Jwt => + tokenPropertyTest { (jwt: Jwt) => val now = System.currentTimeMillis / 1000 val expiry = jwt.claims.getExpirationTime.getTime / 1000 val expectedExpiry = now + connectProperties.jwtExpirationTime @@ -64,7 +64,7 @@ class JwtGeneratorSpec extends TestSpec with GuiceOneAppPerSuite { } "set token issuer to add-on key" in { - tokenPropertyTest { jwt: Jwt => + tokenPropertyTest { (jwt: Jwt) => jwt.iss mustBe addonProperties.key } } diff --git a/modules/core/test/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/SymmetricJwtAuthenticationProviderSpec.scala b/modules/core/test/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/SymmetricJwtAuthenticationProviderSpec.scala index c4bfa6e..d9947f3 100644 --- a/modules/core/test/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/SymmetricJwtAuthenticationProviderSpec.scala +++ b/modules/core/test/io/toolsplus/atlassian/connect/play/auth/jwt/symmetric/SymmetricJwtAuthenticationProviderSpec.scala @@ -51,14 +51,14 @@ class SymmetricJwtAuthenticationProviderSpec forAll(atlassianHostGen) { aHost => val host = aHost.copy(sharedSecret = JwtTestHelper.defaultSigningSecret) - implicit val stringNoShrink: Shrink[String] = - Shrink.shrinkAny val customClaims = Seq("iss" -> host.clientKey) forAll( - symmetricJwtCredentialsGen(secret = host.sharedSecret, - customClaims)) { credentials => + symmetricJwtCredentialsGen(secret = host.sharedSecret, customClaims) + ) { credentials => (hostRepository - .findByClientKey(_: ClientKey)) expects host.clientKey returning Future + .findByClientKey( + _: ClientKey + )) expects host.clientKey returning Future .successful(None) val result = await { @@ -76,15 +76,13 @@ class SymmetricJwtAuthenticationProviderSpec "asked to authenticate Atlassian authenticated credentials" should { "fail if authenticated credentials' issuer does not exist" in { - implicit val stringNoShrink: Shrink[String] = - Shrink.shrinkAny forAll(atlassianHostGen) { aHost => val host = aHost.copy(sharedSecret = JwtTestHelper.defaultSigningSecret) val customClaims = Seq("iss" -> null) forAll( - symmetricJwtCredentialsGen(secret = host.sharedSecret, - customClaims)) { credentials => + symmetricJwtCredentialsGen(secret = host.sharedSecret, customClaims) + ) { credentials => val result = await { jwtAuthenticationProvider .authenticate(credentials, "fake-qsh") @@ -98,15 +96,13 @@ class SymmetricJwtAuthenticationProviderSpec } "fail if credentials' signature is not valid" in { - implicit val stringNoShrink: Shrink[String] = - Shrink.shrinkAny forAll(atlassianHostGen) { aHost => val host = aHost.copy(sharedSecret = JwtTestHelper.defaultSigningSecret) val customClaims = Seq("iss" -> host.clientKey) forAll( - symmetricJwtCredentialsGen(secret = host.sharedSecret, - customClaims)) { credentials => + symmetricJwtCredentialsGen(secret = host.sharedSecret, customClaims) + ) { credentials => val nCharsFromSignature = 4 val invalidSignatureJwt = credentials.rawJwt.dropRight(nCharsFromSignature) @@ -114,7 +110,9 @@ class SymmetricJwtAuthenticationProviderSpec credentials.copy(rawJwt = invalidSignatureJwt) (hostRepository - .findByClientKey(_: ClientKey)) expects host.clientKey returning Future + .findByClientKey( + _: ClientKey + )) expects host.clientKey returning Future .successful(Some(host)) val result = await { @@ -123,7 +121,8 @@ class SymmetricJwtAuthenticationProviderSpec .value } result mustBe Left( - InvalidJwtError(invalidSignatureCredentials.rawJwt)) + InvalidJwtError(invalidSignatureCredentials.rawJwt) + ) } } } @@ -136,10 +135,12 @@ class SymmetricJwtAuthenticationProviderSpec aHost.copy(sharedSecret = JwtTestHelper.defaultSigningSecret) val customClaims = Seq("iss" -> host.clientKey, "sub" -> subject) forAll( - symmetricJwtCredentialsGen(secret = host.sharedSecret, - customClaims)) { credentials => + symmetricJwtCredentialsGen(secret = host.sharedSecret, customClaims) + ) { credentials => (hostRepository - .findByClientKey(_: ClientKey)) expects host.clientKey returning Future + .findByClientKey( + _: ClientKey + )) expects host.clientKey returning Future .successful(Some(host)) val result = await { @@ -161,10 +162,12 @@ class SymmetricJwtAuthenticationProviderSpec val customClaims = Seq("iss" -> host.clientKey, "sub" -> userAccountId) forAll( - symmetricJwtCredentialsGen(secret = host.sharedSecret, - customClaims)) { credentials => + symmetricJwtCredentialsGen(secret = host.sharedSecret, customClaims) + ) { credentials => (hostRepository - .findByClientKey(_: ClientKey)) expects host.clientKey returning Future + .findByClientKey( + _: ClientKey + )) expects host.clientKey returning Future .successful(Some(host)) val result = await { @@ -173,7 +176,8 @@ class SymmetricJwtAuthenticationProviderSpec .value } result mustBe Right( - DefaultAtlassianHostUser(host, Some(userAccountId))) + DefaultAtlassianHostUser(host, Some(userAccountId)) + ) } } } @@ -190,17 +194,23 @@ class SymmetricJwtAuthenticationProviderSpec val customClaims = Seq("iss" -> host.clientKey, "sub" -> userAccountId, "qsh" -> qsh) forAll( - symmetricJwtCredentialsGen(secret = host.sharedSecret, - customClaims)) { credentials => + symmetricJwtCredentialsGen( + secret = host.sharedSecret, + customClaims + ) + ) { credentials => (hostRepository - .findByClientKey(_: ClientKey)) expects host.clientKey returning Future + .findByClientKey( + _: ClientKey + )) expects host.clientKey returning Future .successful(Some(host)) val result = await { jwtAuthenticationProvider.authenticate(credentials, qsh).value } result mustBe Right( - DefaultAtlassianHostUser(host, Some(userAccountId))) + DefaultAtlassianHostUser(host, Some(userAccountId)) + ) } } } @@ -216,17 +226,20 @@ class SymmetricJwtAuthenticationProviderSpec val customClaims = Seq("iss" -> host.clientKey, "sub" -> userAccountId, "qsh" -> qsh) forAll( - symmetricJwtCredentialsGen(secret = host.sharedSecret, - customClaims)) { credentials => + symmetricJwtCredentialsGen(secret = host.sharedSecret, customClaims) + ) { credentials => (hostRepository - .findByClientKey(_: ClientKey)) expects host.clientKey returning Future + .findByClientKey( + _: ClientKey + )) expects host.clientKey returning Future .successful(Some(host)) val result = await { jwtAuthenticationProvider.authenticate(credentials, qsh).value } result mustBe Right( - DefaultAtlassianHostUser(host, Some(userAccountId))) + DefaultAtlassianHostUser(host, Some(userAccountId)) + ) } } } diff --git a/modules/core/test/io/toolsplus/atlassian/connect/play/events/EventBusSpec.scala b/modules/core/test/io/toolsplus/atlassian/connect/play/events/EventBusSpec.scala index 537608a..4ea5406 100644 --- a/modules/core/test/io/toolsplus/atlassian/connect/play/events/EventBusSpec.scala +++ b/modules/core/test/io/toolsplus/atlassian/connect/play/events/EventBusSpec.scala @@ -35,8 +35,8 @@ class EventBusSpec "handle a subclass event" in new Context { val listener = system.actorOf(Props(new Actor { - def receive = { - case e => testProbe.ref ! e + def receive = { case e => + testProbe.ref ! e } })) @@ -51,8 +51,8 @@ class EventBusSpec "handle an event" in new Context { val listener = system.actorOf(Props(new Actor { - def receive = { - case e @ AppInstalledEvent(_) => testProbe.ref ! e + def receive = { case e @ AppInstalledEvent(_) => + testProbe.ref ! e } })) @@ -81,8 +81,8 @@ class EventBusSpec "differentiate between event classes" in new Context { val listener = system.actorOf(Props(new Actor { - def receive = { - case e @ AppInstalledEvent(_) => testProbe.ref ! e + def receive = { case e @ AppInstalledEvent(_) => + testProbe.ref ! e } })) @@ -93,9 +93,9 @@ class EventBusSpec } "not handle not subscribed events" in new Context { - val listener = system.actorOf(Props(new Actor { - def receive = { - case e @ AppInstalledEvent(_) => testProbe.ref ! e + system.actorOf(Props(new Actor { + def receive = { case e @ AppInstalledEvent(_) => + testProbe.ref ! e } })) @@ -110,13 +110,11 @@ class EventBusSpec trait Context { - /** - * Play actor system. + /** Play actor system. */ lazy implicit val system: ActorSystem = app.injector.instanceOf[ActorSystem] - /** - * Test probe. + /** Test probe. */ lazy val testProbe = TestProbe() diff --git a/modules/core/test/io/toolsplus/atlassian/connect/play/request/ws/PlayWsAtlassianConnectHttpClientSpec.scala b/modules/core/test/io/toolsplus/atlassian/connect/play/request/ws/PlayWsAtlassianConnectHttpClientSpec.scala index 3e56482..ee43ea4 100644 --- a/modules/core/test/io/toolsplus/atlassian/connect/play/request/ws/PlayWsAtlassianConnectHttpClientSpec.scala +++ b/modules/core/test/io/toolsplus/atlassian/connect/play/request/ws/PlayWsAtlassianConnectHttpClientSpec.scala @@ -43,7 +43,6 @@ class PlayWsAtlassianConnectHttpClientSpec } "set correct authorization and user-agent request headers" in { - implicit val doNotShrinkStrings: Shrink[String] = Shrink.shrinkAny forAll(atlassianHostGen) { host => val path = "foo" forAll(symmetricJwtCredentialsGen(host, subject = "bar")) { diff --git a/modules/core/test/io/toolsplus/atlassian/connect/play/services/LifecycleServiceSpec.scala b/modules/core/test/io/toolsplus/atlassian/connect/play/services/LifecycleServiceSpec.scala index 110093f..74fa76f 100644 --- a/modules/core/test/io/toolsplus/atlassian/connect/play/services/LifecycleServiceSpec.scala +++ b/modules/core/test/io/toolsplus/atlassian/connect/play/services/LifecycleServiceSpec.scala @@ -118,7 +118,7 @@ class LifecycleServiceSpec extends TestSpec { (hostRepository .save(_: AtlassianHost)) - .expects(where { host: AtlassianHost => + .expects(where { (host: AtlassianHost) => // since the created host has a slightly different TTL, // we cannot match against `uninstalledHost` directly !host.installed && host.ttl.isDefined diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 5cf8aa1..86a82e4 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -22,13 +22,13 @@ object Dependencies { } object Version { - val atlassianJwt = "0.3.2" + val atlassianJwt = "0.4.0" val cats = "2.13.0" val circe = "0.14.14" val playCirce = "3014.1" val scalaUri = "4.2.0" val sttp = "3.11.0" - val nimbusJoseJwt = "10.4.1" + val nimbusJoseJwt = "10.5" val scalaTest = "3.2.19" val scalaTestPlusPlay = "7.0.2" val scalaTestPlusScalaCheck = "3.2.18.0" diff --git a/project/build.properties b/project/build.properties index 081fdbb..01a16ed 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.0 +sbt.version=1.11.7