-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- 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
Showing
25 changed files
with
1,708 additions
and
10 deletions.
There are no files selected for viewing
39 changes: 39 additions & 0 deletions
39
modules/core/app/io/toolsplus/atlassian/connect/play/actions/ForgeRemoteAction.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"))) | ||
} | ||
} |
66 changes: 66 additions & 0 deletions
66
...ore/app/io/toolsplus/atlassian/connect/play/actions/ForgeRemoteCredentialsExtractor.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
123 changes: 123 additions & 0 deletions
123
...oolsplus/atlassian/connect/play/actions/asymmetric/AssociateAtlassianHostUserAction.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |
66 changes: 66 additions & 0 deletions
66
...atlassian/connect/play/actions/asymmetric/ForgeRemoteSignedForgeRemoteContextAction.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
|
||
} | ||
} | ||
} |
9 changes: 9 additions & 0 deletions
9
modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/ForgeRemoteContext.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
18 changes: 18 additions & 0 deletions
18
modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/ForgeRemoteCredentials.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) |
51 changes: 51 additions & 0 deletions
51
...es/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeInvocationContext.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.