All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.toolsplus.atlassian.jwt.JwtReader.scala Maven / Gradle / Ivy

The newest version!
package io.toolsplus.atlassian.jwt

import java.time.Instant

import cats.syntax.either._
import com.nimbusds.jose.crypto.MACVerifier
import com.nimbusds.jose.{JWSObject, JWSVerifier}
import com.nimbusds.jwt.JWTClaimsSet

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

/**
  * JWT Reader to read and verify JWT strings.
  *
  * Each reader has to be configured with the shared secret that it will use
  * to verify JWT signatures.
  *
  * NOTE: If the JWT does not include the qsh claim, verification will still succeed.
  * This is because self-authenticated tokens do not contain the qsh claim.
  *
  */
case class JwtReader(sharedSecret: String) {

  private final val verifier: JWSVerifier = new MACVerifier(sharedSecret)

  def readAndVerify(jwt: String, queryStringHash: String): Either[Error, Jwt] =
    read(jwt, queryStringHash, shouldVerifySignature = true)

  private def read(jwt: String,
                   queryStringHash: String,
                   shouldVerifySignature: Boolean): Either[Error, Jwt] = {
    JwtParser.parseJWSObject(jwt) match {
      case Right(jwsObject) =>
        if (shouldVerifySignature) {
          verifySignature(jwsObject) match {
            case Right(_)    => verifyRest(jwsObject, queryStringHash)
            case l @ Left(_) => l.asInstanceOf[Either[Error, Jwt]]
          }
        } else {
          verifyRest(jwsObject, queryStringHash)
        }
      case l @ Left(_) => l.asInstanceOf[Either[Error, Jwt]]
    }
  }

  private def verifyRest(jwsObject: JWSObject,
                         queryStringHash: String): Either[Error, Jwt] =
    JwtParser.parseJWTClaimsSet(jwsObject.getPayload.toJSONObject) match {
      case Right(claims) =>
        verifyStandardClaims(claims) match {
          case Right(_) =>
            verifyQueryStringHash(claims, queryStringHash) match {
              case Right(_) =>
                Right(Jwt(jwsObject, claims))
              case l @ Left(_) => l.asInstanceOf[Either[Error, Jwt]]
            }
          case l @ Left(_) => l.asInstanceOf[Either[Error, Jwt]]
        }
      case l @ Left(_) => l.asInstanceOf[Either[Error, Jwt]]
    }

  private def verifyStandardClaims(
      claims: JWTClaimsSet): Either[Error, JWTClaimsSet] =
    for {
      _ <- validateHasIssueTimeAndExpirationTime(claims)
      now = Instant.now()
      _ <- validateExpirationTimeIsAfterNotBefore(claims)
      _ <- validateNowIsAfterNotBefore(now, claims)
      _ <- validateNowIsBeforeExpirationTime(now, claims)
    } yield claims

  /** Validate that claim set contains issue time and expiration time.
    *
    * @param claims Claim set to validate
    * @return Either given claim set if successful or JwtInvalidClaimError
    */
  private def validateHasIssueTimeAndExpirationTime(
      claims: JWTClaimsSet): Either[Error, JWTClaimsSet] =
    if (Option(claims.getIssueTime).isEmpty || Option(claims.getExpirationTime).isEmpty) {
      Left(JwtInvalidClaimError(
        "'exp' and 'iat' are required claims. Atlassian JWT does not allow JWTs with unlimited lifetimes."))
    } else {
      Right(claims)
    }

  /** Validate that claim set expiration time is after 'not before' (nbf) time.
    *
    * Note that if 'not before' claim is not defined validation is successful.
    *
    * @param claims Claim set to validate
    * @return Either given claim set if successful or JwtInvalidClaimError
    */
  private def validateExpirationTimeIsAfterNotBefore(
      claims: JWTClaimsSet): Either[Error, JWTClaimsSet] =
    if (Option(claims.getNotBeforeTime).isDefined && !claims.getExpirationTime
          .after(claims.getNotBeforeTime)) {
      Left(JwtInvalidClaimError(
        s"The expiration time must be after the not-before time but exp=${claims.getExpirationTime} and nbf=${claims.getNotBeforeTime}"))
    } else {
      Right(claims)
    }

  /** Validate that claim set 'not before' time is before current time.
    *
    * Note that if 'not before' claim is not defined validation is successful.
    *
    * @param now Current time
    * @param claims Claim set to validate
    * @return Either given claim set if successful or JwtTooEarlyError
    */
  private def validateNowIsAfterNotBefore(
      now: Instant,
      claims: JWTClaimsSet): Either[Error, JWTClaimsSet] = {
    val nowPlusLeeway =
      now.plusSeconds(JwtReader.TimeClaimLeewaySeconds)
    if (Option(claims.getNotBeforeTime).isDefined && claims.getNotBeforeTime.toInstant
          .isAfter(nowPlusLeeway)) {
      Left(
        JwtTooEarlyError(claims.getNotBeforeTime.toInstant,
                         now,
                         JwtReader.TimeClaimLeewaySeconds))
    } else {
      Right(claims)
    }
  }

  /** Validate that claim set expiration time is after current time.
    *
    * @param now Current time
    * @param claims Claim set to validate
    * @return Either given claim set if successful or JwtExpiredError
    */
  private def validateNowIsBeforeExpirationTime(
      now: Instant,
      claims: JWTClaimsSet): Either[Error, JWTClaimsSet] = {
    val nowMinusLeeway =
      now.minusSeconds(JwtReader.TimeClaimLeewaySeconds)
    if (claims.getExpirationTime.toInstant.isBefore(nowMinusLeeway)) {
      Left(
        JwtExpiredError(claims.getExpirationTime.toInstant,
                        now,
                        JwtReader.TimeClaimLeewaySeconds))
    } else {
      Right(claims)
    }
  }

  /** Verify query string hash claim if it is present, otherwise assume
    * successful verification.
    *
    * @param claims Claim set to verify.
    * @param queryStringHash Expected query string hash.
    * @return Either claim set if verification succeeded or Error otherwise.
    */
  private def verifyQueryStringHash(
      claims: JWTClaimsSet,
      queryStringHash: String): Either[Error, JWTClaimsSet] = {
    val maybeExtractedQueryStringHash =
      Option(claims.getClaim(HttpRequestCanonicalizer.QueryStringHashClaimName))
    maybeExtractedQueryStringHash match {
      case Some(extractedQueryStringHash) =>
        if (queryStringHash != extractedQueryStringHash) {
          Left(JwtInvalidClaimError(
            s"Expecting claim '${HttpRequestCanonicalizer.QueryStringHashClaimName}' to have value '$queryStringHash' but instead it has the value '$maybeExtractedQueryStringHash'"))
        } else Right(claims)
      case None => Right(claims)
    }
  }

  private def verifySignature(jwsObject: JWSObject): Either[Error, JWSObject] = {
    Try(jwsObject.verify(verifier)) match {
      case Success(isValid) =>
        if (isValid)
          Right(jwsObject)
        else
          Left(JwtSignatureMismatchError(jwsObject.getParsedString))
      case Failure(exception) =>
        Left(JwtSignatureMismatchError(exception.getMessage))
    }
  }

}

object JwtReader {

  /** The JWT spec says that implementers "MAY provide for some small leeway,
    * usually no more than a few minutes, to account for clock skew".
    * Calculations of the current time for the purposes of accepting or
    * rejecting time-based claims (e.g. "exp" and "nbf") will allow for the
    * current time being plus or minus this leeway, resulting in some
    * time-based claims that are marginally before or after the current time
    * being accepted instead of rejected.
    */
  private val TimeClaimLeewaySeconds: Int = 30

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy