Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

[![Continuous integration](https://github.com/toolsplus/atlassian-jwt/actions/workflows/ci.yml/badge.svg)](https://github.com/toolsplus/atlassian-jwt/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/toolsplus/atlassian-jwt/branch/master/graph/badge.svg)](https://codecov.io/gh/toolsplus/atlassian-jwt)
[![Maven Central](https://img.shields.io/maven-central/v/io.toolsplus/atlassian-jwt-core_2.13.svg)](https://maven-badges.herokuapp.com/maven-central/io.toolsplus/atlassian-jwt-core_2.12)
[![Maven Central](https://img.shields.io/maven-central/v/io.toolsplus/atlassian-jwt-core_3.svg)](https://maven-badges.herokuapp.com/maven-central/io.toolsplus/atlassian-jwt-core_3)

Utilities to read, validate and generate valid Atlassian JWTs. Atlassian tokens
are identical to regular JWTs with the exception of a few custom claims, such as `qsh` claim.

## Quick start

atlassian-jwt is published to Maven Central Scala 2.13:
atlassian-jwt is published to Maven Central for Scala 3:

libraryDependencies += "io.toolsplus" %% "atlassian-jwt" % "x.x.x"

Expand Down
9 changes: 6 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ 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
.sonatypeOssRepos("releases")
resolvers ++= Seq(
Resolver.typesafeRepo("releases"),
Resolver
.sonatypeCentralRepo("releases")
)
)

lazy val publishSettings = Seq(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,126 +1,129 @@
package io.toolsplus.atlassian.jwt

import java.io.UnsupportedEncodingException
import java.net.URLEncoder
import java.security.{MessageDigest, NoSuchAlgorithmException}
import java.security.MessageDigest

import io.toolsplus.atlassian.jwt.api.CanonicalHttpRequest
import org.bouncycastle.util.encoders.Hex

/**
* Instructions for computing the query hash parameter ("qsh") from a HTTP request.
/** Instructions for computing the query hash parameter ("qsh") from a HTTP
* request.
* -------------------------------------------------------------------------------------
*
* Overview: query hash = hash(canonical-request)
* Overview: query hash = hash(canonical-request)
*
* canonical-request = canonical-method + '&' + canonical-URI + '&' + canonical-query-string
* canonical-request = canonical-method + '&' + canonical-URI + '&' +
* canonical-query-string
*
* 1. Compute canonical method.
* Simply the upper-case of the method name (e.g. "GET", "PUT").
*
* 2. Append the character '&'
*
* 3. Compute canonical URI.
* Discard the protocol, server, port, context path and query parameters from the full URL.
* For requests targeting add-ons discard the `baseUrl` in the add-on descriptor.
* (Removing the context path allows a reverse proxy to redirect incoming requests for "jira.example.com/getsomething"
* to "example.com/jira/getsomething" without breaking authentication. The requester cannot know that the reverse proxy
* will prepend the context path "/jira" to the originally requested path "/getsomething".)
* Empty-string is not permitted; use "/" instead.
* Do not suffix with a '/' character unless it is the only character.
* Url-encode any '&' characters in the path.
* E.g. in "http://server:80/some/path/?param=value" the canonical URI is "/some/path"
* and in "http://server:80" the canonical URI is "/".
*
* 4. Append the character '&'.
*
* 5. Compute the canonical query string.
* Sort the query parameters primarily by their percent-encoded names and secondarily by their percent-encoded values.
* Sorting is by codepoint: sort(["a", "A", "b", "B"]) => ["A", "B", "a", "b"].
* For each parameter append its percent-encoded name, the '=' character and then its percent-encoded value.
* In the case of repeated parameters append the ',' character and subsequent percent-encoded values.
* Ignore the JWT query string parameter, if present.
* Some particular values to be aware of: "+" is encoded as "%20",
* "*" as "%2A" and
* "~" as "~".
* (These values used for consistency with OAuth1.)
* An example: for a GET request to the not-yet-percent-encoded URL
* "http://localhost:2990/path/to/service?zee_last=param&repeated=parameter 1&first=param&
* repeated=parameter 2"
* the canonical request is "GET&/path/to/service&first=param&repeated=parameter%201,parameter%202&
* zee_last=param".
*
* 6. Convert the canonical request string to bytes.
* The encoding used to represent characters as bytes is UTF-8.
*
* 7. Hash the canonical request bytes using the SHA-256 algorithm.
* E.g. The SHA-256 hash of "foo" is "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae".
* 1. Compute canonical method. Simply the upper-case of the method name
* (e.g. "GET", "PUT").
* 2. Append the character '&'
* 3. Compute canonical URI. Discard the protocol, server, port, context path
* and query parameters from the full URL. For requests targeting add-ons
* discard the `baseUrl` in the add-on descriptor. (Removing the context
* path allows a reverse proxy to redirect incoming requests for
* "jira.example.com/getsomething" to "example.com/jira/getsomething"
* without breaking authentication. The requester cannot know that the
* reverse proxy will prepend the context path "/jira" to the originally
* requested path "/getsomething".) Empty-string is not permitted; use "/"
* instead. Do not suffix with a '/' character unless it is the only
* character. Url-encode any '&' characters in the path. E.g. in
* "http://server:80/some/path/?param=value" the canonical URI is
* "/some/path" and in "http://server:80" the canonical URI is "/".
* 4. Append the character '&'.
* 5. Compute the canonical query string. Sort the query parameters primarily
* by their percent-encoded names and secondarily by their percent-encoded
* values. Sorting is by codepoint: sort(["a", "A", "b", "B"]) => ["A",
* "B", "a", "b"]. For each parameter append its percent-encoded name, the
* '=' character and then its percent-encoded value. In the case of
* repeated parameters append the ',' character and subsequent
* percent-encoded values. Ignore the JWT query string parameter, if
* present. Some particular values to be aware of: "+" is encoded as
* "%20", "*" as "%2A" and "~" as "~". (These values used for consistency
* with OAuth1.) An example: for a GET request to the
* not-yet-percent-encoded URL
* "http://localhost:2990/path/to/service?zee_last=param&repeated=parameter
* 1&first=param& repeated=parameter 2" the canonical request is
* "GET&/path/to/service&first=param&repeated=parameter%201,parameter%202&
* zee_last=param".
* 6. Convert the canonical request string to bytes. The encoding used to
* represent characters as bytes is UTF-8.
* 7. Hash the canonical request bytes using the SHA-256 algorithm. E.g. The
* SHA-256 hash of "foo" is
* "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae".
*/
object HttpRequestCanonicalizer {

val QueryStringHashClaimName: String = "qsh"

/**
* When the JWT message is specified in the query string of a URL then this is the parameter name.
/** When the JWT message is specified in the query string of a URL then this
* is the parameter name.
*
* E.g. "jwt" in:
* <pre>
* E.g. "jwt" in: <pre>
* http://server:80/some/path?otherparam=value&amp;jwt=eyJhbGciOiJIUzI1NiIsI.eyJleHAiOjEzNzg5NCI6MTM3ODk1MjQ4OH0
* .cDihfcsKW_We_EY21tIs55dVwjU
* </pre>
* .cDihfcsKW_We_EY21tIs55dVwjU </pre>
*/
private val JwtParamName: String = "jwt"

/**
* Query parameter separator as it appears between "value1" and "param2" in the URL
* "http://server/path?param1=value1&amp;param2=value2".
/** Query parameter separator as it appears between "value1" and "param2" in
* the URL "http://server/path?param1=value1&amp;param2=value2".
*/
private val QueryParamsSeparator: Char = '&'

/**
* The character between "a" and "b%20c" in "some_param=a,b%20c"
/** The character between "a" and "b%20c" in "some_param=a,b%20c"
*/
private val EncodedParamValueSeparator: String = ","

/**
* For separating the method, URI etc in a canonical request string.
/** For separating the method, URI etc in a canonical request string.
*/
private[jwt] val CanonicalRequestPartSeparator: Char = '&'

/**
* Assemble the components of the HTTP request into the correct format so that they can be signed or hashed.
/** Assemble the components of the HTTP request into the correct format so
* that they can be signed or hashed.
*
* @param request [[CanonicalHttpRequest]] that provides the necessary components
* @return String encoding the canonical form of this request as required for constructing query string hash values
* @throws UnsupportedEncodingException [[UnsupportedEncodingException]] if the [[java.net.URLEncoder]] cannot encode the request's field's characters
* @param request
* [[CanonicalHttpRequest]] that provides the necessary components
* @return
* String encoding the canonical form of this request as required for
* constructing query string hash values
* @throws UnsupportedEncodingException
* [[java.io.UnsupportedEncodingException]] if the [[java.net.URLEncoder]]
* cannot encode the request's field's characters
*/
def canonicalize(request: CanonicalHttpRequest): String =
s"${canonicalizeMethod(request)}$CanonicalRequestPartSeparator" +
s"${canonicalizeUri(request)}$CanonicalRequestPartSeparator" +
s"${canonicalizeQueryParameters(request)}"

/**
* Canonicalize the given [[CanonicalHttpRequest]] and hash it.
* This request hash can be included as a JWT claim to verify that request components are genuine.
/** Canonicalize the given [[CanonicalHttpRequest]] and hash it. This request
* hash can be included as a JWT claim to verify that request components are
* genuine.
*
* @param request CanonicalHttpRequest to be canonicalized and hashed
* @return String hash suitable for use as a JWT claim value
* @throws UnsupportedEncodingException if the [[java.net.URLEncoder]] cannot encode the request's field's characters
* @throws NoSuchAlgorithmException if the hashing algorithm does not exist at runtime
* @param request
* CanonicalHttpRequest to be canonicalized and hashed
* @return
* String hash suitable for use as a JWT claim value
* @throws UnsupportedEncodingException
* if the [[java.net.URLEncoder]] cannot encode the request's field's
* characters
* @throws NoSuchAlgorithmException
* if the hashing algorithm does not exist at runtime
*/
def computeCanonicalRequestHash(request: CanonicalHttpRequest): String = {
// prevent the code in this method being repeated in every call site that needs a request hash,
// encapsulate the knowledge of the type of hash that we are using
computeSha256Hash(canonicalize(request))
}

/**
* Compute the SHA-256 hash of hashInput.
* E.g. The SHA-256 hash of "foo" is "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae".
/** Compute the SHA-256 hash of hashInput. E.g. The SHA-256 hash of "foo" is
* "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae".
*
* @param hashInput String to be hashed.
* @return String hash
* @throws NoSuchAlgorithmException if the hashing algorithm does not exist at runtime
* @param hashInput
* String to be hashed.
* @return
* String hash
* @throws NoSuchAlgorithmException
* if the hashing algorithm does not exist at runtime
*/
private def computeSha256Hash(hashInput: String): String = {
val digest = MessageDigest.getInstance("SHA-256")
Expand Down Expand Up @@ -153,18 +156,17 @@ object HttpRequestCanonicalizer {
}

private[jwt] def canonicalizeQueryParameters(
request: CanonicalHttpRequest): String = {
request: CanonicalHttpRequest
): String = {
(request.parameterMap - JwtParamName).toSeq
.sortBy {
case (key, values) =>
s"${percentEncode(key)} ${percentEncode(values.mkString(","))}"
.sortBy { case (key, values) =>
s"${percentEncode(key)} ${percentEncode(values.mkString(","))}"
}
.map((percentEncodePair _).tupled)
.mkString(QueryParamsSeparator.toString)
}

/**
* Construct a form-urlencoded document from the given name/parameter pair.
/** Construct a form-urlencoded document from the given name/parameter pair.
*/
private def percentEncodePair(key: String, values: Seq[String]): String = {
val encKey = percentEncode(key)
Expand All @@ -173,13 +175,15 @@ object HttpRequestCanonicalizer {
s"$encKey=$encVal"
}

/**
* Encode value using URLEncoder.encode() but encode some characters differently to URLEncoder, to match OAuth1
* and VisualVault.
/** Encode value using URLEncoder.encode() but encode some characters
* differently to URLEncoder, to match OAuth1 and VisualVault.
*
* @param value String to be percent-encoded
* @return encoded Encoded result string
* @throws UnsupportedEncodingException if URLEncoder does not support UTF-8
* @param value
* String to be percent-encoded
* @return
* encoded Encoded result string
* @throws UnsupportedEncodingException
* if URLEncoder does not support UTF-8
*/
private def percentEncode(value: String): String =
URLEncoder
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
package io.toolsplus.atlassian.jwt

import cats.syntax.either._
import com.nimbusds.jose._

import scala.util.{Failure, Success, Try}

/**
* JWT Writer to write valid Atlassian compatible JWTs.
/** JWT Writer to write valid Atlassian compatible JWTs.
*
* Each writer has to be configured with the [[JWSAlgorithm]] and [[JWSSigner]]
* Each writer has to be configured with the [[JWSAlgorithm]] and [[JWSSigner]]
* that will be used sign the token.
*/
case class JwtWriter(algorithm: JWSAlgorithm, signer: JWSSigner) {
Expand All @@ -23,7 +21,7 @@ case class JwtWriter(algorithm: JWSAlgorithm, signer: JWSSigner) {

val jwsObject = new JWSObject(header, new Payload(payload))
Try(jwsObject.sign(signer)) match {
case Success(_) => Right(jwsObject)
case Success(_) => Right(jwsObject)
case Failure(exception) =>
Left(JwtSigningError(exception.getMessage, exception))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,15 @@ package io.toolsplus.atlassian.jwt
import io.toolsplus.atlassian.jwt.generators.core.JwtGen
import io.toolsplus.atlassian.jwt.generators.nimbus.NimbusGen

class HttpRequestCanonicalizerSpec
extends TestSpec
with JwtGen
with NimbusGen {
class HttpRequestCanonicalizerSpec extends TestSpec with JwtGen with NimbusGen {

"Using a HttpRequestCanonicalizer" when {

"given a valid CanonicalHttpRequest" should {

"compute the correct canonical request string" in forAll(
canonicalHttpRequestGen) { request =>
canonicalHttpRequestGen
) { request =>
def partsOf(r: String) =
r.split(HttpRequestCanonicalizer.CanonicalRequestPartSeparator)
val canonicalizedRequest =
Expand All @@ -23,14 +21,16 @@ class HttpRequestCanonicalizerSpec
}

"compute the correct canonical method string" in forAll(
canonicalHttpRequestGen) { request =>
canonicalHttpRequestGen
) { request =>
val canonicalizedMethod =
HttpRequestCanonicalizer.canonicalizeMethod(request)
canonicalizedMethod mustBe canonicalizedMethod.toUpperCase
}

"compute the correct canonical uri string" in forAll(
canonicalHttpRequestGen) { request =>
canonicalHttpRequestGen
) { request =>
val canonicalizedUri =
HttpRequestCanonicalizer.canonicalizeUri(request)
canonicalizedUri must startWith("/")
Expand All @@ -39,10 +39,11 @@ class HttpRequestCanonicalizerSpec
}

"successfully compute canonical request hash" in forAll(
canonicalHttpRequestGen) { request =>
canonicalHttpRequestGen
) { request =>
val canonicalRequestHash =
HttpRequestCanonicalizer.computeCanonicalRequestHash(request)

canonicalRequestHash must not be empty
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import io.toolsplus.atlassian.jwt.generators.util.JwtTestHelper
import org.scalacheck.Gen._
import org.scalatest.Assertion

import scala.collection.JavaConverters._
import scala.jdk.CollectionConverters._

class JwtBuilderSpec extends TestSpec {

Expand Down Expand Up @@ -48,8 +48,8 @@ class JwtBuilderSpec extends TestSpec {

"successfully create JWT claims with overridden issue time" in {
val expireAfter = Duration.of(10, ChronoUnit.SECONDS)
val expectedIssuedAt = Instant.now plus Duration.of(5,
ChronoUnit.SECONDS)
val expectedIssuedAt =
Instant.now plus Duration.of(5, ChronoUnit.SECONDS)
val result = new JwtBuilder(expireAfter)
.withIssuedAt(expectedIssuedAt.getEpochSecond)
.build(JwtTestHelper.defaultSigningSecret)
Expand Down Expand Up @@ -126,8 +126,9 @@ class JwtBuilderSpec extends TestSpec {

}

private def validate(assertion: Jwt => Assertion)(
result: Either[JwtSigningError, RawJwt]) = {
private def validate(
assertion: Jwt => Assertion
)(result: Either[JwtSigningError, RawJwt]) = {
result match {
case Right(rawJwt) =>
JwtParser.parse(rawJwt) match {
Expand Down
Loading