Skip to content

Commit

Permalink
Forge Remote
Browse files Browse the repository at this point in the history
- 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
tbinna committed May 22, 2024
1 parent 96fbd7e commit 00caf9b
Show file tree
Hide file tree
Showing 28 changed files with 1,763 additions and 27 deletions.
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

View check run for this annotation

Codecov / codecov/patch

modules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/AssociateAtlassianHostUserAction.scala#L106

Added line #L106 was not covered by tests

}

}

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

View check run for this annotation

Codecov / codecov/patch

modules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/ForgeRemoteSignedForgeRemoteContextAction.scala#L54-L56

Added lines #L54 - L56 were not covered by tests
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

View check run for this annotation

Codecov / codecov/patch

modules/core/app/io/toolsplus/atlassian/connect/play/actions/asymmetric/ForgeRemoteSignedForgeRemoteContextAction.scala#L63

Added line #L63 was not covered by tests
}
}
}
Loading

0 comments on commit 00caf9b

Please sign in to comment.