-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
- Add JWT validation library to project dependencies - Add ForgeRemoteSignedForgeRemoteContextAction to verify Forge Remote requests - Add action refiners and helper classes to extract and validate Forge Remote context and JWTs - Add `AtlassianHostUserRequest` abstraction because we now have host-user request from Connect and host-user requests from Forge - Add tests for new code fixes #76
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
package io.toolsplus.atlassian.connect.play.actions | ||
|
||
import io.toolsplus.atlassian.connect.play.api.models.AtlassianHostUser | ||
import play.api.mvc.{Request, WrappedRequest} | ||
|
||
abstract class AtlassianHostUserRequest[A](request: Request[A]) | ||
extends WrappedRequest[A](request) { | ||
def hostUser: AtlassianHostUser | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
package io.toolsplus.atlassian.connect.play.actions | ||
|
||
import cats.data.Validated.{Invalid, Valid} | ||
import io.toolsplus.atlassian.connect.play.auth.frc.ForgeRemoteCredentials | ||
import play.api.Logger | ||
import play.api.mvc.Results.Unauthorized | ||
import play.api.mvc.{ActionRefiner, Request, Result, WrappedRequest} | ||
|
||
import javax.inject.Inject | ||
import scala.concurrent.{ExecutionContext, Future} | ||
|
||
case class ForgeRemoteRequest[A](credentials: ForgeRemoteCredentials, | ||
request: Request[A]) | ||
extends WrappedRequest[A](request) | ||
|
||
/** | ||
* Play action refiner that extracts the Forge Remote credentials from a request | ||
* | ||
* Note that this refiner will intercept the request and return an unauthorized | ||
* result if invalid or no Forge Remote credentials were found. | ||
*/ | ||
class ForgeRemoteActionRefiner @Inject()( | ||
implicit val executionContext: ExecutionContext) | ||
extends ActionRefiner[Request, ForgeRemoteRequest] { | ||
|
||
private val logger = Logger(classOf[ForgeRemoteActionRefiner]) | ||
|
||
override def refine[A]( | ||
request: Request[A]): Future[Either[Result, ForgeRemoteRequest[A]]] = | ||
ForgeRemoteCredentialsExtractor.extract(request) match { | ||
case Valid(credentials) => | ||
Future.successful(Right(ForgeRemoteRequest(credentials, request))) | ||
case Invalid(errors) => | ||
logger.info( | ||
s"Failed to extract Forge Remote credentials: ${errors.map(_.getMessage).toNonEmptyList.toList.mkString(", ")}") | ||
Future.successful( | ||
Left(Unauthorized("Invalid or missing Forge Remote credentials"))) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
package io.toolsplus.atlassian.connect.play.actions | ||
|
||
import cats.data.ValidatedNec | ||
import cats.implicits._ | ||
import io.toolsplus.atlassian.connect.play.auth.frc.ForgeRemoteCredentials | ||
import play.api.http.HeaderNames | ||
import play.api.mvc.Request | ||
|
||
object ForgeRemoteCredentialsExtractor { | ||
|
||
/** | ||
* Attempts to extract Forge Remote credentials from the given request. . | ||
* | ||
* @param request Request from which to extract credentials | ||
* @return Either unverified Forge Remote credentials or a list of errors | ||
*/ | ||
def extract[A]( | ||
request: Request[A]): ValidatedNec[Exception, ForgeRemoteCredentials] = { | ||
(validateTraceId(request), | ||
validateSpanId(request), | ||
validateForgeInvocationToken(request)).mapN( | ||
(traceId, spanId, forgeInvocationToken) => | ||
ForgeRemoteCredentials(traceId, | ||
spanId, | ||
forgeInvocationToken, | ||
request.headers | ||
.get("x-forge-oauth-system"), | ||
request.headers | ||
.get("x-forge-oauth-user"))) | ||
} | ||
|
||
private def validateTraceId[A]( | ||
request: Request[A]): ValidatedNec[Exception, String] = | ||
request.headers | ||
.get("x-b3-traceid") match { | ||
case Some(traceId) => traceId.validNec | ||
case None => | ||
new Exception(s"Missing 'x-b3-traceid' header").invalidNec | ||
} | ||
|
||
private def validateSpanId[A]( | ||
request: Request[A]): ValidatedNec[Exception, String] = | ||
request.headers | ||
.get("x-b3-spanid") match { | ||
case Some(spanId) => spanId.validNec | ||
case None => | ||
new Exception(s"Missing 'x-b3-spanid' header").invalidNec | ||
} | ||
|
||
private def validateForgeInvocationToken[A]( | ||
request: Request[A]): ValidatedNec[Exception, String] = | ||
request.headers | ||
.get(HeaderNames.AUTHORIZATION) match { | ||
case Some(fitHeader) => | ||
val authorizationHeaderPrefix = "bearer " | ||
fitHeader match { | ||
case header | ||
if header.toLowerCase.startsWith(authorizationHeaderPrefix) => | ||
header.substring(authorizationHeaderPrefix.length).trim.validNec | ||
case _ => | ||
new Exception(s"Invalid Forge Invocation Token header").invalidNec | ||
} | ||
case None => | ||
new Exception(s"Missing Forge Invocation Token header").invalidNec | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
package io.toolsplus.atlassian.connect.play.actions.asymmetric | ||
|
||
import io.toolsplus.atlassian.connect.play.actions.AtlassianHostUserRequest | ||
import io.toolsplus.atlassian.connect.play.api.models.{ | ||
AtlassianHost, | ||
AtlassianHostUser, | ||
DefaultAtlassianHostUser | ||
} | ||
import io.toolsplus.atlassian.connect.play.api.repositories.{ | ||
AtlassianHostRepository, | ||
ForgeInstallationRepository | ||
} | ||
import play.api.Logger | ||
import play.api.mvc.Results.BadRequest | ||
import play.api.mvc.{ActionRefiner, Result, WrappedRequest} | ||
|
||
import javax.inject.Inject | ||
import scala.concurrent.{ExecutionContext, Future} | ||
|
||
trait AbstractAssociateAtlassianHostUserActionRefiner[R[A] <: WrappedRequest[A]] | ||
extends ActionRefiner[ForgeRemoteContextRequest, R] { | ||
|
||
implicit def executionContext: ExecutionContext | ||
|
||
def logger: Logger | ||
def hostRepository: AtlassianHostRepository | ||
def forgeInstallationRepository: ForgeInstallationRepository | ||
|
||
def hostSearchResultToActionResult[A]( | ||
maybeHost: Option[AtlassianHost], | ||
request: ForgeRemoteContextRequest[A]): Either[Result, R[A]] | ||
|
||
override def refine[A]( | ||
request: ForgeRemoteContextRequest[A]): Future[Either[Result, R[A]]] = { | ||
val installationId = request.context.invocationContext.app.installationId | ||
forgeInstallationRepository | ||
.findByInstallationId(installationId) | ||
.flatMap({ | ||
case Some(installation) => | ||
hostRepository | ||
.findByClientKey(installation.clientKey) | ||
.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") | ||
Future.successful(Left(BadRequest(s"Missing Connect mapping"))) | ||
}) | ||
} | ||
} | ||
|
||
case class ForgeRemoteAssociateAtlassianHostUserRequest[A]( | ||
hostUser: AtlassianHostUser, | ||
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. | ||
* | ||
* 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()( | ||
override val hostRepository: AtlassianHostRepository, | ||
override val forgeInstallationRepository: ForgeInstallationRepository)( | ||
override implicit val executionContext: ExecutionContext) | ||
extends AbstractAssociateAtlassianHostUserActionRefiner[ | ||
ForgeRemoteAssociateAtlassianHostUserRequest] { | ||
override val logger: Logger = Logger( | ||
classOf[AssociateAtlassianHostUserActionRefiner]) | ||
|
||
override def hostSearchResultToActionResult[A]( | ||
maybeHost: Option[AtlassianHost], | ||
request: ForgeRemoteContextRequest[A]) | ||
: Either[Result, ForgeRemoteAssociateAtlassianHostUserRequest[A]] = | ||
maybeHost match { | ||
case Some(host) => | ||
Right( | ||
ForgeRemoteAssociateAtlassianHostUserRequest( | ||
DefaultAtlassianHostUser( | ||
host, | ||
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") | ||
Left(BadRequest(s"Missing Connect installation")) | ||
} | ||
|
||
object Implicits { | ||
|
||
import scala.language.implicitConversions | ||
|
||
/** | ||
* 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. | ||
*/ | ||
implicit def requestToHostUser( | ||
implicit request: ForgeRemoteAssociateAtlassianHostUserRequest[_]) | ||
: AtlassianHostUser = | ||
request.hostUser | ||
Check warning on line 106 in modules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/AssociateAtlassianHostUserAction.scala Codecov / codecov/patchmodules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/AssociateAtlassianHostUserAction.scala#L106
|
||
|
||
} | ||
|
||
} | ||
|
||
case class ForgeRemoteAssociateMaybeAtlassianHostUserRequest[A]( | ||
hostUser: Option[AtlassianHostUser], | ||
request: ForgeRemoteContextRequest[A]) | ||
extends WrappedRequest[A](request) | ||
|
||
/** | ||
* 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. | ||
*/ | ||
case class AssociateMaybeAtlassianHostUserActionRefiner @Inject()( | ||
override val hostRepository: AtlassianHostRepository, | ||
override val forgeInstallationRepository: ForgeInstallationRepository)( | ||
override implicit val executionContext: ExecutionContext) | ||
extends AbstractAssociateAtlassianHostUserActionRefiner[ | ||
ForgeRemoteAssociateMaybeAtlassianHostUserRequest] { | ||
override val logger: Logger = Logger( | ||
classOf[AssociateMaybeAtlassianHostUserActionRefiner]) | ||
|
||
override def hostSearchResultToActionResult[A]( | ||
maybeHost: Option[AtlassianHost], | ||
request: ForgeRemoteContextRequest[A]) | ||
: Either[Result, ForgeRemoteAssociateMaybeAtlassianHostUserRequest[A]] = | ||
Right( | ||
ForgeRemoteAssociateMaybeAtlassianHostUserRequest( | ||
maybeHost.map( | ||
DefaultAtlassianHostUser( | ||
_, | ||
request.context.invocationContext.principal)), | ||
request)) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
package io.toolsplus.atlassian.connect.play.actions.asymmetric | ||
|
||
import cats.implicits._ | ||
import io.toolsplus.atlassian.connect.play.actions.{ | ||
ForgeRemoteActionRefiner, | ||
ForgeRemoteRequest | ||
} | ||
import io.toolsplus.atlassian.connect.play.auth.frc.ForgeRemoteContext | ||
import io.toolsplus.atlassian.connect.play.auth.frc.jwt.ForgeRemoteJwtAuthenticationProvider | ||
import play.api.mvc.Results.Unauthorized | ||
import play.api.mvc.{ | ||
ActionBuilder, | ||
ActionRefiner, | ||
AnyContent, | ||
BodyParsers, | ||
Request, | ||
Result, | ||
WrappedRequest | ||
} | ||
|
||
import javax.inject.Inject | ||
import scala.concurrent.{ExecutionContext, Future} | ||
|
||
case class ForgeRemoteContextRequest[A](context: ForgeRemoteContext, | ||
request: ForgeRemoteRequest[A]) | ||
extends WrappedRequest[A](request) | ||
|
||
case class ForgeRemoteSignedForgeRemoteContextActionRefiner( | ||
authenticationProvider: ForgeRemoteJwtAuthenticationProvider)( | ||
implicit val executionContext: ExecutionContext) | ||
extends ActionRefiner[ForgeRemoteRequest, ForgeRemoteContextRequest] { | ||
override def refine[A](request: ForgeRemoteRequest[A]) | ||
: Future[Either[Result, ForgeRemoteContextRequest[A]]] = { | ||
Future.successful( | ||
authenticationProvider | ||
.authenticate(request.credentials) | ||
.map(ForgeRemoteContextRequest(_, request)) | ||
.leftMap(e => Unauthorized(s"JWT validation failed"))) | ||
} | ||
} | ||
|
||
class ForgeRemoteSignedForgeRemoteContextAction @Inject()( | ||
bodyParser: BodyParsers.Default, | ||
forgeRemoteActionRefiner: ForgeRemoteActionRefiner, | ||
authenticationProvider: ForgeRemoteJwtAuthenticationProvider)( | ||
implicit executionCtx: ExecutionContext) { | ||
|
||
/** | ||
* Creates an action builder that validates requests signed by Forge Remote Compute. | ||
* | ||
* @return Play action for Forge Remote Compute requests | ||
*/ | ||
def authenticate: ActionBuilder[ForgeRemoteContextRequest, AnyContent] = | ||
new ActionBuilder[ForgeRemoteContextRequest, AnyContent] { | ||
override val parser: BodyParsers.Default = bodyParser | ||
override val executionContext: ExecutionContext = executionCtx | ||
Check warning on line 56 in modules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/ForgeRemoteSignedForgeRemoteContextAction.scala Codecov / codecov/patchmodules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/ForgeRemoteSignedForgeRemoteContextAction.scala#L54-L56
|
||
override def invokeBlock[A]( | ||
request: Request[A], | ||
block: ForgeRemoteContextRequest[A] => Future[Result]) | ||
: Future[Result] = { | ||
(forgeRemoteActionRefiner andThen ForgeRemoteSignedForgeRemoteContextActionRefiner( | ||
authenticationProvider)) | ||
.invokeBlock(request, block) | ||
Check warning on line 63 in modules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/ForgeRemoteSignedForgeRemoteContextAction.scala Codecov / codecov/patchmodules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/ForgeRemoteSignedForgeRemoteContextAction.scala#L63
|
||
} | ||
} | ||
} |