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 tests for new code

fixes #76
  • Loading branch information
tbinna committed May 15, 2024
1 parent 96fbd7e commit b6cd762
Show file tree
Hide file tree
Showing 25 changed files with 1,708 additions and 10 deletions.
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,123 @@
package io.toolsplus.atlassian.connect.play.actions.asymmetric

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 WrappedRequest[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"))
}
}

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
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.toolsplus.atlassian.connect.play.auth.frc

import io.toolsplus.atlassian.connect.play.auth.frc.jwt.ForgeInvocationContext

/**
* Verified Forge Remote context including the invocation context and the credentials associated with the call.
*/
case class ForgeRemoteContext(invocationContext: ForgeInvocationContext,
credentials: ForgeRemoteCredentials)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.toolsplus.atlassian.connect.play.auth.frc

/**
* Authentication credentials representing an unverified Forge Remote invocation context.
*
* @param traceId Trace id is 64 or 128-bit in length and indicates the overall ID of the trace
* @param spanId Span id is 64 or 128-bit in length and indicates the position of the current operation in the trace tree
* @param forgeInvocationToken Forge Invocation Token (FIT) is the JWT token authenticating the Forge Remote call
* @param appSystemToken App system token that allows the app to call Atlassian APIs
* @param appUserToken App user token that allows the app to call Atlassian APIs
*
* @see https://developer.atlassian.com/platform/forge/forge-remote-overview/
*/
case class ForgeRemoteCredentials(traceId: String,
spanId: String,
forgeInvocationToken: String,
appSystemToken: Option[String],
appUserToken: Option[String])
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.toolsplus.atlassian.connect.play.auth.frc.jwt

import com.nimbusds.jose.proc.SecurityContext
import io.circe.JsonObject

/**
* Environment provides information about the Forge environment the app is running in
* @param `type` Forge environment type associated with a Forge Remote call, such as DEVELOPMENT, STAGING, PRODUCTION
* @param id Forge environment id associated with a Forge Remote call
*/
final case class Environment(`type`: String, id: String)

/**
* Module provides information about the module that initiated a Forge remote call.
*
* @param `type` Module type initiating the remote call, such as `xen:macro` for front-end invocations. Otherwise, it will be core:endpoint. To determine the type of module that specified the remote resolver, refer to `context.extension.type`
* @param key Forge module key for this endpoint as specified in the manifest.yml
*/
final case class Module(`type`: String, key: String)

/**
*
* @param installationId Identifier for the specific installation of an app. This is the value that any remote storage should be keyed against.
* @param apiBaseUrl Base URL where all product API requests should be routed
* @param id Forge application ID matching the value in the Forge manifest.yml
* @param version Forge application version being invoked
* @param environment Information about the environment the app is running in
* @param module Information about the module that initiated this remote call
*/
final case class App(installationId: String,
apiBaseUrl: String,
id: String,
version: Int,
environment: Environment,
module: Module)

/**
* Forge invocation context represents the payload included in the Forge Invocation Token (FIT).
*
* The FIT payload includes details about the the invocation context of a Forge Remote call.
*
* @param app Details about the app and installation context
* @param context Context depending on how the app is using Forge Remote
* @param principal ID of the user who invoked the app. UI modules only
*
* @see https://developer.atlassian.com/platform/forge/forge-remote-overview/#the-forge-invocation-token--fit-
*/
final case class ForgeInvocationContext(app: App,
context: Option[JsonObject],
principal: Option[String])
extends SecurityContext
Loading

0 comments on commit b6cd762

Please sign in to comment.