diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2956bc..520e951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,10 +26,10 @@ concurrency: jobs: build: - name: Build and Test + name: Test strategy: matrix: - os: [ubuntu-latest] + os: [ubuntu-22.04] scala: [3] java: [temurin@11, temurin@17, temurin@21] runs-on: ${{ matrix.os }} @@ -40,6 +40,9 @@ jobs: with: fetch-depth: 0 + - name: Setup sbt + uses: sbt/setup-sbt@v1 + - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' @@ -86,20 +89,20 @@ jobs: run: sbt '++ ${{ matrix.scala }}' test - name: Check binary compatibility - if: matrix.java == 'temurin@11' && matrix.os == 'ubuntu-latest' + if: matrix.java == 'temurin@11' && matrix.os == 'ubuntu-22.04' run: sbt '++ ${{ matrix.scala }}' mimaReportBinaryIssues - name: Generate API documentation - if: matrix.java == 'temurin@11' && matrix.os == 'ubuntu-latest' + if: matrix.java == 'temurin@11' && matrix.os == 'ubuntu-22.04' run: sbt '++ ${{ matrix.scala }}' doc - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) - run: mkdir -p modules/oath-circe/target modules/oath-jsoniter-scala/target modules/oath-core/target modules/oath-macros/target project/target + run: mkdir -p oath/circe/target oath/jsoniter-scala/target oath/core/target oath/macros/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) - run: tar cf targets.tar modules/oath-circe/target modules/oath-jsoniter-scala/target modules/oath-core/target modules/oath-macros/target project/target + run: tar cf targets.tar oath/circe/target oath/jsoniter-scala/target oath/core/target oath/macros/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) @@ -114,7 +117,7 @@ jobs: if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) strategy: matrix: - os: [ubuntu-latest] + os: [ubuntu-22.04] java: [temurin@11] runs-on: ${{ matrix.os }} steps: @@ -123,6 +126,9 @@ jobs: with: fetch-depth: 0 + - name: Setup sbt + uses: sbt/setup-sbt@v1 + - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' @@ -198,10 +204,10 @@ jobs: dependency-submission: name: Submit Dependencies - if: github.event_name != 'pull_request' + if: github.event.repository.fork == false && github.event_name != 'pull_request' strategy: matrix: - os: [ubuntu-latest] + os: [ubuntu-22.04] java: [temurin@11] runs-on: ${{ matrix.os }} steps: @@ -210,6 +216,9 @@ jobs: with: fetch-depth: 0 + - name: Setup sbt + uses: sbt/setup-sbt@v1 + - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' @@ -252,14 +261,14 @@ jobs: - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 with: - modules-ignore: oath_3 + modules-ignore: oath-root_3 example_3 oath_3 configs-ignore: test scala-tool scala-doc-tool test-internal validate-steward: name: Validate Steward Config strategy: matrix: - os: [ubuntu-latest] + os: [ubuntu-22.04] java: [temurin@11] runs-on: ${{ matrix.os }} steps: @@ -284,8 +293,8 @@ jobs: name: Check code style strategy: matrix: - os: [ubuntu-latest] - scala: [3.3.3] + os: [ubuntu-22.04] + scala: [3.3.5] java: [temurin@11] runs-on: ${{ matrix.os }} steps: @@ -312,8 +321,8 @@ jobs: name: Codecov strategy: matrix: - os: [ubuntu-latest] - scala: [3.3.3] + os: [ubuntu-22.04] + scala: [3.3.5] java: [temurin@11] runs-on: ${{ matrix.os }} steps: diff --git a/.scala-steward.conf b/.scala-steward.conf index 1216a3e..32bad4b 100644 --- a/.scala-steward.conf +++ b/.scala-steward.conf @@ -1,2 +1,2 @@ -commits.message = "chore: update ${artifactName} from ${currentVersion} to ${nextVersion}" +commits.message = "chore(deps): update ${artifactName} from ${currentVersion} to ${nextVersion}" diff --git a/build.sbt b/build.sbt index 3ef3bf1..cb59cc1 100644 --- a/build.sbt +++ b/build.sbt @@ -1,19 +1,19 @@ import org.typelevel.sbt.gha.Permissions +//import org.typelevel.scalacoptions.ScalacOptions Global / onChangedBuildSource := ReloadOnSourceChanges -ThisBuild / scalaVersion := "3.3.3" +ThisBuild / scalaVersion := "3.3.5" ThisBuild / organization := "io.github.scala-jwt" ThisBuild / organizationName := "oath" ThisBuild / organizationHomepage := Some(url("https://github.com/scala-jwt/oath")) -ThisBuild / scalafixDependencies += "com.github.liancheng" %% "organize-imports" % "0.6.0" ThisBuild / tlBaseVersion := "2.0" ThisBuild / tlMimaPreviousVersions := Set.empty ThisBuild / licenses := Seq(License.Apache2) ThisBuild / developers := List( tlGitHubDev("andrewrigas", "Andreas Rigas") ) -ThisBuild / tlSonatypeUseLegacyHost := false +ThisBuild / sonatypeCredentialHost := xerial.sbt.Sonatype.sonatypeLegacy ThisBuild / startYear := Some(2022) ThisBuild / githubWorkflowPermissions := Some(Permissions.WriteAll) ThisBuild / githubWorkflowJavaVersions := Seq("11", "17", "21").map(JavaSpec.temurin) @@ -50,34 +50,96 @@ ThisBuild / githubWorkflowAddedJobs ++= Seq( ), ) -lazy val root = Projects - .createModule("oath", ".") +ThisBuild / Test / fork := true +ThisBuild / run / fork := true +ThisBuild / Test / parallelExecution := false +ThisBuild / Test / testForkedParallel := true +ThisBuild / scalafmtOnCompile := sys.env.getOrElse("RUN_SCALAFMT_ON_COMPILE", "false").toBoolean +ThisBuild / scalafixOnCompile := sys.env.getOrElse("RUN_SCALAFIX_ON_COMPILE", "false").toBoolean +ThisBuild / semanticdbEnabled := true +ThisBuild / semanticdbVersion := "4.8.15" + +def rootModule(rootModule: String)(subModule: Option[String]): Project = + Project( + s"$rootModule${subModule.map("-" + _).getOrElse("")}", + file(s"$rootModule${subModule.map("/" + _).getOrElse("")}"), + ) + +lazy val root = Project("oath-root", file(".")) .enablePlugins(NoPublishPlugin) .settings(Aliases.all) - .aggregate(modules *) + .aggregate(allModules *) -lazy val oathMacros = Projects - .createModule("oath-macros", "modules/oath-macros") - .settings(Dependencies.oathMacros) +lazy val example = project + .in(file("example")) + .enablePlugins(NoPublishPlugin) + .dependsOn(oathCore, oathCirce, oathJsoniterScala) -lazy val oathCore = Projects - .createModule("oath-core", "modules/oath-core") +lazy val createOathModule = rootModule("oath") _ + +lazy val oathRoot = createOathModule(None) + .enablePlugins(NoPublishPlugin) + .aggregate(oathModules *) + +lazy val oathMacros = createOathModule(Some("macros")) + .settings( + libraryDependencies ++= Seq( + Dependencies.scalaTest % Test, + Dependencies.scalaTestPlusScalaCheck % Test, + Dependencies.scalacheck % Test, + ) + ) + +lazy val oathCore = createOathModule(Some("core")) .dependsOn(oathMacros) - .settings(Dependencies.oathCore) + .settings( + libraryDependencies ++= Seq( + Dependencies.javaJWT, + Dependencies.typesafeConfig, + Dependencies.bcprov, + Dependencies.catsCore, + Dependencies.tink, + Dependencies.scalaTest % Test, + Dependencies.scalaTestPlusScalaCheck % Test, + Dependencies.scalacheck % Test, + Dependencies.circeCore % Test, + Dependencies.circeGeneric % Test, + Dependencies.circeParser % Test, + ) + ) -lazy val oathCirce = Projects - .createModule("oath-circe", "modules/oath-circe") - .settings(Dependencies.oathCirce) - .dependsOn(oathCore % "compile->compile;test->test") +lazy val oathCirce = createOathModule(Some("circe")) + .dependsOn( + oathCore, + oathCore % "test->test", + ) + .settings( + libraryDependencies ++= Seq( + Dependencies.circeCore, + Dependencies.circeGeneric, + Dependencies.circeParser, + ) + ) -lazy val oathJsoniterScala = Projects - .createModule("oath-jsoniter-scala", "modules/oath-jsoniter-scala") - .settings(Dependencies.oathJsoniterScala) - .dependsOn(oathCore % "compile->compile;test->test") +lazy val oathJsoniterScala = createOathModule(Some("jsoniter-scala")) + .dependsOn( + oathCore, + oathCore % "test->test", + ) + .settings( + libraryDependencies ++= Seq( + Dependencies.jsoniterScalacore, + Dependencies.jsoniterScalamacros, + ) + ) -lazy val modules: Seq[ProjectReference] = Seq( +lazy val oathModules: Seq[ProjectReference] = Seq( oathMacros, oathCore, oathCirce, oathJsoniterScala, ) + +lazy val exampleModules: Seq[ProjectReference] = Seq(example) + +lazy val allModules: Seq[ProjectReference] = exampleModules ++ oathModules diff --git a/modules/oath-circe/src/main/scala/io/oath/circe/syntax.scala b/modules/oath-circe/src/main/scala/io/oath/circe/syntax.scala deleted file mode 100644 index 8dd54ed..0000000 --- a/modules/oath-circe/src/main/scala/io/oath/circe/syntax.scala +++ /dev/null @@ -1,30 +0,0 @@ -package io.oath.circe - -import io.circe._ -import io.circe.syntax.EncoderOps -import io.oath.JwtVerifyError -import io.oath.json.{ClaimsCodec, ClaimsDecoder, ClaimsEncoder} - -object syntax: - extension [P](encoder: Encoder[P]) def convert: ClaimsEncoder[P] = data => data.asJson(encoder).noSpaces - - extension [P](decoder: Decoder[P]) - def convert: ClaimsDecoder[P] = - json => - parser - .parse(json) - .left - .map(parsingFailure => JwtVerifyError.DecodingError(parsingFailure.message, parsingFailure.underlying)) - .flatMap( - _.as[P](decoder).left.map(decodingFailure => - JwtVerifyError.DecodingError(decodingFailure.getMessage(), decodingFailure.getCause) - ) - ) - - extension [P](codec: Codec[P]) - def convertCodec: ClaimsCodec[P] = new ClaimsCodec[P]: - override def decode(token: String): Either[JwtVerifyError.DecodingError, P] = - codec.asInstanceOf[Decoder[P]].convert.decode(token) - - override def encode(data: P): String = - codec.asInstanceOf[Encoder[P]].convert.encode(data) diff --git a/modules/oath-circe/src/test/scala/io/oath/circe/Bar.scala b/modules/oath-circe/src/test/scala/io/oath/circe/Bar.scala deleted file mode 100644 index a8c79c7..0000000 --- a/modules/oath-circe/src/test/scala/io/oath/circe/Bar.scala +++ /dev/null @@ -1,12 +0,0 @@ -package io.oath.circe - -import io.circe.generic.semiauto.* -import io.circe.{Decoder, Encoder} -import io.oath.circe.syntax.* -import io.oath.json.* - -case class Bar(name: String, age: Int) - -object Bar: - given barEncoder: ClaimsEncoder[Bar] = deriveEncoder[Bar].convert - given barDecoder: ClaimsDecoder[Bar] = deriveDecoder[Bar].convert diff --git a/modules/oath-circe/src/test/scala/io/oath/circe/Foo.scala b/modules/oath-circe/src/test/scala/io/oath/circe/Foo.scala deleted file mode 100644 index d63d439..0000000 --- a/modules/oath-circe/src/test/scala/io/oath/circe/Foo.scala +++ /dev/null @@ -1,11 +0,0 @@ -package io.oath.circe - -import io.circe.Codec -import io.circe.generic.semiauto.* -import io.oath.circe.syntax.* -import io.oath.json.ClaimsCodec - -final case class Foo(name: String, age: Int) - -object Foo: - given barCodec: ClaimsCodec[Foo] = deriveCodec[Foo].convertCodec diff --git a/modules/oath-core/src/main/scala/io/oath/Jwt.scala b/modules/oath-core/src/main/scala/io/oath/Jwt.scala deleted file mode 100644 index 13065f0..0000000 --- a/modules/oath-core/src/main/scala/io/oath/Jwt.scala +++ /dev/null @@ -1,3 +0,0 @@ -package io.oath - -case class Jwt[T <: JwtClaims](claims: T, token: String) diff --git a/modules/oath-core/src/main/scala/io/oath/JwtClaims.scala b/modules/oath-core/src/main/scala/io/oath/JwtClaims.scala deleted file mode 100644 index 343affb..0000000 --- a/modules/oath-core/src/main/scala/io/oath/JwtClaims.scala +++ /dev/null @@ -1,17 +0,0 @@ -package io.oath - -sealed trait JwtClaims { - val registered: RegisteredClaims -} - -object JwtClaims { - - final case class Claims(registered: RegisteredClaims = RegisteredClaims.empty) extends JwtClaims - - final case class ClaimsH[+H](header: H, registered: RegisteredClaims = RegisteredClaims.empty) extends JwtClaims - - final case class ClaimsP[+P](payload: P, registered: RegisteredClaims = RegisteredClaims.empty) extends JwtClaims - - final case class ClaimsHP[+H, +P](header: H, payload: P, registered: RegisteredClaims = RegisteredClaims.empty) - extends JwtClaims -} diff --git a/modules/oath-core/src/main/scala/io/oath/JwtIssueError.scala b/modules/oath-core/src/main/scala/io/oath/JwtIssueError.scala deleted file mode 100644 index 01ab908..0000000 --- a/modules/oath-core/src/main/scala/io/oath/JwtIssueError.scala +++ /dev/null @@ -1,10 +0,0 @@ -package io.oath - -sealed abstract class JwtIssueError(val error: String) extends Exception(error) - -object JwtIssueError: - case class IllegalArgument(override val error: String) extends JwtIssueError(error) - case class JwtCreationIssueError(override val error: String) extends JwtIssueError(error) - case class EncryptionError(override val error: String) extends JwtIssueError(error) - case class EncodeError(override val error: String) extends JwtIssueError(error) - case class UnexpectedIssueError(override val error: String) extends JwtIssueError(error) diff --git a/modules/oath-core/src/main/scala/io/oath/JwtIssuer.scala b/modules/oath-core/src/main/scala/io/oath/JwtIssuer.scala deleted file mode 100644 index fed7236..0000000 --- a/modules/oath-core/src/main/scala/io/oath/JwtIssuer.scala +++ /dev/null @@ -1,136 +0,0 @@ -package io.oath - -import com.auth0.jwt.algorithms.Algorithm -import com.auth0.jwt.exceptions.JWTCreationException -import com.auth0.jwt.{JWT, JWTCreator} -import io.oath.* -import io.oath.config.* -import io.oath.json.ClaimsEncoder -import io.oath.utils.* - -import java.time.temporal.ChronoUnit -import java.time.{Clock, Instant} -import java.util.UUID -import scala.util.chaining.* -import scala.util.control.Exception.allCatch - -final class JwtIssuer(config: JwtIssuerConfig, clock: Clock = Clock.systemUTC()): - - private def buildJwt(builder: JWTCreator.Builder, registeredClaims: RegisteredClaims): JWTCreator.Builder = - builder - .tap(builder => registeredClaims.iss.map(str => builder.withIssuer(str))) - .tap(builder => registeredClaims.sub.map(str => builder.withSubject(str))) - .tap(builder => builder.withAudience(registeredClaims.aud: _*)) - .tap(builder => registeredClaims.jti.map(str => builder.withJWTId(str))) - .tap(builder => registeredClaims.iat.map(builder.withIssuedAt)) - .tap(builder => registeredClaims.exp.map(builder.withExpiresAt)) - .tap(builder => registeredClaims.nbf.map(builder.withNotBefore)) - - private def setRegisteredClaims(adHocRegisteredClaims: RegisteredClaims): RegisteredClaims = - val now = Instant.now(clock).truncatedTo(ChronoUnit.SECONDS) - RegisteredClaims( - iss = adHocRegisteredClaims.iss orElse config.registered.issuerClaim, - sub = adHocRegisteredClaims.sub orElse config.registered.subjectClaim, - aud = if (adHocRegisteredClaims.aud.isEmpty) config.registered.audienceClaims else adHocRegisteredClaims.aud, - exp = adHocRegisteredClaims.exp orElse config.registered.expiresAtOffset.map(duration => - now.plusSeconds(duration.toSeconds) - ), - nbf = adHocRegisteredClaims.nbf orElse config.registered.notBeforeOffset.map(duration => - now.plusSeconds(duration.toSeconds) - ), - iat = adHocRegisteredClaims.iat orElse Option.when(config.registered.includeIssueAtClaim)(now), - jti = adHocRegisteredClaims.jti orElse Option - .when(config.registered.includeJwtIdClaim)( - config.registered.issuerClaim - .map(_ + "-") - .getOrElse("") - .pipe(prefix => prefix + UUID.randomUUID().toString) - ), - ) - - private def maybeEncryptJwt[T <: JwtClaims]( - jwt: Jwt[T] - ): Either[JwtIssueError.EncryptionError, Jwt[T]] = - config.encrypt - .map(encryptConfig => - EncryptionUtils - .encryptAES(jwt.token, encryptConfig.secret) - .map(encryptedToken => jwt.copy(token = encryptedToken)) - ) - .getOrElse(Right(jwt)) - - private def safeSign(builder: JWTCreator.Builder, algorithm: Algorithm): Either[JwtIssueError, String] = - allCatch - .withTry(builder.sign(algorithm)) - .toEither - .left - .map: - case e: IllegalArgumentException => JwtIssueError.IllegalArgument(e.getMessage) - case e: JWTCreationException => JwtIssueError.JwtCreationIssueError(e.getMessage) - case e => JwtIssueError.UnexpectedIssueError(e.getMessage) - - def issueJwt( - claims: JwtClaims.Claims = JwtClaims.Claims() - ): Either[JwtIssueError, Jwt[JwtClaims.Claims]] = - val jwtBuilder = JWT.create() - setRegisteredClaims(claims.registered) - .pipe(registeredClaims => buildJwt(jwtBuilder, registeredClaims) -> registeredClaims) - .pipe { case (jwtBuilder, registeredClaims) => - safeSign(jwtBuilder, config.algorithm) - .map(token => - Jwt( - JwtClaims.Claims(registeredClaims), - token, - ) - ) - } - .flatMap(jwt => maybeEncryptJwt(jwt)) - - def issueJwt[H](claims: JwtClaims.ClaimsH[H])(using - ClaimsEncoder[H] - ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsH[H]]] = - val jwtBuilder = JWT.create() - for - headerBuilder <- jwtBuilder.safeEncodeHeader(claims.header) - registeredClaims = setRegisteredClaims(claims.registered) - builder = buildJwt(headerBuilder, registeredClaims) - token <- safeSign(builder, config.algorithm) - jwt = Jwt( - claims.copy(registered = registeredClaims), - token, - ) - encryptedJwt <- maybeEncryptJwt(jwt) - yield encryptedJwt - - def issueJwt[P](claims: JwtClaims.ClaimsP[P])(using - ClaimsEncoder[P] - ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsP[P]]] = - val jwtBuilder = JWT.create() - for - payloadBuilder <- jwtBuilder.safeEncodePayload(claims.payload) - registeredClaims = setRegisteredClaims(claims.registered) - builder = buildJwt(payloadBuilder, registeredClaims) - token <- safeSign(builder, config.algorithm) - jwt = Jwt( - claims.copy(registered = registeredClaims), - token, - ) - encryptedJwt <- maybeEncryptJwt(jwt) - yield encryptedJwt - - def issueJwt[H, P]( - claims: JwtClaims.ClaimsHP[H, P] - )(using ClaimsEncoder[H], ClaimsEncoder[P]): Either[JwtIssueError, Jwt[JwtClaims.ClaimsHP[H, P]]] = - val jwtBuilder = JWT.create() - for - payloadBuilder <- jwtBuilder.safeEncodePayload(claims.payload) - headerAndPayloadBuilder <- payloadBuilder.safeEncodeHeader(claims.header) - registeredClaims = setRegisteredClaims(claims.registered) - builder = buildJwt(headerAndPayloadBuilder, registeredClaims) - token <- safeSign(builder, config.algorithm) - jwt = Jwt( - claims.copy(registered = registeredClaims), - token, - ) - encryptedJwt <- maybeEncryptJwt(jwt) - yield encryptedJwt diff --git a/modules/oath-core/src/main/scala/io/oath/JwtManager.scala b/modules/oath-core/src/main/scala/io/oath/JwtManager.scala deleted file mode 100644 index 8b26dae..0000000 --- a/modules/oath-core/src/main/scala/io/oath/JwtManager.scala +++ /dev/null @@ -1,40 +0,0 @@ -package io.oath - -import io.oath.config.* -import io.oath.json.{ClaimsDecoder, ClaimsEncoder} - -final class JwtManager(config: JwtManagerConfig): - - private val issuer: JwtIssuer = new JwtIssuer(config.issuer) - private val verifier: JwtVerifier = new JwtVerifier(config.verifier) - - def issueJwt( - claims: JwtClaims.Claims = JwtClaims.Claims() - ): Either[JwtIssueError, Jwt[JwtClaims.Claims]] = issuer.issueJwt(claims) - - def issueJwt[H](claims: JwtClaims.ClaimsH[H])(using - ClaimsEncoder[H] - ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsH[H]]] = issuer.issueJwt(claims) - - def issueJwt[P](claims: JwtClaims.ClaimsP[P])(using - ClaimsEncoder[P] - ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsP[P]]] = - issuer.issueJwt(claims) - - def issueJwt[H, P]( - claims: JwtClaims.ClaimsHP[H, P] - )(using ClaimsEncoder[H], ClaimsEncoder[P]): Either[JwtIssueError, Jwt[JwtClaims.ClaimsHP[H, P]]] = - issuer.issueJwt(claims) - - def verifyJwt(jwt: JwtToken.Token): Either[JwtVerifyError, JwtClaims.Claims] = verifier.verifyJwt(jwt) - - def verifyJwt[H](jwt: JwtToken.TokenH)(using ClaimsDecoder[H]): Either[JwtVerifyError, JwtClaims.ClaimsH[H]] = - verifier.verifyJwt(jwt) - - def verifyJwt[P](jwt: JwtToken.TokenP)(using ClaimsDecoder[P]): Either[JwtVerifyError, JwtClaims.ClaimsP[P]] = - verifier.verifyJwt(jwt) - - def verifyJwt[H, P]( - jwt: JwtToken.TokenHP - )(using ClaimsDecoder[H], ClaimsDecoder[P]): Either[JwtVerifyError, JwtClaims.ClaimsHP[H, P]] = - verifier.verifyJwt[H, P](jwt) diff --git a/modules/oath-core/src/main/scala/io/oath/JwtToken.scala b/modules/oath-core/src/main/scala/io/oath/JwtToken.scala deleted file mode 100644 index 1b989a4..0000000 --- a/modules/oath-core/src/main/scala/io/oath/JwtToken.scala +++ /dev/null @@ -1,10 +0,0 @@ -package io.oath - -sealed trait JwtToken: - def token: String - -object JwtToken: - case class Token(token: String) extends JwtToken - case class TokenH(token: String) extends JwtToken - case class TokenP(token: String) extends JwtToken - case class TokenHP(token: String) extends JwtToken diff --git a/modules/oath-core/src/main/scala/io/oath/JwtVerifier.scala b/modules/oath-core/src/main/scala/io/oath/JwtVerifier.scala deleted file mode 100644 index d48620d..0000000 --- a/modules/oath-core/src/main/scala/io/oath/JwtVerifier.scala +++ /dev/null @@ -1,131 +0,0 @@ -package io.oath - -import com.auth0.jwt.JWT -import com.auth0.jwt.exceptions.* -import com.auth0.jwt.interfaces.DecodedJWT -import io.oath.* -import io.oath.config.JwtVerifierConfig -import io.oath.json.* -import io.oath.utils.* - -import scala.util.chaining.scalaUtilChainingOps -import scala.util.control.Exception.allCatch - -final class JwtVerifier(config: JwtVerifierConfig): - - private lazy val jwtVerifier = - JWT - .require(config.algorithm) - .tap(jwtVerification => config.providedWith.issuerClaim.map(str => jwtVerification.withIssuer(str))) - .tap(jwtVerification => config.providedWith.subjectClaim.map(str => jwtVerification.withSubject(str))) - .tap(jwtVerification => - if (config.providedWith.audienceClaims.nonEmpty) - jwtVerification.withAudience(config.providedWith.audienceClaims: _*) - () - ) - .tap(jwtVerification => - config.leewayWindow.leeway.map(duration => jwtVerification.acceptLeeway(duration.toSeconds)) - ) - .tap(jwtVerification => - config.leewayWindow.issuedAt.map(duration => jwtVerification.acceptIssuedAt(duration.toSeconds)) - ) - .tap(jwtVerification => - config.leewayWindow.expiresAt.map(duration => jwtVerification.acceptExpiresAt(duration.toSeconds)) - ) - .tap(jwtVerification => - config.leewayWindow.notBefore.map(duration => jwtVerification.acceptNotBefore(duration.toSeconds)) - ) - .build() - - private def getRegisteredClaims(decodedJWT: DecodedJWT): RegisteredClaims = - RegisteredClaims( - iss = decodedJWT.getOptionIssuer, - sub = decodedJWT.getOptionSubject, - aud = decodedJWT.getSeqAudience, - exp = decodedJWT.getOptionExpiresAt, - nbf = decodedJWT.getOptionNotBefore, - iat = decodedJWT.getOptionIssueAt, - jti = decodedJWT.getOptionJwtID, - ) - - private def maybeDecryptJwt(token: String): Either[JwtVerifyError.DecryptionError, String] = - config.encrypt - .map(encryptionConfig => DecryptionUtils.decryptAES(token, encryptionConfig.secret)) - .getOrElse(Right(token)) - - private def validateToken(token: String): Either[JwtVerifyError.VerificationError, String] = - Option(token).filter(_.nonEmpty).toRight(JwtVerifyError.VerificationError("JWT Token is empty.")) - - private def safeDecode[T]( - decodedObject: => Either[JwtVerifyError.DecodingError, T] - ): Either[JwtVerifyError.DecodingError, T] = - allCatch - .withTry(decodedObject) - .fold(error => Left(JwtVerifyError.DecodingError(error.getMessage, error)), identity) - - private def handler(decodedJWT: => DecodedJWT): Either[JwtVerifyError, DecodedJWT] = - allCatch - .withTry(decodedJWT) - .toEither - .left - .map { - case e: IllegalArgumentException => JwtVerifyError.IllegalArgument(e.getMessage) - case e: AlgorithmMismatchException => JwtVerifyError.AlgorithmMismatch(e.getMessage) - case e: SignatureVerificationException => JwtVerifyError.SignatureVerificationError(e.getMessage) - case e: TokenExpiredException => JwtVerifyError.TokenExpired(e.getMessage) - case e: JWTVerificationException => JwtVerifyError.VerificationError(e.getMessage) - case e => JwtVerifyError.UnexpectedError(e.getMessage) - } - - private def verify(token: String): Either[JwtVerifyError, DecodedJWT] = - handler( - jwtVerifier - .verify(token) - ) - - def verifyJwt(jwt: JwtToken.Token): Either[JwtVerifyError, JwtClaims.Claims] = - for - token <- validateToken(jwt.token) - decryptedToken <- maybeDecryptJwt(token) - decodedJwt <- verify(decryptedToken) - registeredClaims = getRegisteredClaims(decodedJwt) - yield JwtClaims.Claims(registeredClaims) - - def verifyJwt[H](jwt: JwtToken.TokenH)(using - claimsDecoder: ClaimsDecoder[H] - ): Either[JwtVerifyError, JwtClaims.ClaimsH[H]] = - for - token <- validateToken(jwt.token) - decryptedToken <- maybeDecryptJwt(token) - decodedJwt <- verify(decryptedToken) - json <- base64DecodeToken(decodedJwt.getHeader) - payload <- safeDecode(claimsDecoder.decode(json)) - registeredClaims = getRegisteredClaims(decodedJwt) - yield JwtClaims.ClaimsH(payload, registeredClaims) - - def verifyJwt[P](jwt: JwtToken.TokenP)(using - claimsDecoder: ClaimsDecoder[P] - ): Either[JwtVerifyError, JwtClaims.ClaimsP[P]] = - for - token <- validateToken(jwt.token) - decryptedToken <- maybeDecryptJwt(token) - decodedJwt <- verify(decryptedToken) - json <- base64DecodeToken(decodedJwt.getPayload) - payload <- safeDecode(claimsDecoder.decode(json)) - registeredClaims = getRegisteredClaims(decodedJwt) - yield JwtClaims.ClaimsP(payload, registeredClaims) - - def verifyJwt[H, P](jwt: JwtToken.TokenHP)(using - headerDecoder: ClaimsDecoder[H], - payloadDecoder: ClaimsDecoder[P], - ): Either[JwtVerifyError, JwtClaims.ClaimsHP[H, P]] = - for - token <- validateToken(jwt.token) - decryptedToken <- maybeDecryptJwt(token) - decodedJwt <- verify(decryptedToken) - jsonHeader <- base64DecodeToken(decodedJwt.getHeader) - jsonPayload <- base64DecodeToken(decodedJwt.getPayload) - headerClaims <- safeDecode(headerDecoder.decode(jsonHeader)) - payloadClaims <- safeDecode(payloadDecoder.decode(jsonPayload)) - registeredClaims = getRegisteredClaims(decodedJwt) - yield JwtClaims.ClaimsHP(headerClaims, payloadClaims, registeredClaims) diff --git a/modules/oath-core/src/main/scala/io/oath/JwtVerifyError.scala b/modules/oath-core/src/main/scala/io/oath/JwtVerifyError.scala deleted file mode 100644 index 4ee41e7..0000000 --- a/modules/oath-core/src/main/scala/io/oath/JwtVerifyError.scala +++ /dev/null @@ -1,13 +0,0 @@ -package io.oath - -sealed abstract class JwtVerifyError(val error: String) extends Exception(error) - -object JwtVerifyError: - case class IllegalArgument(override val error: String) extends JwtVerifyError(error) - case class AlgorithmMismatch(override val error: String) extends JwtVerifyError(error) - case class DecodingError(override val error: String, underlying: Throwable) extends JwtVerifyError(error) - case class VerificationError(override val error: String) extends JwtVerifyError(error) - case class SignatureVerificationError(override val error: String) extends JwtVerifyError(error) - case class DecryptionError(override val error: String) extends JwtVerifyError(error) - case class TokenExpired(override val error: String) extends JwtVerifyError(error) - case class UnexpectedError(override val error: String) extends JwtVerifyError(error) diff --git a/modules/oath-core/src/main/scala/io/oath/OathIssuer.scala b/modules/oath-core/src/main/scala/io/oath/OathIssuer.scala deleted file mode 100644 index 0ac29ee..0000000 --- a/modules/oath-core/src/main/scala/io/oath/OathIssuer.scala +++ /dev/null @@ -1,21 +0,0 @@ -package io.oath - -import io.oath.config.JwtIssuerConfig - -import scala.util.chaining.scalaUtilChainingOps - -final class OathIssuer[A](mapping: Map[A, JwtIssuer]): - def as[S <: A](tokenType: S): JIssuer[S] = mapping(tokenType) - -object OathIssuer: - inline def none[A]: OathIssuer[A] = - getEnumValues[A].map { case (tokenType, _) => - tokenType -> JwtIssuer(JwtIssuerConfig.none()) - }.toMap - .pipe(mapping => OathIssuer(mapping)) - - inline def createOrFail[A]: OathIssuer[A] = - getEnumValues[A].map { case (tokenType, tokenConfig) => - tokenType -> JwtIssuerConfig.loadOrThrowOath(tokenConfig).pipe(JwtIssuer(_)) - }.toMap - .pipe(mapping => OathIssuer(mapping)) diff --git a/modules/oath-core/src/main/scala/io/oath/OathManager.scala b/modules/oath-core/src/main/scala/io/oath/OathManager.scala deleted file mode 100644 index 72a95ce..0000000 --- a/modules/oath-core/src/main/scala/io/oath/OathManager.scala +++ /dev/null @@ -1,21 +0,0 @@ -package io.oath - -import io.oath.config.* - -import scala.util.chaining.scalaUtilChainingOps - -final class OathManager[A](mapping: Map[A, JwtManager]): - def as[S <: A](tokenType: S): JManager[S] = mapping(tokenType) - -object OathManager: - inline def none[A]: OathManager[A] = - getEnumValues[A].map { case (tokenType, _) => - tokenType -> JwtManager(JwtManagerConfig.none()) - }.toMap - .pipe(mapping => OathManager(mapping)) - - inline def createOrFail[A]: OathManager[A] = - getEnumValues[A].map { case (tokenType, tokenConfig) => - tokenType -> JwtManagerConfig.loadOrThrowOath(tokenConfig).pipe(JwtManager(_)) - }.toMap - .pipe(mapping => OathManager(mapping)) diff --git a/modules/oath-core/src/main/scala/io/oath/OathVerifier.scala b/modules/oath-core/src/main/scala/io/oath/OathVerifier.scala deleted file mode 100644 index 7e198bd..0000000 --- a/modules/oath-core/src/main/scala/io/oath/OathVerifier.scala +++ /dev/null @@ -1,21 +0,0 @@ -package io.oath - -import io.oath.config.* - -import scala.util.chaining.scalaUtilChainingOps - -final class OathVerifier[A](mapping: Map[A, JwtVerifier]): - def as[S <: A](tokenType: S): JVerifier[S] = mapping(tokenType) - -object OathVerifier: - inline def none[A]: OathVerifier[A] = - getEnumValues[A].map { case (tokenType, _) => - tokenType -> JwtVerifier(JwtVerifierConfig.none()) - }.toMap - .pipe(mapping => OathVerifier(mapping)) - - inline def createOrFail[A]: OathVerifier[A] = - getEnumValues[A].map { case (tokenType, tokenConfig) => - tokenType -> JwtVerifierConfig.loadOrThrowOath(tokenConfig).pipe(JwtVerifier(_)) - }.toMap - .pipe(mapping => OathVerifier(mapping)) diff --git a/modules/oath-core/src/main/scala/io/oath/config/EncryptConfig.scala b/modules/oath-core/src/main/scala/io/oath/config/EncryptConfig.scala deleted file mode 100644 index f672ce7..0000000 --- a/modules/oath-core/src/main/scala/io/oath/config/EncryptConfig.scala +++ /dev/null @@ -1,16 +0,0 @@ -package io.oath.config - -import com.typesafe.config.Config - -import scala.util.chaining.* - -private[oath] final case class EncryptConfig(secret: String) - -object EncryptConfig: - private def loadOrThrowEncryptConfig(encryptScoped: Config): EncryptConfig = - encryptScoped - .getMaybeNonEmptyString("secret") - .getOrElse(throw new IllegalArgumentException("Empty string for secret is not allowed for encryption!")) - .pipe(EncryptConfig.apply) - - private[config] def loadOrThrow(encryptScoped: Config): EncryptConfig = loadOrThrowEncryptConfig(encryptScoped) diff --git a/modules/oath-core/src/main/scala/io/oath/config/config.scala b/modules/oath-core/src/main/scala/io/oath/config/config.scala deleted file mode 100644 index 427fa06..0000000 --- a/modules/oath-core/src/main/scala/io/oath/config/config.scala +++ /dev/null @@ -1,57 +0,0 @@ -package io.oath - -import com.typesafe.config.{Config, ConfigException, ConfigFactory} - -import scala.concurrent.duration.FiniteDuration -import scala.jdk.CollectionConverters.* -import scala.jdk.DurationConverters.* -import scala.util.chaining.* -import scala.util.control.Exception.allCatch - -package config: - inline private[config] val OathLocation = "oath" - private[config] val rootConfig = ConfigFactory.load().getConfig(OathLocation) - - extension (config: Config) - private def ifMissingDefault[T](default: T): PartialFunction[Throwable, T] = { case _: ConfigException.Missing => - default - } - - private[config] def getMaybeNonEmptyString(path: String): Option[String] = - allCatch - .withTry(Some(config.getString(path))) - .recover(ifMissingDefault(Option.empty)) - .toOption - .flatten - .tap(value => - if (value.exists(_.isEmpty)) throw new IllegalArgumentException(s"$path empty string not allowed.") - ) - - private[config] def getMaybeFiniteDuration(path: String): Option[FiniteDuration] = - allCatch - .withTry(Some(config.getDuration(path).toScala)) - .recover(ifMissingDefault(None)) - .get - - private[config] def getBooleanDefaultFalse(path: String): Boolean = - allCatch - .withTry(config.getBoolean(path)) - .recover(ifMissingDefault(false)) - .get - - private[config] def getSeqNonEmptyString(path: String): Seq[String] = - allCatch - .withTry(config.getStringList(path).asScala.toSeq) - .recover(ifMissingDefault(Seq.empty)) - .get - .tap(value => - if value.exists(_.isEmpty) then - throw new IllegalArgumentException(s"$path empty string in the list not allowed.") - ) - - private[config] def getMaybeConfig(path: String): Option[Config] = - allCatch - .withTry(Some(config.getConfig(path))) - .recover(ifMissingDefault(Option.empty)) - .toOption - .flatten diff --git a/modules/oath-core/src/main/scala/io/oath/package.scala b/modules/oath-core/src/main/scala/io/oath/package.scala deleted file mode 100644 index 99d3adf..0000000 --- a/modules/oath-core/src/main/scala/io/oath/package.scala +++ /dev/null @@ -1,14 +0,0 @@ -package io -import io.oath.utils.* - -// Type aliases with extra information, useful to determine the token type. -package oath: - type JIssuer[_] = JwtIssuer - type JManager[_] = JwtManager - type JVerifier[_] = JwtVerifier - - inline private def getEnumValues[A]: Set[(A, String)] = - OathEnumMacro - .enumValues[A] - .toSet - .map(value => value -> convertUpperCamelToLowerHyphen(value.toString)) diff --git a/modules/oath-core/src/main/scala/io/oath/syntax/package.scala b/modules/oath-core/src/main/scala/io/oath/syntax/package.scala deleted file mode 100644 index 8aed960..0000000 --- a/modules/oath-core/src/main/scala/io/oath/syntax/package.scala +++ /dev/null @@ -1,16 +0,0 @@ -package io.oath - -package syntax: - extension (value: String) - def toToken: JwtToken.Token = JwtToken.Token(value) - def toTokenH: JwtToken.TokenH = JwtToken.TokenH(value) - def toTokenP: JwtToken.TokenP = JwtToken.TokenP(value) - def toTokenHP: JwtToken.TokenHP = JwtToken.TokenHP(value) - - extension (value: RegisteredClaims) def toClaims: JwtClaims.Claims = JwtClaims.Claims(value) - - extension [A](value: A) - def toClaimsP: JwtClaims.ClaimsP[A] = JwtClaims.ClaimsP(value) - def toClaimsH: JwtClaims.ClaimsH[A] = JwtClaims.ClaimsH(value) - - extension [A, B](value: (A, B)) def toClaimsHP: JwtClaims.ClaimsHP[A, B] = JwtClaims.ClaimsHP(value._1, value._2) diff --git a/modules/oath-core/src/main/scala/io/oath/utils/DecryptionUtils.scala b/modules/oath-core/src/main/scala/io/oath/utils/DecryptionUtils.scala deleted file mode 100644 index d6fa0fd..0000000 --- a/modules/oath-core/src/main/scala/io/oath/utils/DecryptionUtils.scala +++ /dev/null @@ -1,30 +0,0 @@ -package io.oath.utils - -import io.oath.JwtVerifyError -import io.oath.JwtVerifyError.DecryptionError - -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec -import scala.util.control.Exception.allCatch - -private[oath] object DecryptionUtils: - - private def hexStringToByte(hexString: String): Array[Byte] = - // https://stackoverflow.com/questions/140131/convert-a-string-representation-of-a-hex-dump-to-a-byte-array-using-java - val len = hexString.length - val data = new Array[Byte](len / 2) - var i = 0 - while i < len do - data(i / 2) = ((Character.digit(hexString.charAt(i), 16) << 4) + - Character.digit(hexString.charAt(i + 1), 16)).toByte - i += 2 - data - - def decryptAES(message: String, secret: String): Either[DecryptionError, String] = - allCatch.withTry { - val raw = java.util.Arrays.copyOf(secret.getBytes(UTF8), 16) - val secretKeySpec = new SecretKeySpec(raw, AES) - val cipher = Cipher.getInstance(AES) - cipher.init(Cipher.DECRYPT_MODE, secretKeySpec) - new String(cipher.doFinal(hexStringToByte(message))) - }.toEither.left.map(e => JwtVerifyError.DecryptionError(e.getMessage)) diff --git a/modules/oath-core/src/main/scala/io/oath/utils/EncryptionUtils.scala b/modules/oath-core/src/main/scala/io/oath/utils/EncryptionUtils.scala deleted file mode 100644 index 40e434b..0000000 --- a/modules/oath-core/src/main/scala/io/oath/utils/EncryptionUtils.scala +++ /dev/null @@ -1,31 +0,0 @@ -package io.oath.utils - -import io.oath.JwtIssueError - -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec -import scala.util.control.Exception.allCatch - -private[oath] object EncryptionUtils: - private lazy val HexArray = "0123456789ABCDEF".toCharArray - - private def toHexString(bytes: Array[Byte]): String = - // from https://stackoverflow.com/questions/9655181/how-to-convert-a-byte-array-to-a-hex-string-in-java - val hexChars = new Array[Char](bytes.length * 2) - var j = 0 - while (j < bytes.length) { - val v = bytes(j) & 0xff - hexChars(j * 2) = HexArray(v >>> 4) - hexChars(j * 2 + 1) = HexArray(v & 0x0f) - j += 1 - } - new String(hexChars) - - def encryptAES(message: String, secret: String): Either[JwtIssueError.EncryptionError, String] = - allCatch.withTry { - val raw = java.util.Arrays.copyOf(secret.getBytes(UTF8), 16) - val secretKeySpec = new SecretKeySpec(raw, AES) - val cipher = Cipher.getInstance(AES) - cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec) - toHexString(cipher.doFinal(message.getBytes(UTF8))) - }.toEither.left.map(e => JwtIssueError.EncryptionError(e.getMessage)) diff --git a/modules/oath-core/src/main/scala/io/oath/utils/package.scala b/modules/oath-core/src/main/scala/io/oath/utils/package.scala deleted file mode 100644 index cd6c162..0000000 --- a/modules/oath-core/src/main/scala/io/oath/utils/package.scala +++ /dev/null @@ -1,74 +0,0 @@ -package io.oath - -import com.auth0.jwt.JWTCreator.Builder -import com.auth0.jwt.interfaces.DecodedJWT -import io.oath.json.ClaimsEncoder - -import java.nio.charset.StandardCharsets -import java.time.Instant -import java.util.Base64 -import scala.jdk.CollectionConverters.CollectionHasAsScala -import scala.util.chaining.scalaUtilChainingOps -import scala.util.control.Exception.allCatch - -package utils: - inline private[utils] val AES: "AES" = "AES" - inline private[utils] val UTF8: "utf-8" = "utf-8" - - private[oath] def convertUpperCamelToLowerHyphen(str: String): String = - str.split("(?=\\p{Lu})").map(_.trim.toLowerCase).filter(_.nonEmpty).mkString("-").trim - - private[oath] def base64DecodeToken(token: String): Either[JwtVerifyError.DecodingError, String] = - allCatch - .withTry(new String(Base64.getUrlDecoder.decode(token), StandardCharsets.UTF_8)) - .toEither - .left - .map(JwtVerifyError.DecodingError("Base64 decode failure.", _)) - - extension (decodedJWT: DecodedJWT) - private[oath] def getOptionIssuer: Option[String] = - Option(decodedJWT.getIssuer) - - private[oath] def getOptionSubject: Option[String] = - Option(decodedJWT.getSubject) - - private[oath] def getOptionJwtID: Option[String] = - Option(decodedJWT.getId) - - private[oath] def getSeqAudience: Seq[String] = - Option(decodedJWT.getAudience).map(_.asScala).toSeq.flatten - - private[oath] def getOptionExpiresAt: Option[Instant] = - Option(decodedJWT.getExpiresAt).map(_.toInstant) - - private[oath] def getOptionIssueAt: Option[Instant] = - Option(decodedJWT.getIssuedAt).map(_.toInstant) - - private[oath] def getOptionNotBefore: Option[Instant] = - Option(decodedJWT.getNotBefore).map(_.toInstant) - - extension (builder: Builder) - - private def safeEncode[T]( - claims: T, - toBuilder: String => Builder, - )(using claimsEncoder: ClaimsEncoder[T]): Either[JwtIssueError.EncodeError, Builder] = - allCatch - .withTry( - claimsEncoder - .encode(claims) - .pipe(toBuilder) - ) - .toEither - .left - .map(error => JwtIssueError.EncodeError(error.getMessage)) - - private[oath] def safeEncodeHeader[H](claims: H)(using - ClaimsEncoder[H] - ): Either[JwtIssueError.EncodeError, Builder] = - safeEncode(claims, builder.withHeader) - - private[oath] def safeEncodePayload[P](claims: P)(using - ClaimsEncoder[P] - ): Either[JwtIssueError.EncodeError, Builder] = - safeEncode(claims, builder.withPayload) diff --git a/modules/oath-core/src/test/scala/io/oath/test/JwtVerifierSpec.scala b/modules/oath-core/src/test/scala/io/oath/test/JwtVerifierSpec.scala deleted file mode 100644 index 5973972..0000000 --- a/modules/oath-core/src/test/scala/io/oath/test/JwtVerifierSpec.scala +++ /dev/null @@ -1,349 +0,0 @@ -package io.oath.test - -import com.auth0.jwt.algorithms.Algorithm -import com.auth0.jwt.{JWT, JWTCreator} -import io.oath.* -import io.oath.config.JwtVerifierConfig.* -import io.oath.config.{EncryptConfig, JwtVerifierConfig} -import io.oath.syntax.* -import io.oath.test.NestedHeader.{SimpleHeader, nestedHeaderEncoder} -import io.oath.test.NestedPayload.{SimplePayload, nestedPayloadEncoder} -import io.oath.testkit.{AnyWordSpecBase, PropertyBasedTesting} -import io.oath.utils.* - -import scala.util.chaining.scalaUtilChainingOps - -class JwtVerifierSpec extends AnyWordSpecBase, PropertyBasedTesting, ClockHelper, CodecUtils: - - val defaultConfig = - JwtVerifierConfig( - Algorithm.none(), - None, - ProvidedWithConfig(None, None, Nil), - LeewayWindowConfig(None, None, None, None), - ) - - def setRegisteredClaims(builder: JWTCreator.Builder, config: JwtVerifierConfig): TestData = - val now = getInstantNowSeconds - val leeway = config.leewayWindow.leeway.map(leeway => now.plusSeconds(leeway.toSeconds - 1)) - val expiresAt = config.leewayWindow.expiresAt.map(expiresAt => now.plusSeconds(expiresAt.toSeconds - 1)) - val notBefore = config.leewayWindow.notBefore.map(notBefore => now.plusSeconds(notBefore.toSeconds - 1)) - val issueAt = config.leewayWindow.issuedAt.map(issueAt => now.plusSeconds(issueAt.toSeconds - 1)) - - val registeredClaims = RegisteredClaims( - config.providedWith.issuerClaim, - config.providedWith.subjectClaim, - config.providedWith.audienceClaims, - expiresAt orElse leeway, - notBefore orElse leeway, - issueAt orElse leeway, - None, - ) - - val builderWithRegistered = builder - .tap(builder => registeredClaims.iss.map(str => builder.withIssuer(str))) - .tap(builder => registeredClaims.sub.map(str => builder.withSubject(str))) - .tap(builder => builder.withAudience(registeredClaims.aud: _*)) - .tap(builder => registeredClaims.exp.map(builder.withExpiresAt)) - .tap(builder => registeredClaims.nbf.map(builder.withNotBefore)) - .tap(builder => registeredClaims.iat.map(builder.withIssuedAt)) - - TestData(registeredClaims, builderWithRegistered) - - "JwtVerifier" should: - "verify token with prerequisite configurations" in forAll: (config: JwtVerifierConfig) => - val jwtVerifier = new JwtVerifier(config.copy(encrypt = None)) - - val testData = setRegisteredClaims(JWT.create(), config) - - val token = testData.builder.sign(config.algorithm) - - val verified = jwtVerifier.verifyJwt(token.toToken).value - - verified.registered shouldBe testData.registeredClaims - - "verify a token with header" in forAll: (nestedHeader: NestedHeader, config: JwtVerifierConfig) => - val jwtVerifier = new JwtVerifier(config.copy(encrypt = None)) - - val testData = setRegisteredClaims(JWT.create(), config) - - val token = testData.builder - .withHeader(unsafeParseJsonToJavaMap(nestedHeaderEncoder.encode(nestedHeader))) - .sign(config.algorithm) - - val verified = jwtVerifier.verifyJwt[NestedHeader](token.toTokenH) - - verified.value shouldBe nestedHeader.toClaimsH.copy(registered = testData.registeredClaims) - - "verify a token with header that is encrypted" in forAll: - (nestedHeader: NestedHeader, config: JwtVerifierConfig, encryptConfig: EncryptConfig) => - val jwtVerifier = new JwtVerifier(config.copy(encrypt = Some(encryptConfig))) - - val testData = setRegisteredClaims(JWT.create(), config) - - val token = testData.builder - .withHeader(unsafeParseJsonToJavaMap(nestedHeaderEncoder.encode(nestedHeader))) - .sign(config.algorithm) - .pipe(token => EncryptionUtils.encryptAES(token, encryptConfig.secret)) - .value - - val verified = jwtVerifier.verifyJwt[NestedHeader](token.toTokenH) - - verified.value shouldBe nestedHeader.toClaimsH.copy(registered = testData.registeredClaims) - - "verify a token with payload" in forAll: (nestedPayload: NestedPayload, config: JwtVerifierConfig) => - val jwtVerifier = new JwtVerifier(config.copy(encrypt = None)) - - val testData = setRegisteredClaims(JWT.create(), config) - - val token = testData.builder - .withPayload(unsafeParseJsonToJavaMap(nestedPayloadEncoder.encode(nestedPayload))) - .sign(config.algorithm) - - val verified = jwtVerifier.verifyJwt[NestedPayload](token.toTokenP) - - verified.value shouldBe nestedPayload.toClaimsP.copy(registered = testData.registeredClaims) - - "verify a token with payload that is encrypted" in forAll: - (nestedPayload: NestedPayload, config: JwtVerifierConfig, encryptConfig: EncryptConfig) => - val jwtVerifier = new JwtVerifier(config.copy(encrypt = Some(encryptConfig))) - - val testData = setRegisteredClaims(JWT.create(), config) - - val token = testData.builder - .withPayload(unsafeParseJsonToJavaMap(nestedPayloadEncoder.encode(nestedPayload))) - .sign(config.algorithm) - .pipe(token => EncryptionUtils.encryptAES(token, encryptConfig.secret)) - .value - - val verified = jwtVerifier.verifyJwt[NestedPayload](token.toTokenP) - - verified.value shouldBe nestedPayload.toClaimsP.copy(registered = testData.registeredClaims) - - "verify a token with header & payload" in forAll: - (nestedHeader: NestedHeader, nestedPayload: NestedPayload, config: JwtVerifierConfig) => - val jwtVerifier = new JwtVerifier(config.copy(encrypt = None)) - - val testData = setRegisteredClaims(JWT.create(), config) - - val token = testData.builder - .withPayload(unsafeParseJsonToJavaMap(nestedPayloadEncoder.encode(nestedPayload))) - .withHeader(unsafeParseJsonToJavaMap(nestedHeaderEncoder.encode(nestedHeader))) - .sign(config.algorithm) - - val verified = - jwtVerifier.verifyJwt[NestedHeader, NestedPayload](token.toTokenHP) - - verified.value shouldBe (nestedHeader, nestedPayload).toClaimsHP.copy(registered = testData.registeredClaims) - - "verify a token with header & payload that is encrypted" in forAll: - ( - nestedHeader: NestedHeader, - nestedPayload: NestedPayload, - config: JwtVerifierConfig, - encryptConfig: EncryptConfig, - ) => - val jwtVerifier = new JwtVerifier(config.copy(encrypt = Some(encryptConfig))) - - val testData = setRegisteredClaims(JWT.create(), config) - - val token = testData.builder - .withPayload(unsafeParseJsonToJavaMap(nestedPayloadEncoder.encode(nestedPayload))) - .withHeader(unsafeParseJsonToJavaMap(nestedHeaderEncoder.encode(nestedHeader))) - .sign(config.algorithm) - .pipe(token => EncryptionUtils.encryptAES(token, encryptConfig.secret)) - .value - - val verified = - jwtVerifier.verifyJwt[NestedHeader, NestedPayload](token.toTokenHP) - - verified.value shouldBe (nestedHeader, nestedPayload).toClaimsHP.copy(registered = testData.registeredClaims) - - "fail to verify a token that is encrypted" in: - val encryptConfig = defaultConfig.copy(encrypt = Some(EncryptConfig("secret"))) - val jwtVerifier = new JwtVerifier(encryptConfig) - - val token = JWT - .create() - .sign(encryptConfig.algorithm) - .pipe(token => EncryptionUtils.encryptAES(token, encryptConfig.encrypt.value.secret)) - .value - - val outOfRangeLongerToken = token + "H" - val outOfRangeShorterToken = token.take(token.length - 1) - val notValid = outOfRangeShorterToken + "." - - val failedOutOfRangeLonger = jwtVerifier.verifyJwt(outOfRangeLongerToken.toToken) - val failedOutOfRangeShorter = jwtVerifier.verifyJwt(outOfRangeShorterToken.toToken) - val failedNotValid = jwtVerifier.verifyJwt(notValid.toToken) - - failedOutOfRangeLonger.left.value shouldBe a[JwtVerifyError.DecryptionError] - failedOutOfRangeShorter.left.value shouldBe a[JwtVerifyError.DecryptionError] - failedNotValid.left.value shouldBe a[JwtVerifyError.DecryptionError] - - "fail to decode a token with header" in: - val jwtVerifier = new JwtVerifier(defaultConfig) - - val header = """{"name": "name"}""" - val token = JWT - .create() - .withHeader(unsafeParseJsonToJavaMap(header)) - .sign(defaultConfig.algorithm) - - val verified = jwtVerifier.verifyJwt[NestedHeader](token.toTokenH) - - verified shouldBe Left(JwtVerifyError.DecodingError("DecodingFailure at .mapping: Missing required field", null)) - - "fail to decode a token with payload" in: - val jwtVerifier = new JwtVerifier(defaultConfig) - - val payload = """{"name": "name"}""" - val token = JWT - .create() - .withPayload(unsafeParseJsonToJavaMap(payload)) - .sign(defaultConfig.algorithm) - - val verified = jwtVerifier.verifyJwt[NestedPayload](token.toTokenP) - - verified shouldBe Left(JwtVerifyError.DecodingError("DecodingFailure at .mapping: Missing required field", null)) - - "fail to decode a token with header & payload" in: - val jwtVerifier = new JwtVerifier(defaultConfig) - - val header = """{"name": "name"}""" - val token = JWT - .create() - .withHeader(unsafeParseJsonToJavaMap(header)) - .sign(defaultConfig.algorithm) - - val verified = - jwtVerifier.verifyJwt[NestedHeader, NestedPayload](token.toTokenHP) - - verified shouldBe Left(JwtVerifyError.DecodingError("DecodingFailure at .mapping: Missing required field", null)) - - "fail to decode a token with header if exception raised in decoder" in: - val jwtVerifier = new JwtVerifier(defaultConfig) - - val token = JWT - .create() - .sign(defaultConfig.algorithm) - - val verified = jwtVerifier.verifyJwt[SimpleHeader](token.toTokenH) - - verified.left.value.error shouldBe "Boom" - - "fail to decode a token with payload if exception raised in decoder" in: - val jwtVerifier = new JwtVerifier(defaultConfig) - - val token = JWT - .create() - .sign(defaultConfig.algorithm) - - val verified = jwtVerifier.verifyJwt[SimplePayload](token.toTokenP) - - verified.left.value.error shouldBe "Boom" - - "fail to decode a token with header & payload if exception raised in decoder" in: - val jwtVerifier = new JwtVerifier(defaultConfig) - - val token = JWT - .create() - .sign(defaultConfig.algorithm) - - val verified = - jwtVerifier.verifyJwt[SimpleHeader, SimplePayload](token.toTokenHP) - - verified.left.value.error shouldBe "Boom" - - "fail to verify token with VerificationError when provided with claims are not meet criteria" in: - val config = defaultConfig.copy(providedWith = defaultConfig.providedWith.copy(issuerClaim = Some("issuer"))) - val jwtVerifier = new JwtVerifier(config) - - val token = JWT - .create() - .sign(config.algorithm) - - val verified = jwtVerifier.verifyJwt(token.toToken) - - verified.left.value shouldBe JwtVerifyError.VerificationError("The Claim 'iss' is not present in the JWT.") - - "fail to verify token with IllegalArgument when null algorithm is provided" in forAll: - (config: JwtVerifierConfig) => - val jwtVerifier = new JwtVerifier(config.copy(algorithm = null, encrypt = None)) - - val token = JWT - .create() - .sign(config.algorithm) - - val verified = jwtVerifier.verifyJwt(token.toToken) - - verified.left.value shouldBe JwtVerifyError.IllegalArgument("The Algorithm cannot be null.") - - "fail to verify token with AlgorithmMismatch when jwt header algorithm doesn't match with verify" in forAll: - (config: JwtVerifierConfig) => - val jwtVerifier = new JwtVerifier(config.copy(algorithm = Algorithm.HMAC256("secret"), encrypt = None)) - - val token = JWT - .create() - .sign(config.algorithm) - - val verified = jwtVerifier.verifyJwt(token.toToken) - - verified.left.value shouldBe - JwtVerifyError - .AlgorithmMismatch("The provided Algorithm doesn't match the one defined in the JWT's Header.") - - "fail to verify token with SignatureVerificationError when secrets provided are wrong" in forAll: - (config: JwtVerifierConfig) => - val jwtVerifier = new JwtVerifier(config.copy(algorithm = Algorithm.HMAC256("secret2"), encrypt = None)) - - val token = JWT - .create() - .sign(Algorithm.HMAC256("secret1")) - - val verified = jwtVerifier.verifyJwt(token.toToken) - - verified.left.value shouldBe - JwtVerifyError - .SignatureVerificationError( - "The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA256" - ) - - "fail to verify token with TokenExpired when JWT expires" in: - val jwtVerifier = new JwtVerifier(defaultConfig) - - val expiresAt = getInstantNowSeconds.minusSeconds(1) - val token = JWT - .create() - .withExpiresAt(expiresAt) - .sign(defaultConfig.algorithm) - - val verified = jwtVerifier.verifyJwt(token.toToken) - - verified.left.value shouldBe - JwtVerifyError - .TokenExpired(s"The Token has expired on $expiresAt.") - - "fail to verify an empty string token" in: - val jwtVerifier = new JwtVerifier(defaultConfig) - val token = "" - val verified = jwtVerifier.verifyJwt(token.toToken) - val verifiedH = jwtVerifier.verifyJwt[NestedHeader](token.toTokenH) - val verifiedP = jwtVerifier.verifyJwt[NestedPayload](token.toTokenP) - val verifiedHP = jwtVerifier.verifyJwt[NestedHeader, NestedPayload](token.toTokenHP) - - verified.left.value shouldBe - JwtVerifyError - .VerificationError("JWT Token is empty.") - - verifiedH.left.value shouldBe - JwtVerifyError - .VerificationError("JWT Token is empty.") - - verifiedP.left.value shouldBe - JwtVerifyError - .VerificationError("JWT Token is empty.") - - verifiedHP.left.value shouldBe - JwtVerifyError - .VerificationError("JWT Token is empty.") diff --git a/modules/oath-core/src/test/scala/io/oath/test/NestedHeader.scala b/modules/oath-core/src/test/scala/io/oath/test/NestedHeader.scala deleted file mode 100644 index 0feee9d..0000000 --- a/modules/oath-core/src/test/scala/io/oath/test/NestedHeader.scala +++ /dev/null @@ -1,33 +0,0 @@ -package io.oath.test - -import io.circe.* -import io.circe.generic.semiauto.* -import io.circe.parser.* -import io.circe.syntax.* -import io.oath.* -import io.oath.json.* -import io.oath.test.NestedHeader.SimpleHeader - -case class NestedHeader(name: String, mapping: Map[String, SimpleHeader]) - -object NestedHeader: - case class SimpleHeader(name: String, data: List[String]) - - given simpleHeaderCirceEncoder: Encoder[SimpleHeader] = deriveEncoder[SimpleHeader] - given simpleHeaderCirceDecoder: Decoder[SimpleHeader] = deriveDecoder[SimpleHeader] - - given nestedHeaderCirceEncoder: Encoder[NestedHeader] = deriveEncoder[NestedHeader] - given nestedHeaderCirceDecoder: Decoder[NestedHeader] = deriveDecoder[NestedHeader] - - given simpleHeaderEncoder: ClaimsEncoder[SimpleHeader] = simpleHeader => simpleHeader.asJson.noSpaces - given simpleHeaderDecoder: ClaimsDecoder[SimpleHeader] = _ => throw new RuntimeException("Boom") - - given nestedHeaderEncoder: ClaimsEncoder[NestedHeader] = nestedHeader => nestedHeader.asJson.noSpaces - given nestedHeaderDecoder: ClaimsDecoder[NestedHeader] = nestedHeaderJson => - parse(nestedHeaderJson).left - .map(parsingFailure => JwtVerifyError.DecodingError(parsingFailure.message, parsingFailure.underlying)) - .flatMap( - _.as[NestedHeader].left.map(decodingFailure => - JwtVerifyError.DecodingError(decodingFailure.getMessage(), decodingFailure.getCause) - ) - ) diff --git a/modules/oath-core/src/test/scala/io/oath/test/NestedPayload.scala b/modules/oath-core/src/test/scala/io/oath/test/NestedPayload.scala deleted file mode 100644 index fb10403..0000000 --- a/modules/oath-core/src/test/scala/io/oath/test/NestedPayload.scala +++ /dev/null @@ -1,33 +0,0 @@ -package io.oath.test - -import io.circe.generic.semiauto.* -import io.circe.parser.* -import io.circe.syntax.* -import io.circe.{Decoder, Encoder} -import io.oath.* -import io.oath.json.* -import io.oath.test.NestedPayload.SimplePayload - -case class NestedPayload(name: String, mapping: Map[String, SimplePayload]) - -object NestedPayload: - case class SimplePayload(name: String, data: List[String]) - - given simplePayloadCirceEncoder: Encoder[SimplePayload] = deriveEncoder[SimplePayload] - given simplePayloadCirceDecoder: Decoder[SimplePayload] = deriveDecoder[SimplePayload] - - given nestedPayloadCirceEncoder: Encoder[NestedPayload] = deriveEncoder[NestedPayload] - given nestedPayloadCirceDecoder: Decoder[NestedPayload] = deriveDecoder[NestedPayload] - - given simplePayloadEncoder: ClaimsEncoder[SimplePayload] = simplePayload => simplePayload.asJson.noSpaces - given simplePayloadDecoder: ClaimsDecoder[SimplePayload] = _ => throw new RuntimeException("Boom") - - given nestedPayloadEncoder: ClaimsEncoder[NestedPayload] = nestedPayload => nestedPayload.asJson.noSpaces - given nestedPayloadDecoder: ClaimsDecoder[NestedPayload] = nestedPayloadJson => - parse(nestedPayloadJson).left - .map(parsingFailure => JwtVerifyError.DecodingError(parsingFailure.message, parsingFailure.underlying)) - .flatMap( - _.as[NestedPayload].left.map(decodingFailure => - JwtVerifyError.DecodingError(decodingFailure.getMessage(), decodingFailure.getCause) - ) - ) diff --git a/modules/oath-core/src/test/scala/io/oath/test/OathToken.scala b/modules/oath-core/src/test/scala/io/oath/test/OathToken.scala deleted file mode 100644 index 23bbb19..0000000 --- a/modules/oath-core/src/test/scala/io/oath/test/OathToken.scala +++ /dev/null @@ -1,4 +0,0 @@ -package io.oath.test - -enum OathToken: - case AccessToken, RefreshToken, ActivationEmailToken, ForgotPasswordToken diff --git a/modules/oath-core/src/test/scala/io/oath/test/utils/UtilsSpec.scala b/modules/oath-core/src/test/scala/io/oath/test/utils/UtilsSpec.scala deleted file mode 100644 index 8aaca61..0000000 --- a/modules/oath-core/src/test/scala/io/oath/test/utils/UtilsSpec.scala +++ /dev/null @@ -1,26 +0,0 @@ -package io.oath.test.utils - -import io.oath.testkit.AnyWordSpecBase -import io.oath.utils.* - -class UtilsSpec extends AnyWordSpecBase: - - "FormatConversion" should: - "convert upper camel case to lower hyphen" in: - val res1 = convertUpperCamelToLowerHyphen("HelloWorld") - val res2 = convertUpperCamelToLowerHyphen(" Hello World ") - - val expected = "hello-world" - - res1 shouldBe expected - res2 shouldBe expected - - "convert scala enum string values to lower hyphen" in: - enum SomeEnum: - case firstEnum, SecondEnum, Third, ForthEnumValue - - val expected = Seq("first-enum", "second-enum", "third", "forth-enum-value") - - SomeEnum.values.toSeq - .map(_.toString) - .map(convertUpperCamelToLowerHyphen) should contain theSameElementsAs expected diff --git a/modules/oath-core/src/test/scala/io/oath/utils/TestData.scala b/modules/oath-core/src/test/scala/io/oath/utils/TestData.scala deleted file mode 100644 index 579bbf5..0000000 --- a/modules/oath-core/src/test/scala/io/oath/utils/TestData.scala +++ /dev/null @@ -1,6 +0,0 @@ -package io.oath.utils - -import com.auth0.jwt.JWTCreator -import io.oath.RegisteredClaims - -case class TestData(registeredClaims: RegisteredClaims, builder: JWTCreator.Builder) diff --git a/modules/oath-jsoniter-scala/src/main/scala/io/oath/jsoniter_scala/syntax.scala b/modules/oath-jsoniter-scala/src/main/scala/io/oath/jsoniter_scala/syntax.scala deleted file mode 100644 index a414d52..0000000 --- a/modules/oath-jsoniter-scala/src/main/scala/io/oath/jsoniter_scala/syntax.scala +++ /dev/null @@ -1,20 +0,0 @@ -package io.oath.jsoniter_scala - -import com.github.plokhotnyuk.jsoniter_scala.core.* -import io.oath.JwtVerifyError -import io.oath.json.ClaimsCodec - -import scala.util.control.Exception.allCatch - -object syntax: - extension [P](codec: JsonValueCodec[P]) - def convert: ClaimsCodec[P] = new ClaimsCodec[P]: - override def decode(token: String): Either[JwtVerifyError.DecodingError, P] = - allCatch - .withTry(readFromString(token)(codec)) - .toEither - .left - .map(error => JwtVerifyError.DecodingError(error.getMessage, error.getCause)) - - override def encode(data: P): String = - writeToString(data)(codec) diff --git a/modules/oath-jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/Bar.scala b/modules/oath-jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/Bar.scala deleted file mode 100644 index b8bf052..0000000 --- a/modules/oath-jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/Bar.scala +++ /dev/null @@ -1,13 +0,0 @@ -package io.oath.jsoniter_scala - -import com.github.plokhotnyuk.jsoniter_scala.core.* -import com.github.plokhotnyuk.jsoniter_scala.macros.* -import io.oath.json.ClaimsCodec -import io.oath.jsoniter_scala.syntax.* - -final case class Bar(name: String, age: Int) - -object Bar { - - implicit val codecBar: ClaimsCodec[Bar] = JsonCodecMaker.make.convert -} diff --git a/modules/oath-macros/src/main/scala/io/oath/OathEnumMacro.scala b/modules/oath-macros/src/main/scala/io/oath/OathEnumMacro.scala deleted file mode 100644 index b593f75..0000000 --- a/modules/oath-macros/src/main/scala/io/oath/OathEnumMacro.scala +++ /dev/null @@ -1,11 +0,0 @@ -package io.oath - -import scala.quoted.* - -object OathEnumMacro: - inline def enumValues[E]: Array[E] = ${ enumValuesImpl[E] } - - def enumValuesImpl[E: Type](using Quotes): Expr[Array[E]] = - import quotes.reflect.* - val companion = Ref(TypeTree.of[E].symbol.companionModule) - Select.unique(companion, "values").asExprOf[Array[E]] diff --git a/modules/oath-macros/src/test/scala/io/oath/OathEnum.scala b/modules/oath-macros/src/test/scala/io/oath/OathEnum.scala deleted file mode 100644 index 01736c2..0000000 --- a/modules/oath-macros/src/test/scala/io/oath/OathEnum.scala +++ /dev/null @@ -1,4 +0,0 @@ -package io.oath - -object OathEnum: - inline def apply[A]: Set[A] = OathEnumMacro.enumValues[A].toSet diff --git a/modules/oath-macros/src/test/scala/io/oath/OathEnumMacroSpec.scala b/modules/oath-macros/src/test/scala/io/oath/OathEnumMacroSpec.scala deleted file mode 100644 index 5dc6fdd..0000000 --- a/modules/oath-macros/src/test/scala/io/oath/OathEnumMacroSpec.scala +++ /dev/null @@ -1,15 +0,0 @@ -package io.oath - -import org.scalatest.matchers.should -import org.scalatest.wordspec.AnyWordSpec - -class OathEnumMacroSpec extends AnyWordSpec with should.Matchers: - - enum Foo: - case Foo1, Foo2, Foo3, Foo4 - - "OathEnumMacros" should: - - "discover all children directories (enum values) in a Sum type" in: - val fooChildren = OathEnum[Foo] - fooChildren should contain theSameElementsAs Set(Foo.Foo1, Foo.Foo2, Foo.Foo3, Foo.Foo4) diff --git a/oath/circe/src/main/scala/io/oath/circe/conversion.scala b/oath/circe/src/main/scala/io/oath/circe/conversion.scala new file mode 100644 index 0000000..3963b72 --- /dev/null +++ b/oath/circe/src/main/scala/io/oath/circe/conversion.scala @@ -0,0 +1,29 @@ +package io.oath.circe + +import io.circe._ +import io.circe.syntax.EncoderOps +import io.oath.JwtVerifyError +import io.oath.json._ + +object conversion { + given [P](using codec: Codec[P]): ClaimsCodec[P] = new ClaimsCodec[P] { + override def decode(token: String): Either[JwtVerifyError.DecodingError, P] = + decoderConverter[P].decode(token) + + override def encode(data: P): String = + encoderConverter[P].encode(data) + } + + given encoderConverter[P](using encoder: Encoder[P]): ClaimsEncoder[P] = data => data.asJson(encoder).noSpaces + + given decoderConverter[P](using decoder: Decoder[P]): ClaimsDecoder[P] = json => + parser + .parse(json) + .left + .map(parsingFailure => JwtVerifyError.DecodingError(parsingFailure.message, parsingFailure.underlying)) + .flatMap( + _.as[P](decoder).left.map(decodingFailure => + JwtVerifyError.DecodingError(decodingFailure.getMessage(), decodingFailure.getCause) + ) + ) +} diff --git a/oath/circe/src/test/scala/io/oath/circe/Bar.scala b/oath/circe/src/test/scala/io/oath/circe/Bar.scala new file mode 100644 index 0000000..85a4ca1 --- /dev/null +++ b/oath/circe/src/test/scala/io/oath/circe/Bar.scala @@ -0,0 +1,11 @@ +package io.oath.circe + +import io.circe.generic.semiauto._ +import io.circe.{Decoder, Encoder} + +final case class Bar(name: String, age: Int) + +object Bar { + given barEncoder: Encoder[Bar] = deriveEncoder[Bar] + given barDecoder: Decoder[Bar] = deriveDecoder[Bar] +} diff --git a/modules/oath-circe/src/test/scala/io/oath/circe/CirceConversionSpec.scala b/oath/circe/src/test/scala/io/oath/circe/CirceConversionSpec.scala similarity index 60% rename from modules/oath-circe/src/test/scala/io/oath/circe/CirceConversionSpec.scala rename to oath/circe/src/test/scala/io/oath/circe/CirceConversionSpec.scala index b6c1dd2..4eadf57 100644 --- a/modules/oath-circe/src/test/scala/io/oath/circe/CirceConversionSpec.scala +++ b/oath/circe/src/test/scala/io/oath/circe/CirceConversionSpec.scala @@ -2,49 +2,53 @@ package io.oath.circe import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm -import io.oath.* +import io.oath._ +import io.oath.circe.conversion.given import io.oath.config.JwtIssuerConfig.RegisteredConfig import io.oath.config.JwtVerifierConfig.{LeewayWindowConfig, ProvidedWithConfig} -import io.oath.config.* -import io.oath.syntax.* -import io.oath.testkit.AnyWordSpecBase -import io.oath.utils.CodecUtils +import io.oath.config._ +import io.oath.json.ClaimsDecoder +import io.oath.syntax.all._ +import io.oath.testkit.CodecHelper.unsafeParseJsonToJavaMap +import io.oath.testkit._ +import org.typelevel.jawn.ParseException -class CirceConversionSpec extends AnyWordSpecBase, CodecUtils: +class CirceConversionSpec extends WordSpecBase { val verifierConfig = JwtVerifierConfig( Algorithm.HMAC256("secret"), - None, ProvidedWithConfig(None, None, Nil), LeewayWindowConfig(None, None, None, None), ) + val issuerConfig = JwtIssuerConfig( Algorithm.HMAC256("secret"), - None, RegisteredConfig(None, None, Nil, includeJwtIdClaim = false, includeIssueAtClaim = false, None, None), ) - val jwtVerifier = new JwtVerifier(verifierConfig) - val jwtIssuer = new JwtIssuer(issuerConfig) + val jwtVerifier = JwtVerifier(verifierConfig) + val jwtIssuer = JwtIssuer(issuerConfig) - "CirceConversion" should: - "convert circe (encoders & decoders) to claims (encoders & decoders)" in: + "CirceConversion" should { + "convert circe (encoders & decoders) to claims (encoders & decoders)" in { val bar = Bar("bar", 10) val jwt = jwtIssuer.issueJwt(JwtClaims.ClaimsP(bar)).value val claims = jwtVerifier.verifyJwt[Bar](jwt.token.toTokenP).value claims.payload shouldBe bar + } - "convert circe (codec) to claims (encoders & decoders)" in: + "convert circe (codec) to claims (encoders & decoders)" in { val foo = Foo("foo", 10) val jwt = jwtIssuer.issueJwt(JwtClaims.ClaimsP(foo, RegisteredClaims.empty.copy(iss = Some("issuer")))).value val claims = jwtVerifier.verifyJwt[Foo](jwt.token.toTokenP).value claims.payload shouldBe foo + } - "convert circe decoder to claims decoder and get error" in: + "convert circe decoder to claims decoder and get error" in { val fooJson = """{"name":"Hello","age":"not number"}""" val jwt = JWT .create() @@ -53,3 +57,15 @@ class CirceConversionSpec extends AnyWordSpecBase, CodecUtils: val claims = jwtVerifier.verifyJwt[Foo](jwt.toTokenP) claims.left.value shouldBe JwtVerifyError.DecodingError("DecodingFailure at .age: Int", null) + } + + "convert circe decoder to claims decoder and get error when format is incorrect" in { + val fooJson = """{"name":,}""" + + summon[ClaimsDecoder[Foo]].decode(fooJson).left.value shouldEqual JwtVerifyError.DecodingError( + "expected json value got ',}' (line 1, column 9)", + ParseException("expected json value got ',}' (line 1, column 9)", 8, 1, 9), + ) + } + } +} diff --git a/oath/circe/src/test/scala/io/oath/circe/Foo.scala b/oath/circe/src/test/scala/io/oath/circe/Foo.scala new file mode 100644 index 0000000..85bc80f --- /dev/null +++ b/oath/circe/src/test/scala/io/oath/circe/Foo.scala @@ -0,0 +1,10 @@ +package io.oath.circe + +import io.circe.Codec +import io.circe.generic.semiauto._ + +final case class Foo(name: String, age: Int) + +object Foo { + given barCodec: Codec[Foo] = deriveCodec[Foo] +} diff --git a/oath/core/src/main/scala/io/oath/Jwt.scala b/oath/core/src/main/scala/io/oath/Jwt.scala new file mode 100644 index 0000000..9a433c4 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/Jwt.scala @@ -0,0 +1,3 @@ +package io.oath + +final case class Jwt[T <: JwtClaims](claims: T, token: String) diff --git a/oath/core/src/main/scala/io/oath/JwtClaims.scala b/oath/core/src/main/scala/io/oath/JwtClaims.scala new file mode 100644 index 0000000..6c8c2f3 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/JwtClaims.scala @@ -0,0 +1,20 @@ +package io.oath + +sealed abstract class JwtClaims(val registered: RegisteredClaims) + +object JwtClaims { + final case class Claims(override val registered: RegisteredClaims = RegisteredClaims.empty) + extends JwtClaims(registered) + + final case class ClaimsH[+H](header: H, override val registered: RegisteredClaims = RegisteredClaims.empty) + extends JwtClaims(registered) + + final case class ClaimsP[+P](payload: P, override val registered: RegisteredClaims = RegisteredClaims.empty) + extends JwtClaims(registered) + + final case class ClaimsHP[+H, +P]( + header: H, + payload: P, + override val registered: RegisteredClaims = RegisteredClaims.empty, + ) extends JwtClaims(registered) +} diff --git a/oath/core/src/main/scala/io/oath/JwtIssueError.scala b/oath/core/src/main/scala/io/oath/JwtIssueError.scala new file mode 100644 index 0000000..9f0b3a4 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/JwtIssueError.scala @@ -0,0 +1,9 @@ +package io.oath + +sealed abstract class JwtIssueError(error: String, cause: Throwable) extends Exception(error, cause) + +object JwtIssueError { + final case class SignError(message: String)(underlying: Throwable) extends JwtIssueError(message, underlying) + + final case class EncodeError(message: String)(underlying: Throwable) extends JwtIssueError(message, underlying) +} diff --git a/oath/core/src/main/scala/io/oath/JwtIssuer.scala b/oath/core/src/main/scala/io/oath/JwtIssuer.scala new file mode 100644 index 0000000..eac930a --- /dev/null +++ b/oath/core/src/main/scala/io/oath/JwtIssuer.scala @@ -0,0 +1,167 @@ +package io.oath + +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.{JWT, JWTCreator} +import io.oath.config._ +import io.oath.json.ClaimsEncoder + +import java.time.temporal.ChronoUnit +import java.time.{Clock, Instant} +import java.util.UUID +import scala.util.chaining._ +import scala.util.control.Exception.allCatch + +trait JwtIssuer { + def issueJwt(claims: JwtClaims.Claims): Either[JwtIssueError, Jwt[JwtClaims.Claims]] + def issueJwt[H](claims: JwtClaims.ClaimsH[H])(using + ClaimsEncoder[H] + ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsH[H]]] + def issueJwt[P](claims: JwtClaims.ClaimsP[P])(using + ClaimsEncoder[P] + ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsP[P]]] + def issueJwt[H, P]( + claims: JwtClaims.ClaimsHP[H, P] + )(using ClaimsEncoder[H], ClaimsEncoder[P]): Either[JwtIssueError, Jwt[JwtClaims.ClaimsHP[H, P]]] +} + +object JwtIssuer { + + private final class JavaJwtIssuerImpl(config: JwtIssuerConfig, clock: Clock = Clock.systemUTC()) extends JwtIssuer { + + private def buildJwt(builder: JWTCreator.Builder, registeredClaims: RegisteredClaims): JWTCreator.Builder = + builder + .tap(builder => registeredClaims.iss.map(str => builder.withIssuer(str))) + .tap(builder => registeredClaims.sub.map(str => builder.withSubject(str))) + .tap(builder => builder.withAudience(registeredClaims.aud*)) + .tap(builder => registeredClaims.jti.map(str => builder.withJWTId(str))) + .tap(builder => registeredClaims.iat.map(builder.withIssuedAt)) + .tap(builder => registeredClaims.exp.map(builder.withExpiresAt)) + .tap(builder => registeredClaims.nbf.map(builder.withNotBefore)) + + private def setRegisteredClaims(adHocRegisteredClaims: RegisteredClaims): RegisteredClaims = { + val now = Instant.now(clock).truncatedTo(ChronoUnit.SECONDS) + RegisteredClaims( + iss = adHocRegisteredClaims.iss orElse config.registered.issuerClaim, + sub = adHocRegisteredClaims.sub orElse config.registered.subjectClaim, + aud = if (adHocRegisteredClaims.aud.isEmpty) config.registered.audienceClaims else adHocRegisteredClaims.aud, + exp = adHocRegisteredClaims.exp orElse config.registered.expiresAtOffset.map(duration => + now.plusSeconds(duration.toSeconds) + ), + nbf = adHocRegisteredClaims.nbf orElse config.registered.notBeforeOffset.map(duration => + now.plusSeconds(duration.toSeconds) + ), + iat = adHocRegisteredClaims.iat orElse Option.when(config.registered.includeIssueAtClaim)(now), + jti = adHocRegisteredClaims.jti orElse Option + .when(config.registered.includeJwtIdClaim)( + config.registered.issuerClaim + .map(_ + "-") + .getOrElse("") + .pipe(prefix => prefix + UUID.randomUUID().toString) + ), + ) + } + + private def safeEncodeHeader[T]( + jwtBuilder: JWTCreator.Builder, + claims: T, + )(using claimsEncoder: ClaimsEncoder[T]): Either[JwtIssueError.EncodeError, JWTCreator.Builder] = + allCatch + .withTry( + claimsEncoder + .encode(claims) + .pipe(jwtBuilder.withHeader) + ) + .toEither + .left + .map(JwtIssueError.EncodeError("Failed when trying to encode header")) + + private def safeEncodePayload[T]( + jwtBuilder: JWTCreator.Builder, + claims: T, + )(using claimsEncoder: ClaimsEncoder[T]): Either[JwtIssueError.EncodeError, JWTCreator.Builder] = + allCatch + .withTry( + claimsEncoder + .encode(claims) + .pipe(jwtBuilder.withPayload) + ) + .toEither + .left + .map(JwtIssueError.EncodeError("Failed when trying to encode payload")) + + private def safeSign(builder: JWTCreator.Builder, algorithm: Algorithm): Either[JwtIssueError, String] = + allCatch + .withTry(builder.sign(algorithm)) + .toEither + .left + .map(JwtIssueError.SignError("Signing token failed")) + + def issueJwt( + claims: JwtClaims.Claims = JwtClaims.Claims() + ): Either[JwtIssueError, Jwt[JwtClaims.Claims]] = { + val jwtBuilder = JWT.create() + setRegisteredClaims(claims.registered) + .pipe(registeredClaims => buildJwt(jwtBuilder, registeredClaims) -> registeredClaims) + .pipe { case (jwtBuilder, registeredClaims) => + safeSign(jwtBuilder, config.algorithm) + .map(token => + Jwt( + JwtClaims.Claims(registeredClaims), + token, + ) + ) + } + } + + def issueJwt[H](claims: JwtClaims.ClaimsH[H])(using + ClaimsEncoder[H] + ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsH[H]]] = { + val jwtBuilder = JWT.create() + for + headerBuilder <- safeEncodeHeader(jwtBuilder, claims.header) + registeredClaims = setRegisteredClaims(claims.registered) + builder = buildJwt(headerBuilder, registeredClaims) + token <- safeSign(builder, config.algorithm) + jwt = Jwt( + claims.copy(registered = registeredClaims), + token, + ) + yield jwt + } + + def issueJwt[P](claims: JwtClaims.ClaimsP[P])(using + ClaimsEncoder[P] + ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsP[P]]] = { + val jwtBuilder = JWT.create() + for + payloadBuilder <- safeEncodePayload(jwtBuilder, claims.payload) + registeredClaims = setRegisteredClaims(claims.registered) + builder = buildJwt(payloadBuilder, registeredClaims) + token <- safeSign(builder, config.algorithm) + jwt = Jwt( + claims.copy(registered = registeredClaims), + token, + ) + yield jwt + } + + def issueJwt[H, P]( + claims: JwtClaims.ClaimsHP[H, P] + )(using ClaimsEncoder[H], ClaimsEncoder[P]): Either[JwtIssueError, Jwt[JwtClaims.ClaimsHP[H, P]]] = { + val jwtBuilder = JWT.create() + for { + payloadBuilder <- safeEncodePayload(jwtBuilder, claims.payload) + headerAndPayloadBuilder <- safeEncodeHeader(payloadBuilder, claims.header) + registeredClaims = setRegisteredClaims(claims.registered) + builder = buildJwt(headerAndPayloadBuilder, registeredClaims) + token <- safeSign(builder, config.algorithm) + jwt = Jwt( + claims.copy(registered = registeredClaims), + token, + ) + } yield jwt + } + } + + def apply(config: JwtIssuerConfig, clock: Clock = Clock.systemUTC()): JwtIssuer = new JavaJwtIssuerImpl(config, clock) +} diff --git a/oath/core/src/main/scala/io/oath/JwtManager.scala b/oath/core/src/main/scala/io/oath/JwtManager.scala new file mode 100644 index 0000000..956ab10 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/JwtManager.scala @@ -0,0 +1,67 @@ +package io.oath + +import io.oath.config._ +import io.oath.json.{ClaimsDecoder, ClaimsEncoder} + +import java.time.Clock + +trait JwtManager { + def issueJwt(claims: JwtClaims.Claims = JwtClaims.Claims()): Either[JwtIssueError, Jwt[JwtClaims.Claims]] + def issueJwt[H](claims: JwtClaims.ClaimsH[H])(using + ClaimsEncoder[H] + ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsH[H]]] + def issueJwt[P](claims: JwtClaims.ClaimsP[P])(using + ClaimsEncoder[P] + ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsP[P]]] + def issueJwt[H, P]( + claims: JwtClaims.ClaimsHP[H, P] + )(using ClaimsEncoder[H], ClaimsEncoder[P]): Either[JwtIssueError, Jwt[JwtClaims.ClaimsHP[H, P]]] + def verifyJwt(jwt: JwtToken.Token): Either[JwtVerifyError, JwtClaims.Claims] + def verifyJwt[H](jwt: JwtToken.TokenH)(using ClaimsDecoder[H]): Either[JwtVerifyError, JwtClaims.ClaimsH[H]] + def verifyJwt[P](jwt: JwtToken.TokenP)(using ClaimsDecoder[P]): Either[JwtVerifyError, JwtClaims.ClaimsP[P]] + def verifyJwt[H, P]( + jwt: JwtToken.TokenHP + )(using ClaimsDecoder[H], ClaimsDecoder[P]): Either[JwtVerifyError, JwtClaims.ClaimsHP[H, P]] +} + +object JwtManager { + private final class JavaJwtManagerImpl(config: JwtManagerConfig, clock: Clock) extends JwtManager { + + private val issuer = JwtIssuer(config.issuer, clock) + private val verifier = JwtVerifier(config.verifier) + + def issueJwt( + claims: JwtClaims.Claims = JwtClaims.Claims() + ): Either[JwtIssueError, Jwt[JwtClaims.Claims]] = issuer.issueJwt(claims) + + def issueJwt[H](claims: JwtClaims.ClaimsH[H])(using + ClaimsEncoder[H] + ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsH[H]]] = issuer.issueJwt(claims) + + def issueJwt[P](claims: JwtClaims.ClaimsP[P])(using + ClaimsEncoder[P] + ): Either[JwtIssueError, Jwt[JwtClaims.ClaimsP[P]]] = + issuer.issueJwt(claims) + + def issueJwt[H, P]( + claims: JwtClaims.ClaimsHP[H, P] + )(using ClaimsEncoder[H], ClaimsEncoder[P]): Either[JwtIssueError, Jwt[JwtClaims.ClaimsHP[H, P]]] = + issuer.issueJwt(claims) + + def verifyJwt(jwt: JwtToken.Token): Either[JwtVerifyError, JwtClaims.Claims] = verifier.verifyJwt(jwt) + + def verifyJwt[H](jwt: JwtToken.TokenH)(using ClaimsDecoder[H]): Either[JwtVerifyError, JwtClaims.ClaimsH[H]] = + verifier.verifyJwt(jwt) + + def verifyJwt[P](jwt: JwtToken.TokenP)(using ClaimsDecoder[P]): Either[JwtVerifyError, JwtClaims.ClaimsP[P]] = + verifier.verifyJwt(jwt) + + def verifyJwt[H, P]( + jwt: JwtToken.TokenHP + )(using ClaimsDecoder[H], ClaimsDecoder[P]): Either[JwtVerifyError, JwtClaims.ClaimsHP[H, P]] = + verifier.verifyJwt[H, P](jwt) + } + + def apply(config: JwtManagerConfig, clock: Clock = Clock.systemUTC()): JwtManager = + new JavaJwtManagerImpl(config, clock) +} diff --git a/oath/core/src/main/scala/io/oath/JwtToken.scala b/oath/core/src/main/scala/io/oath/JwtToken.scala new file mode 100644 index 0000000..4399bb5 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/JwtToken.scala @@ -0,0 +1,10 @@ +package io.oath + +sealed abstract class JwtToken(val token: String) + +object JwtToken { + final case class Token(override val token: String) extends JwtToken(token) + final case class TokenH(override val token: String) extends JwtToken(token) + final case class TokenP(override val token: String) extends JwtToken(token) + final case class TokenHP(override val token: String) extends JwtToken(token) +} diff --git a/oath/core/src/main/scala/io/oath/JwtVerifier.scala b/oath/core/src/main/scala/io/oath/JwtVerifier.scala new file mode 100644 index 0000000..cca4308 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/JwtVerifier.scala @@ -0,0 +1,123 @@ +package io.oath + +import com.auth0.jwt.JWT +import com.auth0.jwt.interfaces.DecodedJWT +import io.oath.config.JwtVerifierConfig +import io.oath.json._ +import io.oath.utils.Base64 + +import scala.util.chaining.scalaUtilChainingOps +import scala.util.control.Exception.allCatch + +trait JwtVerifier { + def verifyJwt(jwt: JwtToken.Token): Either[JwtVerifyError, JwtClaims.Claims] + def verifyJwt[H](jwt: JwtToken.TokenH)(using ClaimsDecoder[H]): Either[JwtVerifyError, JwtClaims.ClaimsH[H]] + def verifyJwt[P](jwt: JwtToken.TokenP)(using ClaimsDecoder[P]): Either[JwtVerifyError, JwtClaims.ClaimsP[P]] + def verifyJwt[H, P]( + jwt: JwtToken.TokenHP + )(using ClaimsDecoder[H], ClaimsDecoder[P]): Either[JwtVerifyError, JwtClaims.ClaimsHP[H, P]] +} + +object JwtVerifier { + private final class JavaJwtVerifierImpl(config: JwtVerifierConfig) extends JwtVerifier { + + private lazy val jwtVerifier = + JWT + .require(config.algorithm) + .tap(jwtVerification => config.providedWith.issuerClaim.map(str => jwtVerification.withIssuer(str))) + .tap(jwtVerification => config.providedWith.subjectClaim.map(str => jwtVerification.withSubject(str))) + .tap(jwtVerification => + if (config.providedWith.audienceClaims.nonEmpty) + jwtVerification.withAudience(config.providedWith.audienceClaims*) + else () + ) + .tap(jwtVerification => + config.leewayWindow.leeway.map(duration => jwtVerification.acceptLeeway(duration.toSeconds)) + ) + .tap(jwtVerification => + config.leewayWindow.issuedAt.map(duration => jwtVerification.acceptIssuedAt(duration.toSeconds)) + ) + .tap(jwtVerification => + config.leewayWindow.expiresAt.map(duration => jwtVerification.acceptExpiresAt(duration.toSeconds)) + ) + .tap(jwtVerification => + config.leewayWindow.notBefore.map(duration => jwtVerification.acceptNotBefore(duration.toSeconds)) + ) + .build() + + private def getRegisteredClaims(decodedJWT: DecodedJWT): RegisteredClaims = + RegisteredClaims( + iss = decodedJWT.getOptionIssuer, + sub = decodedJWT.getOptionSubject, + aud = decodedJWT.getSeqAudience, + exp = decodedJWT.getOptionExpiresAt, + nbf = decodedJWT.getOptionNotBefore, + iat = decodedJWT.getOptionIssueAt, + jti = decodedJWT.getOptionJwtID, + ) + + private def validateToken(token: String): Either[JwtVerifyError.VerificationError, String] = + Option(token) + .filter(_.nonEmpty) + .toRight(JwtVerifyError.VerificationError("JWTVerifier failed with an empty token.")) + + private def safeDecode[T]( + decodedObject: => Either[JwtVerifyError.DecodingError, T] + ): Either[JwtVerifyError.DecodingError, T] = + allCatch + .withTry(decodedObject) + .fold(error => Left(JwtVerifyError.DecodingError(error.getMessage, error)), identity) + + private def verify(token: String): Either[JwtVerifyError, DecodedJWT] = + allCatch + .withTry(jwtVerifier.verify(token)) + .toEither + .left + .map(e => JwtVerifyError.VerificationError("JwtVerifier failed with verification error", Some(e))) + + def verifyJwt(jwt: JwtToken.Token): Either[JwtVerifyError, JwtClaims.Claims] = + for + token <- validateToken(jwt.token) + decodedJwt <- verify(token) + registeredClaims = getRegisteredClaims(decodedJwt) + yield JwtClaims.Claims(registeredClaims) + + def verifyJwt[H](jwt: JwtToken.TokenH)(using + claimsDecoder: ClaimsDecoder[H] + ): Either[JwtVerifyError, JwtClaims.ClaimsH[H]] = + for + token <- validateToken(jwt.token) + decodedJwt <- verify(token) + json <- Base64.decodeToken(decodedJwt.getHeader) + payload <- safeDecode(claimsDecoder.decode(json)) + registeredClaims = getRegisteredClaims(decodedJwt) + yield JwtClaims.ClaimsH(payload, registeredClaims) + + def verifyJwt[P](jwt: JwtToken.TokenP)(using + claimsDecoder: ClaimsDecoder[P] + ): Either[JwtVerifyError, JwtClaims.ClaimsP[P]] = + for + token <- validateToken(jwt.token) + decodedJwt <- verify(token) + json <- Base64.decodeToken(decodedJwt.getPayload) + payload <- safeDecode(claimsDecoder.decode(json)) + registeredClaims = getRegisteredClaims(decodedJwt) + yield JwtClaims.ClaimsP(payload, registeredClaims) + + def verifyJwt[H, P](jwt: JwtToken.TokenHP)(using + headerDecoder: ClaimsDecoder[H], + payloadDecoder: ClaimsDecoder[P], + ): Either[JwtVerifyError, JwtClaims.ClaimsHP[H, P]] = + for + token <- validateToken(jwt.token) + decodedJwt <- verify(token) + jsonHeader <- Base64.decodeToken(decodedJwt.getHeader) + jsonPayload <- Base64.decodeToken(decodedJwt.getPayload) + headerClaims <- safeDecode(headerDecoder.decode(jsonHeader)) + payloadClaims <- safeDecode(payloadDecoder.decode(jsonPayload)) + registeredClaims = getRegisteredClaims(decodedJwt) + yield JwtClaims.ClaimsHP(headerClaims, payloadClaims, registeredClaims) + } + + def apply(config: JwtVerifierConfig): JwtVerifier = new JavaJwtVerifierImpl(config) +} diff --git a/oath/core/src/main/scala/io/oath/JwtVerifyError.scala b/oath/core/src/main/scala/io/oath/JwtVerifyError.scala new file mode 100644 index 0000000..f78895e --- /dev/null +++ b/oath/core/src/main/scala/io/oath/JwtVerifyError.scala @@ -0,0 +1,14 @@ +package io.oath + +import cats.syntax.all._ + +sealed abstract class JwtVerifyError(error: String, cause: Option[Throwable] = None) + extends Exception(error, cause.orNull) + +object JwtVerifyError { + final case class VerificationError(message: String, underlying: Option[Throwable] = None) + extends JwtVerifyError(message, underlying) + + final case class DecodingError(message: String, underlying: Throwable) + extends JwtVerifyError(message, underlying.some) +} diff --git a/oath/core/src/main/scala/io/oath/OathIssuer.scala b/oath/core/src/main/scala/io/oath/OathIssuer.scala new file mode 100644 index 0000000..72f9041 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/OathIssuer.scala @@ -0,0 +1,30 @@ +package io.oath + +import io.oath.OathIssuer.JIssuer +import io.oath.config.JwtIssuerConfig +import io.oath.macros.OathEnum + +import scala.util.chaining.scalaUtilChainingOps + +trait OathIssuer[A] { + def as[S <: A](tokenType: S): JIssuer[S] +} + +object OathIssuer { + // Type aliases with extra information, useful to determine the token type. + type JIssuer[_] = JwtIssuer + + private final class JavaJwtOathIssuer[A](mapping: Map[A, JwtIssuer]) extends OathIssuer[A] { + def as[S <: A](tokenType: S): JIssuer[S] = mapping(tokenType) + } + + def none[E: OathEnum]: OathIssuer[E] = + summon[OathEnum[E]].values.map { case (tokenType, _) => + tokenType -> JwtIssuer(JwtIssuerConfig.none()) + }.pipe(mapping => new JavaJwtOathIssuer(mapping)) + + def createOrFail[E: OathEnum]: OathIssuer[E] = + summon[OathEnum[E]].values.map { case (tokenType, tokenConfig) => + tokenType -> JwtIssuerConfig.loadOrThrowOath(tokenConfig).pipe(JwtIssuer(_)) + }.pipe(mapping => new JavaJwtOathIssuer(mapping)) +} diff --git a/oath/core/src/main/scala/io/oath/OathManager.scala b/oath/core/src/main/scala/io/oath/OathManager.scala new file mode 100644 index 0000000..b896105 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/OathManager.scala @@ -0,0 +1,32 @@ +package io.oath + +import io.oath.OathManager.JManager +import io.oath.config._ +import io.oath.macros.OathEnum + +import scala.util.chaining.scalaUtilChainingOps + +trait OathManager[A] { + def as[S <: A](tokenType: S): JManager[S] +} + +object OathManager { + // Type aliases with extra information, useful to determine the token type. + type JManager[_] = JwtManager + + private final class JavaJwtOathManager[A](mapping: Map[A, JwtManager]) extends OathManager[A] { + def as[S <: A](tokenType: S): JManager[S] = mapping(tokenType) + } + + def none[E: OathEnum]: OathManager[E] = + summon[OathEnum[E]].values.map { case (tokenType, _) => + tokenType -> JwtManager(JwtManagerConfig.none()) + }.pipe(mapping => new JavaJwtOathManager(mapping)) + + def createOrFail[E: OathEnum]: OathManager[E] = + summon[OathEnum[E]].values.map { case (tokenType, tokenConfig) => + tokenType -> JwtManagerConfig + .loadOrThrowOath(tokenConfig) + .pipe(JwtManager(_)) + }.pipe(mapping => new JavaJwtOathManager(mapping)) +} diff --git a/oath/core/src/main/scala/io/oath/OathVerifier.scala b/oath/core/src/main/scala/io/oath/OathVerifier.scala new file mode 100644 index 0000000..84194fd --- /dev/null +++ b/oath/core/src/main/scala/io/oath/OathVerifier.scala @@ -0,0 +1,33 @@ +package io.oath + +import io.oath.OathVerifier.JVerifier +import io.oath.config._ +import io.oath.macros.OathEnum + +import scala.util.chaining.scalaUtilChainingOps + +trait OathVerifier[A] { + def as[S <: A](tokenType: S): JVerifier[S] + +} + +object OathVerifier { + // Type aliases with extra information, useful to determine the token type. + type JVerifier[_] = JwtVerifier + + private final class JavaJwtOathVerifier[A](mapping: Map[A, JwtVerifier]) extends OathVerifier[A] { + def as[S <: A](tokenType: S): JVerifier[S] = mapping(tokenType) + } + + def none[E: OathEnum]: OathVerifier[E] = + summon[OathEnum[E]].values.map { case (tokenType, _) => + tokenType -> JwtVerifier(JwtVerifierConfig.none()) + }.pipe(mapping => new JavaJwtOathVerifier(mapping)) + + def createOrFail[E: OathEnum]: OathVerifier[E] = + summon[OathEnum[E]].values.map { case (tokenType, tokenConfig) => + tokenType -> JwtVerifierConfig + .loadOrThrowOath(tokenConfig) + .pipe(JwtVerifier(_)) + }.pipe(mapping => new JavaJwtOathVerifier(mapping)) +} diff --git a/modules/oath-core/src/main/scala/io/oath/RegisteredClaims.scala b/oath/core/src/main/scala/io/oath/RegisteredClaims.scala similarity index 84% rename from modules/oath-core/src/main/scala/io/oath/RegisteredClaims.scala rename to oath/core/src/main/scala/io/oath/RegisteredClaims.scala index 2574bda..6781d54 100644 --- a/modules/oath-core/src/main/scala/io/oath/RegisteredClaims.scala +++ b/oath/core/src/main/scala/io/oath/RegisteredClaims.scala @@ -2,7 +2,7 @@ package io.oath import java.time.Instant -case class RegisteredClaims( +final case class RegisteredClaims( iss: Option[String] = None, sub: Option[String] = None, aud: Seq[String] = Seq.empty, @@ -12,5 +12,6 @@ case class RegisteredClaims( jti: Option[String] = None, ) -object RegisteredClaims: +object RegisteredClaims { def empty: RegisteredClaims = RegisteredClaims() +} diff --git a/modules/oath-core/src/main/scala/io/oath/config/AlgorithmLoader.scala b/oath/core/src/main/scala/io/oath/config/AlgorithmLoader.scala similarity index 95% rename from modules/oath-core/src/main/scala/io/oath/config/AlgorithmLoader.scala rename to oath/core/src/main/scala/io/oath/config/AlgorithmLoader.scala index 2eff4ce..998686a 100644 --- a/modules/oath-core/src/main/scala/io/oath/config/AlgorithmLoader.scala +++ b/oath/core/src/main/scala/io/oath/config/AlgorithmLoader.scala @@ -5,13 +5,13 @@ import com.typesafe.config.Config import org.bouncycastle.util.io.pem.PemReader import java.io.{File, FileReader} -import java.security.interfaces.* +import java.security.interfaces._ import java.security.spec.{PKCS8EncodedKeySpec, X509EncodedKeySpec} import java.security.{KeyFactory, PrivateKey, PublicKey} import scala.util.Using -import scala.util.chaining.* +import scala.util.chaining._ -object AlgorithmLoader: +object AlgorithmLoader { private val SecretKeyConfigValue = "secret-key" private val PrivateKeyPemPathConfigValue = "private-key-pem-path" private val PublicKeyPemPathConfigValue = "public-key-pem-path" @@ -28,31 +28,33 @@ object AlgorithmLoader: algorithmScoped: Config, forIssuing: Boolean, ): (Option[RSAPrivateKey], Option[RSAPublicKey]) = - if forIssuing then + if (forIssuing) { val privateKey: RSAPrivateKey = loadPrivateKey(algorithmScoped, RSAKeyFactory) .map(_.asInstanceOf[RSAPrivateKey]) .fold(error => throw new IllegalArgumentException(s"Fail to load RSA Private key pem file: $error"), identity) (Some(privateKey), None) - else + } else { val publicKey: RSAPublicKey = loadPublicKey(algorithmScoped, RSAKeyFactory) .map(_.asInstanceOf[RSAPublicKey]) .fold(error => throw new IllegalArgumentException(s"Fail to load RSA Public key pem file: $error"), identity) (None, Some(publicKey)) + } private def loadECKeyOrThrow( algorithmScoped: Config, forIssuing: Boolean, ): (Option[ECPrivateKey], Option[ECPublicKey]) = - if forIssuing then + if (forIssuing) { val privateKey: ECPrivateKey = loadPrivateKey(algorithmScoped, ECKeyFactory) .map(_.asInstanceOf[ECPrivateKey]) .fold(error => throw new IllegalArgumentException(s"Failed to load EC Private key pem file: $error"), identity) (Some(privateKey), None) - else + } else { val publicKey: ECPublicKey = loadPublicKey(algorithmScoped, ECKeyFactory) .map(_.asInstanceOf[ECPublicKey]) .fold(error => throw new IllegalArgumentException(s"Failed to load EC Public key pem file: $error"), identity) (None, Some(publicKey)) + } private def loadPublicKey(algorithmScoped: Config, keyFactory: KeyFactory): Either[String, PublicKey] = algorithmScoped @@ -82,7 +84,7 @@ object AlgorithmLoader: private[oath] def loadOrThrow(algorithmScoped: Config, isIssuer: Boolean): Algorithm = val algorithm = algorithmScoped.getString("name") - algorithm.trim.toUpperCase match + algorithm.trim.toUpperCase match { case "HS256" => loadSecretKeyOrThrow(algorithmScoped).pipe(Algorithm.HMAC256) case "HS384" => @@ -110,3 +112,5 @@ object AlgorithmLoader: case "NONE" => Algorithm.none() case _ => throw new IllegalArgumentException(s"Unsupported signature algorithm: $algorithm") + } +} diff --git a/modules/oath-core/src/main/scala/io/oath/config/JwtIssuerConfig.scala b/oath/core/src/main/scala/io/oath/config/JwtIssuerConfig.scala similarity index 79% rename from modules/oath-core/src/main/scala/io/oath/config/JwtIssuerConfig.scala rename to oath/core/src/main/scala/io/oath/config/JwtIssuerConfig.scala index ff44141..0467f6f 100644 --- a/modules/oath-core/src/main/scala/io/oath/config/JwtIssuerConfig.scala +++ b/oath/core/src/main/scala/io/oath/config/JwtIssuerConfig.scala @@ -6,10 +6,14 @@ import io.oath.config.JwtIssuerConfig.RegisteredConfig import scala.concurrent.duration.FiniteDuration -case class JwtIssuerConfig(algorithm: Algorithm, encrypt: Option[EncryptConfig], registered: RegisteredConfig) +final case class JwtIssuerConfig(algorithm: Algorithm, registered: RegisteredConfig) -object JwtIssuerConfig: - case class RegisteredConfig( +object JwtIssuerConfig { + private val IssuerConfigLocation = "issuer" + private val AlgorithmConfigLocation = "algorithm" + private val RegisteredConfigLocation = "registered" + + final case class RegisteredConfig( issuerClaim: Option[String] = None, subjectClaim: Option[String] = None, audienceClaims: Seq[String] = Seq.empty, @@ -19,11 +23,6 @@ object JwtIssuerConfig: notBeforeOffset: Option[FiniteDuration] = None, ) - private val IssuerConfigLocation = "issuer" - private val AlgorithmConfigLocation = "algorithm" - private val EncryptConfigLocation = "encrypt" - private val RegisteredConfigLocation = "registered" - private def loadOrThrowRegisteredConfig(registeredScoped: Config): RegisteredConfig = val issuerClaim = registeredScoped.getMaybeNonEmptyString("issuer-claim") val subjectClaim = registeredScoped.getMaybeNonEmptyString("subject-claim") @@ -43,20 +42,18 @@ object JwtIssuerConfig: notBeforeOffset, ) - def none(): JwtIssuerConfig = JwtIssuerConfig(Algorithm.none(), None, RegisteredConfig()) + def none(): JwtIssuerConfig = JwtIssuerConfig(Algorithm.none(), RegisteredConfig()) def loadOrThrow(config: Config): JwtIssuerConfig = (for algorithmScoped <- config.getMaybeConfig(AlgorithmConfigLocation) - algorithmConfig = AlgorithmLoader.loadOrThrow(algorithmScoped, isIssuer = true) - maybeEncryptionScoped = config.getMaybeConfig(EncryptConfigLocation) - maybeEncryptConfig = maybeEncryptionScoped.map(EncryptConfig.loadOrThrow) + algorithmConfig = AlgorithmLoader.loadOrThrow(algorithmScoped, isIssuer = true) maybeRegisteredConfig = for issuerScoped <- config.getMaybeConfig(IssuerConfigLocation) registeredScoped <- issuerScoped.getMaybeConfig(RegisteredConfigLocation) yield loadOrThrowRegisteredConfig(registeredScoped) - yield JwtIssuerConfig(algorithmConfig, maybeEncryptConfig, maybeRegisteredConfig.getOrElse(RegisteredConfig()))) + yield JwtIssuerConfig(algorithmConfig, maybeRegisteredConfig.getOrElse(RegisteredConfig()))) .getOrElse(none()) def loadOrThrow(location: String): JwtIssuerConfig = @@ -66,4 +63,4 @@ object JwtIssuerConfig: private[oath] def loadOrThrowOath(location: String): JwtIssuerConfig = JwtIssuerConfig.loadOrThrow(rootConfig.getConfig(location)) -end JwtIssuerConfig +} diff --git a/modules/oath-core/src/main/scala/io/oath/config/JwtManagerConfig.scala b/oath/core/src/main/scala/io/oath/config/JwtManagerConfig.scala similarity index 84% rename from modules/oath-core/src/main/scala/io/oath/config/JwtManagerConfig.scala rename to oath/core/src/main/scala/io/oath/config/JwtManagerConfig.scala index 11dfcb2..7b3fffd 100644 --- a/modules/oath-core/src/main/scala/io/oath/config/JwtManagerConfig.scala +++ b/oath/core/src/main/scala/io/oath/config/JwtManagerConfig.scala @@ -4,9 +4,9 @@ import com.typesafe.config.{Config, ConfigFactory} import scala.util.chaining.scalaUtilChainingOps -case class JwtManagerConfig(issuer: JwtIssuerConfig, verifier: JwtVerifierConfig) +final case class JwtManagerConfig(issuer: JwtIssuerConfig, verifier: JwtVerifierConfig) -object JwtManagerConfig: +object JwtManagerConfig { private[oath] def loadOrThrowOath(location: String): JwtManagerConfig = JwtManagerConfig.loadOrThrow(rootConfig.getConfig(location)) @@ -17,3 +17,4 @@ object JwtManagerConfig: def loadOrThrow(config: Config): JwtManagerConfig = JwtManagerConfig(JwtIssuerConfig.loadOrThrow(config), JwtVerifierConfig.loadOrThrow(config)) +} diff --git a/modules/oath-core/src/main/scala/io/oath/config/JwtVerifierConfig.scala b/oath/core/src/main/scala/io/oath/config/JwtVerifierConfig.scala similarity index 80% rename from modules/oath-core/src/main/scala/io/oath/config/JwtVerifierConfig.scala rename to oath/core/src/main/scala/io/oath/config/JwtVerifierConfig.scala index 0623fa7..b151991 100644 --- a/modules/oath-core/src/main/scala/io/oath/config/JwtVerifierConfig.scala +++ b/oath/core/src/main/scala/io/oath/config/JwtVerifierConfig.scala @@ -2,38 +2,35 @@ package io.oath.config import com.auth0.jwt.algorithms.Algorithm import com.typesafe.config.{Config, ConfigFactory} -import io.oath.config.EncryptConfig.* import io.oath.config.JwtVerifierConfig._ import scala.concurrent.duration.FiniteDuration -case class JwtVerifierConfig( +final case class JwtVerifierConfig( algorithm: Algorithm, - encrypt: Option[EncryptConfig], providedWith: ProvidedWithConfig, leewayWindow: LeewayWindowConfig, ) -object JwtVerifierConfig: - case class ProvidedWithConfig( +object JwtVerifierConfig { + private val VerifierConfigLocation = "verifier" + private val AlgorithmConfigLocation = "algorithm" + private val ProvidedWithConfigLocation = "provided-with" + private val LeewayWindowConfigLocation = "leeway-window" + + final case class ProvidedWithConfig( issuerClaim: Option[String] = None, subjectClaim: Option[String] = None, audienceClaims: Seq[String] = Seq.empty, ) - case class LeewayWindowConfig( + final case class LeewayWindowConfig( leeway: Option[FiniteDuration] = None, issuedAt: Option[FiniteDuration] = None, expiresAt: Option[FiniteDuration] = None, notBefore: Option[FiniteDuration] = None, ) - private val VerifierConfigLocation = "verifier" - private val AlgorithmConfigLocation = "algorithm" - private val EncryptConfigLocation = "encrypt" - private val ProvidedWithConfigLocation = "provided-with" - private val LeewayWindowConfigLocation = "leeway-window" - private def loadOrdThrowProvidedWithConfig(providedWithScoped: Config): ProvidedWithConfig = val issuerClaim = providedWithScoped.getMaybeNonEmptyString("issuer-claim") val subjectClaim = providedWithScoped.getMaybeNonEmptyString("subject-claim") @@ -50,28 +47,25 @@ object JwtVerifierConfig: private[oath] def loadOrThrowOath(location: String): JwtVerifierConfig = JwtVerifierConfig.loadOrThrow(rootConfig.getConfig(location)) - def none(): JwtVerifierConfig = JwtVerifierConfig(Algorithm.none(), None, ProvidedWithConfig(), LeewayWindowConfig()) + def none(): JwtVerifierConfig = JwtVerifierConfig(Algorithm.none(), ProvidedWithConfig(), LeewayWindowConfig()) def loadOrThrow(config: Config): JwtVerifierConfig = - (for + (for { algorithmScoped <- config.getMaybeConfig(AlgorithmConfigLocation) algorithmConfig = AlgorithmLoader.loadOrThrow(algorithmScoped, isIssuer = false) - maybeEncryptionScoped = config.getMaybeConfig(EncryptConfigLocation) - maybeEncryptConfig = maybeEncryptionScoped.map(EncryptConfig.loadOrThrow) maybeVerificationScoped = config.getMaybeConfig(VerifierConfigLocation) maybeProvidedWithConfig = - for + for { verificationScoped <- maybeVerificationScoped providedWithScoped <- verificationScoped.getMaybeConfig(ProvidedWithConfigLocation) - yield loadOrdThrowProvidedWithConfig(providedWithScoped) + } yield loadOrdThrowProvidedWithConfig(providedWithScoped) maybeLeewayWindowConfig = - for + for { verificationScoped <- maybeVerificationScoped leewayWindowScoped <- verificationScoped.getMaybeConfig(LeewayWindowConfigLocation) - yield loadOrThrowLeewayWindowConfig(leewayWindowScoped) - yield JwtVerifierConfig( + } yield loadOrThrowLeewayWindowConfig(leewayWindowScoped) + } yield JwtVerifierConfig( algorithmConfig, - maybeEncryptConfig, maybeProvidedWithConfig.getOrElse(ProvidedWithConfig()), maybeLeewayWindowConfig.getOrElse(LeewayWindowConfig()), )).getOrElse(none()) @@ -79,5 +73,4 @@ object JwtVerifierConfig: def loadOrThrow(location: String): JwtVerifierConfig = val configLocation = ConfigFactory.load().getConfig(location) loadOrThrow(configLocation) - -end JwtVerifierConfig +} diff --git a/oath/core/src/main/scala/io/oath/config/package.scala b/oath/core/src/main/scala/io/oath/config/package.scala new file mode 100644 index 0000000..20b37c5 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/config/package.scala @@ -0,0 +1,55 @@ +package io.oath.config + +import com.typesafe.config.{Config, ConfigException, ConfigFactory} + +import scala.concurrent.duration.FiniteDuration +import scala.jdk.CollectionConverters._ +import scala.jdk.DurationConverters._ +import scala.util.chaining._ +import scala.util.control.Exception.allCatch + +private[config] val OathLocation = "oath" +private[config] val rootConfig = ConfigFactory.load().getConfig(OathLocation) + +extension (config: Config) { + private def ifMissingDefault[T](default: T): PartialFunction[Throwable, T] = { case _: ConfigException.Missing => + default + } + + private[config] def getMaybeNonEmptyString(path: String): Option[String] = + allCatch + .withTry(Some(config.getString(path))) + .recover(ifMissingDefault(Option.empty)) + .toOption + .flatten + .tap(value => if (value.exists(_.isEmpty)) throw new IllegalArgumentException(s"$path empty string not allowed.")) + + private[config] def getMaybeFiniteDuration(path: String): Option[FiniteDuration] = + allCatch + .withTry(Some(config.getDuration(path).toScala)) + .recover(ifMissingDefault(None)) + .get + + private[config] def getBooleanDefaultFalse(path: String): Boolean = + allCatch + .withTry(config.getBoolean(path)) + .recover(ifMissingDefault(false)) + .get + + private[config] def getSeqNonEmptyString(path: String): Seq[String] = + allCatch + .withTry(config.getStringList(path).asScala.toSeq) + .recover(ifMissingDefault(Seq.empty)) + .get + .tap(value => + if value.exists(_.isEmpty) then + throw new IllegalArgumentException(s"$path empty string in the list not allowed.") + ) + + private[config] def getMaybeConfig(path: String): Option[Config] = + allCatch + .withTry(Some(config.getConfig(path))) + .recover(ifMissingDefault(Option.empty)) + .toOption + .flatten +} diff --git a/modules/oath-core/src/main/scala/io/oath/json/ClaimsCodec.scala b/oath/core/src/main/scala/io/oath/json/ClaimsCodec.scala similarity index 100% rename from modules/oath-core/src/main/scala/io/oath/json/ClaimsCodec.scala rename to oath/core/src/main/scala/io/oath/json/ClaimsCodec.scala diff --git a/modules/oath-core/src/main/scala/io/oath/json/ClaimsDecoder.scala b/oath/core/src/main/scala/io/oath/json/ClaimsDecoder.scala similarity index 100% rename from modules/oath-core/src/main/scala/io/oath/json/ClaimsDecoder.scala rename to oath/core/src/main/scala/io/oath/json/ClaimsDecoder.scala diff --git a/modules/oath-core/src/main/scala/io/oath/json/ClaimsEncoder.scala b/oath/core/src/main/scala/io/oath/json/ClaimsEncoder.scala similarity index 100% rename from modules/oath-core/src/main/scala/io/oath/json/ClaimsEncoder.scala rename to oath/core/src/main/scala/io/oath/json/ClaimsEncoder.scala diff --git a/oath/core/src/main/scala/io/oath/package.scala b/oath/core/src/main/scala/io/oath/package.scala new file mode 100644 index 0000000..417650b --- /dev/null +++ b/oath/core/src/main/scala/io/oath/package.scala @@ -0,0 +1,18 @@ +package io.oath + +import com.auth0.jwt.interfaces.DecodedJWT + +import java.time.Instant +import scala.jdk.CollectionConverters.CollectionHasAsScala + +// TODO: Move to file +extension (decodedJWT: DecodedJWT) { + def getOptionIssuer: Option[String] = Option(decodedJWT.getIssuer) + def getOptionSubject: Option[String] = Option(decodedJWT.getSubject) + def getSeqAudience: Seq[String] = + Option(decodedJWT.getAudience).map(_.asScala).toSeq.flatten + def getOptionExpiresAt: Option[Instant] = Option(decodedJWT.getExpiresAt).map(_.toInstant) + def getOptionNotBefore: Option[Instant] = Option(decodedJWT.getNotBefore).map(_.toInstant) + def getOptionIssueAt: Option[Instant] = Option(decodedJWT.getIssuedAt).map(_.toInstant) + def getOptionJwtID: Option[String] = Option(decodedJWT.getId) +} diff --git a/oath/core/src/main/scala/io/oath/syntax/JwtClaimsOps.scala b/oath/core/src/main/scala/io/oath/syntax/JwtClaimsOps.scala new file mode 100644 index 0000000..9563837 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/syntax/JwtClaimsOps.scala @@ -0,0 +1,13 @@ +package io.oath.syntax + +import io.oath.JwtClaims + +trait JwtClaimsOps { + extension [A](value: A) + def toClaimsP: JwtClaims.ClaimsP[A] = JwtClaims.ClaimsP(value) + def toClaimsH: JwtClaims.ClaimsH[A] = JwtClaims.ClaimsH(value) + + extension [A, B](value: (A, B)) def toClaimsHP: JwtClaims.ClaimsHP[A, B] = JwtClaims.ClaimsHP(value._1, value._2) +} + +object JwtClaimsOps extends JwtClaimsOps diff --git a/oath/core/src/main/scala/io/oath/syntax/JwtTokenOps.scala b/oath/core/src/main/scala/io/oath/syntax/JwtTokenOps.scala new file mode 100644 index 0000000..b77f857 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/syntax/JwtTokenOps.scala @@ -0,0 +1,14 @@ +package io.oath.syntax + +import io.oath.JwtToken + +trait JwtTokenOps { + extension (value: String) { + def toToken: JwtToken.Token = JwtToken.Token(value) + def toTokenH: JwtToken.TokenH = JwtToken.TokenH(value) + def toTokenP: JwtToken.TokenP = JwtToken.TokenP(value) + def toTokenHP: JwtToken.TokenHP = JwtToken.TokenHP(value) + } +} + +object JwtTokenOps extends JwtTokenOps diff --git a/oath/core/src/main/scala/io/oath/syntax/RegisteredClaimsOps.scala b/oath/core/src/main/scala/io/oath/syntax/RegisteredClaimsOps.scala new file mode 100644 index 0000000..19e6d6d --- /dev/null +++ b/oath/core/src/main/scala/io/oath/syntax/RegisteredClaimsOps.scala @@ -0,0 +1,9 @@ +package io.oath.syntax + +import io.oath.{JwtClaims, RegisteredClaims} + +trait RegisteredClaimsOps { + extension (value: RegisteredClaims) def toClaims: JwtClaims.Claims = JwtClaims.Claims(value) +} + +object RegisteredClaimsOps extends RegisteredClaimsOps diff --git a/oath/core/src/main/scala/io/oath/syntax/all.scala b/oath/core/src/main/scala/io/oath/syntax/all.scala new file mode 100644 index 0000000..a293ccd --- /dev/null +++ b/oath/core/src/main/scala/io/oath/syntax/all.scala @@ -0,0 +1,3 @@ +package io.oath.syntax + +object all extends JwtClaimsOps, JwtTokenOps, RegisteredClaimsOps diff --git a/oath/core/src/main/scala/io/oath/utils/Base64.scala b/oath/core/src/main/scala/io/oath/utils/Base64.scala new file mode 100644 index 0000000..3a77047 --- /dev/null +++ b/oath/core/src/main/scala/io/oath/utils/Base64.scala @@ -0,0 +1,17 @@ +package io.oath.utils + +import io.oath.JwtVerifyError + +import java.nio.charset.StandardCharsets +import java.util.{Base64 => JBase64} +import scala.util.control.Exception.allCatch + +private[oath] object Base64 { + + def decodeToken(token: String): Either[JwtVerifyError.DecodingError, String] = + allCatch + .withTry(new String(JBase64.getUrlDecoder.decode(token), StandardCharsets.UTF_8)) + .toEither + .left + .map(JwtVerifyError.DecodingError("Base64 decode failure.", _)) +} diff --git a/modules/oath-core/src/test/resources/algorithm-es256.conf b/oath/core/src/test/resources/algorithm-es256.conf similarity index 100% rename from modules/oath-core/src/test/resources/algorithm-es256.conf rename to oath/core/src/test/resources/algorithm-es256.conf diff --git a/modules/oath-core/src/test/resources/algorithm-es384.conf b/oath/core/src/test/resources/algorithm-es384.conf similarity index 100% rename from modules/oath-core/src/test/resources/algorithm-es384.conf rename to oath/core/src/test/resources/algorithm-es384.conf diff --git a/modules/oath-core/src/test/resources/algorithm-es512.conf b/oath/core/src/test/resources/algorithm-es512.conf similarity index 100% rename from modules/oath-core/src/test/resources/algorithm-es512.conf rename to oath/core/src/test/resources/algorithm-es512.conf diff --git a/modules/oath-core/src/test/resources/algorithm-hsxxx.conf b/oath/core/src/test/resources/algorithm-hsxxx.conf similarity index 100% rename from modules/oath-core/src/test/resources/algorithm-hsxxx.conf rename to oath/core/src/test/resources/algorithm-hsxxx.conf diff --git a/modules/oath-core/src/test/resources/algorithm-none.conf b/oath/core/src/test/resources/algorithm-none.conf similarity index 100% rename from modules/oath-core/src/test/resources/algorithm-none.conf rename to oath/core/src/test/resources/algorithm-none.conf diff --git a/modules/oath-core/src/test/resources/algorithm-rsxxx.conf b/oath/core/src/test/resources/algorithm-rsxxx.conf similarity index 100% rename from modules/oath-core/src/test/resources/algorithm-rsxxx.conf rename to oath/core/src/test/resources/algorithm-rsxxx.conf diff --git a/modules/oath-core/src/test/resources/algorithm-unsupported.conf b/oath/core/src/test/resources/algorithm-unsupported.conf similarity index 100% rename from modules/oath-core/src/test/resources/algorithm-unsupported.conf rename to oath/core/src/test/resources/algorithm-unsupported.conf diff --git a/modules/oath-core/src/test/resources/issuer.conf b/oath/core/src/test/resources/issuer.conf similarity index 76% rename from modules/oath-core/src/test/resources/issuer.conf rename to oath/core/src/test/resources/issuer.conf index f8e2743..be3138f 100644 --- a/modules/oath-core/src/test/resources/issuer.conf +++ b/oath/core/src/test/resources/issuer.conf @@ -23,13 +23,10 @@ token { } } -token-with-encryption { +without-private-key-token { algorithm { name = "RS256" - private-key-pem-path = "src/test/secrets/rsa-private.pem" - } - encrypt { - secret = "password" + public-key-pem-path = "src/test/secrets/rsa-private.pem" } issuer { registered { @@ -44,44 +41,38 @@ token-with-encryption { } } -without-private-key-token { +invalid-token-empty-string { algorithm { - name = "RS256" - public-key-pem-path = "src/test/secrets/rsa-private.pem" + name = "HS256" + secret-key = "secret" } issuer { registered { - issuer-claim = "issuer" - subject-claim = "subject" - audience-claims = ["aud1", "aud2"] - include-issued-at-claim = true - include-jwt-id-claim = false - expires-at-offset = 1 day - not-before-offset = 1 minute + issuer-claim = "" } } } -invalid-token-empty-string { +invalid-token-wrong-type { algorithm { name = "HS256" secret-key = "secret" } issuer { registered { - issuer-claim = "" + not-before-offset = "" } } } -invalid-token-wrong-type { +invalid-seq-empty-string { algorithm { name = "HS256" secret-key = "secret" } issuer { registered { - not-before-offset = "" + audience-claims = ["aud1", ""] } } } diff --git a/modules/oath-core/src/test/resources/manager.conf b/oath/core/src/test/resources/manager.conf similarity index 100% rename from modules/oath-core/src/test/resources/manager.conf rename to oath/core/src/test/resources/manager.conf diff --git a/modules/oath-core/src/test/resources/reference.conf b/oath/core/src/test/resources/reference.conf similarity index 99% rename from modules/oath-core/src/test/resources/reference.conf rename to oath/core/src/test/resources/reference.conf index 5589b75..0019270 100644 --- a/modules/oath-core/src/test/resources/reference.conf +++ b/oath/core/src/test/resources/reference.conf @@ -1,3 +1,4 @@ + token { algorithm { name = "RS256" diff --git a/modules/oath-core/src/test/resources/verifier.conf b/oath/core/src/test/resources/verifier.conf similarity index 75% rename from modules/oath-core/src/test/resources/verifier.conf rename to oath/core/src/test/resources/verifier.conf index 5a2a76f..b87fc6f 100644 --- a/modules/oath-core/src/test/resources/verifier.conf +++ b/oath/core/src/test/resources/verifier.conf @@ -25,14 +25,12 @@ token { } } -token-with-encryption { +without-public-key-token { algorithm { name = "RS256" - public-key-pem-path = "src/test/secrets/rsa-public.pem" - } - encrypt { - secret = "password" + private-key-pem-path = "src/test/secrets/rsa-public.pem" } + verifier { provided-with { issuer-claim = "issuer" @@ -48,49 +46,38 @@ token-with-encryption { } } -without-public-key-token { +invalid-token-empty-string { algorithm { - name = "RS256" - private-key-pem-path = "src/test/secrets/rsa-public.pem" - } - encrypt { - key = "password" + name = "HS256" + secret-key = "secret" } verifier { provided-with { - issuer-claim = "issuer" - subject-claim = "subject" - audience-claims = ["aud1", "aud2"] - } - leeway-window { - leeway = 1 minute - issued-at = 4 minutes - expires-at = 3 minutes - not-before = 2 minutes + issuer-claim = "" } } } -invalid-token-empty-string { +invalid-token-wrong-type { algorithm { name = "HS256" secret-key = "secret" } verifier { provided-with { - issuer-claim = "" + audience-claims = "" } } } -invalid-token-wrong-type { +invalid-seq-empty-string { algorithm { name = "HS256" secret-key = "secret" } verifier { provided-with { - audience-claims = "" + audience-claims = ["aud1", ""] } } } diff --git a/modules/oath-core/src/test/scala/io/oath/test/JwtIssuerSpec.scala b/oath/core/src/test/scala/io/oath/JwtIssuerSpec.scala similarity index 60% rename from modules/oath-core/src/test/scala/io/oath/test/JwtIssuerSpec.scala rename to oath/core/src/test/scala/io/oath/JwtIssuerSpec.scala index ce18dc7..0c27644 100644 --- a/modules/oath-core/src/test/scala/io/oath/test/JwtIssuerSpec.scala +++ b/oath/core/src/test/scala/io/oath/JwtIssuerSpec.scala @@ -1,32 +1,29 @@ -package io.oath.test +package io.oath import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm -import io.oath.* -import io.oath.config.* -import io.oath.syntax.* -import io.oath.test.NestedHeader.nestedHeaderDecoder -import io.oath.test.NestedPayload.nestedPayloadDecoder -import io.oath.testkit.* -import io.oath.utils.* - -import scala.concurrent.duration.DurationInt +import io.oath.config._ +import io.oath.syntax.all._ +import io.oath.testkit._ +import io.oath.utils._ + +import scala.concurrent.duration._ import scala.jdk.CollectionConverters.ListHasAsScala import scala.util.Try -import scala.util.chaining.scalaUtilChainingOps +import scala.util.chaining._ -class JwtIssuerSpec extends AnyWordSpecBase, PropertyBasedTesting, ClockHelper: +class JwtIssuerSpec extends WordSpecBase, PropertyBasedTesting, ClockHelper { val jwtVerifier = JWT .require(Algorithm.none()) .acceptLeeway(5) .build() - "JwtIssuer" should: - "issue jwt tokens" when: - "issue token with predefine configure claims" in forAll: (config: JwtIssuerConfig) => + "JwtIssuer" when { + "issueJwt Claims" should { + "successfully issue token with predefine configure claims" in forAll { (config: JwtIssuerConfig) => val now = getInstantNowSeconds - val jwtIssuer = new JwtIssuer(config.copy(encrypt = None), getFixedClock(now)) + val jwtIssuer = JwtIssuer(config, getFixedClock(now)) val jwtClaims = jwtIssuer.issueJwt().value val decodedJWT = jwtVerifier.verify(jwtClaims.token) @@ -52,11 +49,12 @@ class JwtIssuerSpec extends AnyWordSpecBase, PropertyBasedTesting, ClockHelper: Try(decodedJWT.getNotBefore.toInstant).toOption shouldBe config.registered.notBeforeOffset.map(offset => now.plusSeconds(offset.toSeconds) ) + } - "issue token with predefine configure claims and ad-hoc registered claims" in forAll: + "successfully issue token with predefine configure claims and ad-hoc registered claims" in forAll { (registeredClaims: RegisteredClaims, config: JwtIssuerConfig) => val now = getInstantNowSeconds - val jwtIssuer = new JwtIssuer(config, getFixedClock(now)) + val jwtIssuer = JwtIssuer(config, getFixedClock(now)) val jwtClaims = jwtIssuer.issueJwt(registeredClaims.toClaims).value val expectedIssuer = registeredClaims.iss orElse config.registered.issuerClaim @@ -85,13 +83,14 @@ class JwtIssuerSpec extends AnyWordSpecBase, PropertyBasedTesting, ClockHelper: else if (config.registered.includeJwtIdClaim) jwtClaims.claims.registered.jti should not be empty else jwtClaims.claims.registered.jti shouldBe empty + } - "issue token with only registered claims empty strings" in forAll: + "successfully issue token with only registered claims empty strings" in forAll { (registeredClaims: RegisteredClaims, config: JwtIssuerConfig) => val now = getInstantNowSeconds val adHocRegisteredClaims = registeredClaims.copy(iat = Some(now), exp = Some(now.plusSeconds(5.minutes.toSeconds)), nbf = Some(now)) - val jwtIssuer = new JwtIssuer(config.copy(encrypt = None), getFixedClock(now)) + val jwtIssuer = JwtIssuer(config, getFixedClock(now)) val jwtClaims = jwtIssuer.issueJwt(adHocRegisteredClaims.toClaims).value val decodedJWT = jwtVerifier.verify(jwtClaims.token) @@ -106,13 +105,14 @@ class JwtIssuerSpec extends AnyWordSpecBase, PropertyBasedTesting, ClockHelper: Option(decodedJWT.getId) shouldBe jwtClaims.claims.registered.jti Try(decodedJWT.getExpiresAt.toInstant).toOption shouldBe jwtClaims.claims.registered.exp Try(decodedJWT.getNotBefore.toInstant).toOption shouldBe jwtClaims.claims.registered.nbf + } - "issue token with only registered claims when decoded should have the same values with the return registered claims" in forAll: + "successfully issue token with only registered claims when decoded should have the same values with the return registered claims" in forAll { (registeredClaims: RegisteredClaims, config: JwtIssuerConfig) => val now = getInstantNowSeconds val adHocRegisteredClaims = registeredClaims.copy(iat = Some(now), exp = Some(now.plusSeconds(5.minutes.toSeconds)), nbf = Some(now)) - val jwtIssuer = new JwtIssuer(config.copy(encrypt = None), getFixedClock(now)) + val jwtIssuer = JwtIssuer(config, getFixedClock(now)) val jwtClaims = jwtIssuer.issueJwt(adHocRegisteredClaims.toClaims).value val decodedJWT = jwtVerifier.verify(jwtClaims.token) @@ -127,90 +127,76 @@ class JwtIssuerSpec extends AnyWordSpecBase, PropertyBasedTesting, ClockHelper: Option(decodedJWT.getId) shouldBe jwtClaims.claims.registered.jti Try(decodedJWT.getExpiresAt.toInstant).toOption shouldBe jwtClaims.claims.registered.exp Try(decodedJWT.getNotBefore.toInstant).toOption shouldBe jwtClaims.claims.registered.nbf + } - "issue token with only registered claims encrypted" in forAll: - (registeredClaims: RegisteredClaims, config: JwtIssuerConfig) => - whenever(config.encrypt.nonEmpty): - val clock = getFixedClock(getInstantNowSeconds) - val jwtIssuer = new JwtIssuer(config, clock) - val jwt = jwtIssuer.issueJwt(registeredClaims.toClaims).value + "fail with IllegalArgument when issue token algorithm is set to null" in forAll { (config: JwtIssuerConfig) => + val jwtIssuer = JwtIssuer(config.copy(algorithm = null)) + val jwt = jwtIssuer.issueJwt() + + val signError = jwt.left.value + .asInstanceOf[JwtIssueError.SignError] - jwt.token should fullyMatch regex """[0123456789ABCDEF]+""" - jwt.token.length % 16 shouldBe 0 + signError.message shouldBe "Signing token failed" + signError.getCause shouldBe a[IllegalArgumentException] + signError.getCause.getMessage shouldBe "The Algorithm cannot be null." + } + } - "issue token with header claims" in forAll: (config: JwtIssuerConfig, header: NestedHeader) => - val jwtIssuer = new JwtIssuer(config.copy(encrypt = None)) + "issueJwt ClaimsH" should { + "successfully issue token with header claims" in forAll { (config: JwtIssuerConfig, header: NestedHeader) => + val jwtIssuer = JwtIssuer(config) val jwt = jwtIssuer.issueJwt(header.toClaimsH).value val result = jwtVerifier .verify(jwt.token) .pipe(_.getHeader) - .pipe(base64DecodeToken) + .pipe(Base64.decodeToken) .pipe(_.value) - .pipe(nestedHeaderDecoder.decode) + .pipe(NestedHeader.claimsDecoder.decode) .value result shouldBe header + } + } - "issue token with header claims encrypted" in forAll: (config: JwtIssuerConfig, header: NestedHeader) => - whenever(config.encrypt.nonEmpty): - val jwtIssuer = new JwtIssuer(config) - val jwt = jwtIssuer.issueJwt(header.toClaimsH).value - - jwt.token should fullyMatch regex """[0123456789ABCDEF]+""" - jwt.token.length % 16 shouldBe 0 - - "issue token with payload claims" in forAll: (config: JwtIssuerConfig, payload: NestedPayload) => - val jwtIssuer = new JwtIssuer(config.copy(encrypt = None)) + "issueJwt ClaimsP" should { + "successfully issue token with payload claims" in forAll { (config: JwtIssuerConfig, payload: NestedPayload) => + val jwtIssuer = JwtIssuer(config) val jwt = jwtIssuer.issueJwt(payload.toClaimsP).value val result = jwtVerifier .verify(jwt.token) .pipe(_.getPayload) - .pipe(base64DecodeToken) + .pipe(Base64.decodeToken) .pipe(_.value) - .pipe(nestedPayloadDecoder.decode) + .pipe(NestedPayload.claimsDecoder.decode) .value result shouldBe payload + } + } - "issue token with payload claims encrypted" in forAll: (config: JwtIssuerConfig, payload: NestedPayload) => - whenever(config.encrypt.nonEmpty): - val jwtIssuer = new JwtIssuer(config) - val jwt = jwtIssuer.issueJwt(payload.toClaimsP).value - - jwt.token should fullyMatch regex """[0123456789ABCDEF]+""" - jwt.token.length % 16 shouldBe 0 - - "issue token with header & payload claims" in forAll: + "issueJwt ClaimsHP" should { + "successfully issue token with header & payload claims" in forAll { (config: JwtIssuerConfig, header: NestedHeader, payload: NestedPayload) => - val jwtIssuer = new JwtIssuer(config.copy(encrypt = None)) + val jwtIssuer = JwtIssuer(config) val jwt = jwtIssuer.issueJwt((header, payload).toClaimsHP).value val (headerResult, payloadResult) = jwtVerifier .verify(jwt.token) .pipe(decodedJwt => - base64DecodeToken(decodedJwt.getHeader).value -> base64DecodeToken(decodedJwt.getPayload).value + Base64.decodeToken(decodedJwt.getHeader).value -> Base64.decodeToken(decodedJwt.getPayload).value ) .pipe { case (headerJson, payloadJson) => - (nestedHeaderDecoder.decode(headerJson).value, nestedPayloadDecoder.decode(payloadJson).value) + ( + NestedHeader.claimsDecoder.decode(headerJson).value, + NestedPayload.claimsDecoder.decode(payloadJson).value, + ) } headerResult shouldBe header payloadResult shouldBe payload - - "issue token with header & payload claims encrypted" in forAll: - (config: JwtIssuerConfig, header: NestedHeader, payload: NestedPayload) => - whenever(config.encrypt.nonEmpty): - val jwtIssuer = new JwtIssuer(config) - val jwt = jwtIssuer.issueJwt((header, payload).toClaimsHP).value - - jwt.token should fullyMatch regex """[0123456789ABCDEF]+""" - jwt.token.length % 16 shouldBe 0 - - "issue token should fail with IllegalArgument when algorithm is set to null" in forAll: - (config: JwtIssuerConfig) => - val jwtIssuer = new JwtIssuer(config.copy(algorithm = null)) - val jwt = jwtIssuer.issueJwt() - - jwt.left.value shouldBe JwtIssueError.IllegalArgument("The Algorithm cannot be null.") + } + } + } +} diff --git a/modules/oath-core/src/test/scala/io/oath/test/JwtManagerSpec.scala b/oath/core/src/test/scala/io/oath/JwtManagerSpec.scala similarity index 74% rename from modules/oath-core/src/test/scala/io/oath/test/JwtManagerSpec.scala rename to oath/core/src/test/scala/io/oath/JwtManagerSpec.scala index dc5f90f..3de90c4 100644 --- a/modules/oath-core/src/test/scala/io/oath/test/JwtManagerSpec.scala +++ b/oath/core/src/test/scala/io/oath/JwtManagerSpec.scala @@ -1,42 +1,47 @@ -package io.oath.test +package io.oath -import io.oath.* -import io.oath.config.* -import io.oath.syntax.* -import io.oath.testkit.* +import io.oath.config._ +import io.oath.syntax.all._ +import io.oath.testkit._ -class JwtManagerSpec extends AnyWordSpecBase, PropertyBasedTesting: +class JwtManagerSpec extends WordSpecBase, PropertyBasedTesting { - "JwtManager" should: - "be able to issue and verify jwt tokens without claims" in forAll: (config: JwtManagerConfig) => - val jwtManager = new JwtManager(config) + "JwtManager" should { + "be able to issue and verify jwt tokens without claims" in forAll { (config: JwtManagerConfig) => + val jwtManager = JwtManager(config) val jwt = jwtManager.issueJwt().value jwtManager.verifyJwt(jwt.token.toToken).value.registered shouldBe jwt.claims.registered + } - "be able to issue and verify jwt tokens with header claims" in forAll: + "be able to issue and verify jwt tokens with header claims" in forAll { (config: JwtManagerConfig, nestedHeader: NestedHeader) => - val jwtManager = new JwtManager(config) + val jwtManager = JwtManager(config) val claims = nestedHeader.toClaimsH val jwt = jwtManager.issueJwt(claims).value jwtManager.verifyJwt[NestedHeader](jwt.token.toTokenH).value shouldBe claims .copy(registered = jwt.claims.registered) + } - "be able to issue and verify jwt tokens with payload claims" in forAll: + "be able to issue and verify jwt tokens with payload claims" in forAll { (config: JwtManagerConfig, nestedPayload: NestedPayload) => - val jwtManager = new JwtManager(config) + val jwtManager = JwtManager(config) val claims = nestedPayload.toClaimsP val jwt = jwtManager.issueJwt(claims).value jwtManager.verifyJwt[NestedPayload](jwt.token.toTokenP).value shouldBe claims .copy(registered = jwt.claims.registered) + } - "be able to issue and verify jwt tokens with header & payload claims" in forAll: + "be able to issue and verify jwt tokens with header & payload claims" in forAll { (config: JwtManagerConfig, nestedHeader: NestedHeader, nestedPayload: NestedPayload) => - val jwtManager = new JwtManager(config) + val jwtManager = JwtManager(config) val claims = (nestedHeader, nestedPayload).toClaimsHP val jwt = jwtManager.issueJwt(claims).value jwtManager.verifyJwt[NestedHeader, NestedPayload](jwt.token.toTokenHP).value shouldBe claims .copy(registered = jwt.claims.registered) + } + } +} diff --git a/oath/core/src/test/scala/io/oath/JwtVerifierSpec.scala b/oath/core/src/test/scala/io/oath/JwtVerifierSpec.scala new file mode 100644 index 0000000..3062462 --- /dev/null +++ b/oath/core/src/test/scala/io/oath/JwtVerifierSpec.scala @@ -0,0 +1,333 @@ +package io.oath + +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.exceptions._ +import com.auth0.jwt.{JWT, JWTCreator} +import io.oath.NestedHeader.SimpleHeader +import io.oath.NestedPayload.SimplePayload +import io.oath.config.JwtVerifierConfig +import io.oath.config.JwtVerifierConfig._ +import io.oath.syntax.all._ +import io.oath.testkit._ + +import scala.util.chaining.scalaUtilChainingOps + +class JwtVerifierSpec extends WordSpecBase, PropertyBasedTesting, ClockHelper { + + val defaultConfig = + JwtVerifierConfig( + Algorithm.none(), + ProvidedWithConfig(None, None, Nil), + LeewayWindowConfig(None, None, None, None), + ) + + def setRegisteredClaims(builder: JWTCreator.Builder, config: JwtVerifierConfig): TestData = { + val now = getInstantNowSeconds + val leeway = config.leewayWindow.leeway.map(leeway => now.plusSeconds(leeway.toSeconds - 1)) + val expiresAt = config.leewayWindow.expiresAt.map(expiresAt => now.plusSeconds(expiresAt.toSeconds - 1)) + val notBefore = config.leewayWindow.notBefore.map(notBefore => now.plusSeconds(notBefore.toSeconds - 1)) + val issueAt = config.leewayWindow.issuedAt.map(issueAt => now.plusSeconds(issueAt.toSeconds - 1)) + + val registeredClaims = RegisteredClaims( + config.providedWith.issuerClaim, + config.providedWith.subjectClaim, + config.providedWith.audienceClaims, + expiresAt orElse leeway, + notBefore orElse leeway, + issueAt orElse leeway, + None, + ) + + val builderWithRegistered = builder + .tap(builder => registeredClaims.iss.map(str => builder.withIssuer(str))) + .tap(builder => registeredClaims.sub.map(str => builder.withSubject(str))) + .tap(builder => builder.withAudience(registeredClaims.aud*)) + .tap(builder => registeredClaims.exp.map(builder.withExpiresAt)) + .tap(builder => registeredClaims.nbf.map(builder.withNotBefore)) + .tap(builder => registeredClaims.iat.map(builder.withIssuedAt)) + + TestData(registeredClaims, builderWithRegistered) + } + + "JwtVerifier" when { + "verifyJwt" should { + "verify token with prerequisite configurations" in forAll { (config: JwtVerifierConfig) => + val jwtVerifier = JwtVerifier(config) + val testData = setRegisteredClaims(JWT.create(), config) + val token = testData.builder.sign(config.algorithm) + + val verified = jwtVerifier.verifyJwt(token.toToken).value + + verified.registered shouldBe testData.registeredClaims + } + + "fail to verify token with VerificationError when provided with claims are not meet criteria" in { + val config = defaultConfig.copy(providedWith = defaultConfig.providedWith.copy(issuerClaim = Some("issuer"))) + val jwtVerifier = JwtVerifier(config) + val token = JWT + .create() + .sign(config.algorithm) + + val verificationError = jwtVerifier + .verifyJwt(token.toToken) + .left + .value + .asInstanceOf[JwtVerifyError.VerificationError] + + verificationError.message shouldBe "JwtVerifier failed with verification error" + verificationError.underlying.value shouldBe a[JWTVerificationException] + verificationError.underlying.value.getMessage shouldBe "The Claim 'iss' is not present in the JWT." + } + + "fail to verify token with IllegalArgument when null algorithm is provided" in forAll { + (config: JwtVerifierConfig) => + val jwtVerifier = JwtVerifier(config.copy(algorithm = null)) + val token = JWT + .create() + .sign(config.algorithm) + + val verificationError = jwtVerifier + .verifyJwt(token.toToken) + .left + .value + .asInstanceOf[JwtVerifyError.VerificationError] + + verificationError.message shouldBe "JwtVerifier failed with verification error" + verificationError.underlying.value shouldBe a[IllegalArgumentException] + verificationError.underlying.value.getMessage shouldBe "The Algorithm cannot be null." + } + + "fail to verify token with AlgorithmMismatch when jwt header algorithm doesn't match with verify" in forAll { + (config: JwtVerifierConfig) => + val jwtVerifier = JwtVerifier(config.copy(algorithm = Algorithm.HMAC256("secret"))) + val token = JWT + .create() + .sign(config.algorithm) + + val verificationError = jwtVerifier + .verifyJwt(token.toToken) + .left + .value + .asInstanceOf[JwtVerifyError.VerificationError] + + verificationError.message shouldBe "JwtVerifier failed with verification error" + verificationError.underlying.value shouldBe a[AlgorithmMismatchException] + verificationError.underlying.value.getMessage shouldBe "The provided Algorithm doesn't match the one defined in the JWT's Header." + } + + "fail to verify token with SignatureVerificationError when secrets provided are wrong" in forAll { + (config: JwtVerifierConfig) => + val jwtVerifier = JwtVerifier(config.copy(algorithm = Algorithm.HMAC256("secret2"))) + val algorithm = Algorithm.HMAC256("secret1") + val token = JWT + .create() + .sign(algorithm) + + val verificationError = jwtVerifier + .verifyJwt(token.toToken) + .left + .value + .asInstanceOf[JwtVerifyError.VerificationError] + + verificationError.message shouldBe "JwtVerifier failed with verification error" + verificationError.underlying.value shouldBe a[SignatureVerificationException] + verificationError.underlying.value.getMessage shouldBe "The Token's Signature resulted invalid when verified using the Algorithm: HmacSHA256" + } + + "fail to verify token with TokenExpired when JWT expires" in { + val jwtVerifier = JwtVerifier(defaultConfig) + val expiresAt = getInstantNowSeconds.minusSeconds(1) + val token = JWT + .create() + .withExpiresAt(expiresAt) + .sign(defaultConfig.algorithm) + + val verificationError = jwtVerifier + .verifyJwt(token.toToken) + .left + .value + .asInstanceOf[JwtVerifyError.VerificationError] + + verificationError.message shouldBe "JwtVerifier failed with verification error" + verificationError.underlying.value shouldBe a[TokenExpiredException] + verificationError.underlying.value.getMessage shouldBe s"The Token has expired on $expiresAt." + } + + "fail to verify an empty string token" in { + val jwtVerifier = JwtVerifier(defaultConfig) + val token = "" + val verified = jwtVerifier.verifyJwt(token.toToken) + val verifiedH = jwtVerifier.verifyJwt[NestedHeader](token.toTokenH) + val verifiedP = jwtVerifier.verifyJwt[NestedPayload](token.toTokenP) + val verifiedHP = jwtVerifier.verifyJwt[NestedHeader, NestedPayload](token.toTokenHP) + + val verificationError = verified.left.value + .asInstanceOf[JwtVerifyError.VerificationError] + + verificationError.message shouldBe "JWTVerifier failed with an empty token." + verificationError.underlying shouldBe empty + + val verificationErrorH = verifiedH.left.value + .asInstanceOf[JwtVerifyError.VerificationError] + + verificationErrorH.message shouldBe "JWTVerifier failed with an empty token." + verificationErrorH.underlying shouldBe empty + + val verificationErrorP = verifiedP.left.value + .asInstanceOf[JwtVerifyError.VerificationError] + + verificationErrorP.message shouldBe "JWTVerifier failed with an empty token." + verificationErrorP.underlying shouldBe empty + + val verificationErrorHP = verifiedHP.left.value + .asInstanceOf[JwtVerifyError.VerificationError] + + verificationErrorHP.message shouldBe "JWTVerifier failed with an empty token." + verificationErrorHP.underlying shouldBe empty + } + } + + "verifyJwt TokenH" should { + "verify a token with header" in forAll { (nestedHeader: NestedHeader, config: JwtVerifierConfig) => + val jwtVerifier = JwtVerifier(config) + val testData = setRegisteredClaims(JWT.create(), config) + val token = testData.builder + .withHeader(CodecHelper.unsafeParseJsonToJavaMap(NestedHeader.claimsEncoder.encode(nestedHeader))) + .sign(config.algorithm) + + val verified = jwtVerifier.verifyJwt[NestedHeader](token.toTokenH) + + verified.value shouldBe nestedHeader.toClaimsH.copy(registered = testData.registeredClaims) + } + + "fail to decode a token with header" in { + val jwtVerifier = JwtVerifier(defaultConfig) + val header = """{"name": "name"}""" + val token = JWT + .create() + .withHeader(CodecHelper.unsafeParseJsonToJavaMap(header)) + .sign(defaultConfig.algorithm) + + val verified = jwtVerifier.verifyJwt[NestedHeader](token.toTokenH) + + verified shouldBe Left( + JwtVerifyError.DecodingError("DecodingFailure at .mapping: Missing required field", null) + ) + } + + "fail to decode a token with header if exception raised in decoder" in { + val jwtVerifier = JwtVerifier(defaultConfig) + val token = JWT + .create() + .sign(defaultConfig.algorithm) + + val decodingError = jwtVerifier + .verifyJwt[SimpleHeader](token.toTokenH) + .left + .value + .asInstanceOf[JwtVerifyError.DecodingError] + + decodingError.message shouldBe "Boom" + decodingError.underlying shouldBe a[RuntimeException] + decodingError.underlying.getMessage shouldBe "Boom" + } + } + + "verifyJwt TokenP" should { + "verify a token with payload" in forAll { (nestedPayload: NestedPayload, config: JwtVerifierConfig) => + val jwtVerifier = JwtVerifier(config) + val testData = setRegisteredClaims(JWT.create(), config) + val token = testData.builder + .withPayload(CodecHelper.unsafeParseJsonToJavaMap(NestedPayload.claimsEncoder.encode(nestedPayload))) + .sign(config.algorithm) + + val verified = jwtVerifier.verifyJwt[NestedPayload](token.toTokenP) + + verified.value shouldBe nestedPayload.toClaimsP.copy(registered = testData.registeredClaims) + } + + "fail to decode a token with payload" in { + val jwtVerifier = JwtVerifier(defaultConfig) + val payload = """{"name": "name"}""" + val token = JWT + .create() + .withPayload(CodecHelper.unsafeParseJsonToJavaMap(payload)) + .sign(defaultConfig.algorithm) + + val verified = jwtVerifier.verifyJwt[NestedPayload](token.toTokenP) + + verified shouldBe Left( + JwtVerifyError.DecodingError("DecodingFailure at .mapping: Missing required field", null) + ) + } + + "fail to decode a token with payload if exception raised in decoder" in { + val jwtVerifier = JwtVerifier(defaultConfig) + val token = JWT + .create() + .sign(defaultConfig.algorithm) + + val decodingError = jwtVerifier + .verifyJwt[SimplePayload](token.toTokenP) + .left + .value + .asInstanceOf[JwtVerifyError.DecodingError] + + decodingError.message shouldBe "Boom" + decodingError.underlying shouldBe a[RuntimeException] + decodingError.underlying.getMessage shouldBe "Boom" + } + } + + "verifyJwt TokenHP" should { + "verify a token with header & payload" in forAll { + (nestedHeader: NestedHeader, nestedPayload: NestedPayload, config: JwtVerifierConfig) => + val jwtVerifier = JwtVerifier(config) + val testData = setRegisteredClaims(JWT.create(), config) + val token = testData.builder + .withPayload(CodecHelper.unsafeParseJsonToJavaMap(NestedPayload.claimsEncoder.encode(nestedPayload))) + .withHeader(CodecHelper.unsafeParseJsonToJavaMap(NestedHeader.claimsEncoder.encode(nestedHeader))) + .sign(config.algorithm) + + val verified = + jwtVerifier.verifyJwt[NestedHeader, NestedPayload](token.toTokenHP) + + verified.value shouldBe (nestedHeader, nestedPayload).toClaimsHP.copy(registered = testData.registeredClaims) + } + + "fail to decode a token with header & payload" in { + val jwtVerifier = JwtVerifier(defaultConfig) + val header = """{"name": "name"}""" + val token = JWT + .create() + .withHeader(CodecHelper.unsafeParseJsonToJavaMap(header)) + .sign(defaultConfig.algorithm) + + val verified = + jwtVerifier.verifyJwt[NestedHeader, NestedPayload](token.toTokenHP) + + verified shouldBe Left( + JwtVerifyError.DecodingError("DecodingFailure at .mapping: Missing required field", null) + ) + } + + "fail to decode a token with header & payload if exception raised in decoder" in { + val jwtVerifier = JwtVerifier(defaultConfig) + val token = JWT + .create() + .sign(defaultConfig.algorithm) + + val decodingError = + jwtVerifier + .verifyJwt[SimpleHeader, SimplePayload](token.toTokenHP) + .left + .value + .asInstanceOf[JwtVerifyError.DecodingError] + + decodingError.message shouldBe "Boom" + decodingError.underlying shouldBe a[RuntimeException] + decodingError.underlying.getMessage shouldBe "Boom" + } + } + } +} diff --git a/oath/core/src/test/scala/io/oath/NestedHeader.scala b/oath/core/src/test/scala/io/oath/NestedHeader.scala new file mode 100644 index 0000000..1c0175f --- /dev/null +++ b/oath/core/src/test/scala/io/oath/NestedHeader.scala @@ -0,0 +1,37 @@ +package io.oath + +import io.circe._ +import io.circe.generic.semiauto._ +import io.circe.parser._ +import io.circe.syntax._ +import io.oath.NestedHeader.SimpleHeader +import io.oath.json._ + +final case class NestedHeader(name: String, mapping: Map[String, SimpleHeader]) + +object NestedHeader { + final case class SimpleHeader(name: String, data: List[String]) + + given Encoder[SimpleHeader] = deriveEncoder[SimpleHeader] + + given Decoder[SimpleHeader] = deriveDecoder[SimpleHeader] + + given circeEncoder: Encoder[NestedHeader] = deriveEncoder[NestedHeader] + + given circeDecoder: Decoder[NestedHeader] = deriveDecoder[NestedHeader] + + given ClaimsEncoder[SimpleHeader] = simpleHeader => simpleHeader.asJson.noSpaces + + given failWithBoomDecoder: ClaimsDecoder[SimpleHeader] = _ => throw new RuntimeException("Boom") + + given claimsEncoder: ClaimsEncoder[NestedHeader] = nestedHeader => nestedHeader.asJson.noSpaces + + given claimsDecoder: ClaimsDecoder[NestedHeader] = nestedHeaderJson => + parse(nestedHeaderJson).left + .map(parsingFailure => JwtVerifyError.DecodingError(parsingFailure.message, parsingFailure.underlying)) + .flatMap( + _.as[NestedHeader].left.map(decodingFailure => + JwtVerifyError.DecodingError(decodingFailure.getMessage(), decodingFailure.getCause) + ) + ) +} diff --git a/oath/core/src/test/scala/io/oath/NestedPayload.scala b/oath/core/src/test/scala/io/oath/NestedPayload.scala new file mode 100644 index 0000000..ff4cd53 --- /dev/null +++ b/oath/core/src/test/scala/io/oath/NestedPayload.scala @@ -0,0 +1,37 @@ +package io.oath + +import io.circe.generic.semiauto._ +import io.circe.parser._ +import io.circe.syntax._ +import io.circe.{Decoder, Encoder} +import io.oath.NestedPayload.SimplePayload +import io.oath.json._ + +final case class NestedPayload(name: String, mapping: Map[String, SimplePayload]) + +object NestedPayload { + final case class SimplePayload(name: String, data: List[String]) + + given Encoder[SimplePayload] = deriveEncoder[SimplePayload] + + given Decoder[SimplePayload] = deriveDecoder[SimplePayload] + + given circeEncoder: Encoder[NestedPayload] = deriveEncoder[NestedPayload] + + given circeDecoder: Decoder[NestedPayload] = deriveDecoder[NestedPayload] + + given ClaimsEncoder[SimplePayload] = simplePayload => simplePayload.asJson.noSpaces + + given failWithBoomDecoder: ClaimsDecoder[SimplePayload] = _ => throw new RuntimeException("Boom") + + given claimsEncoder: ClaimsEncoder[NestedPayload] = nestedPayload => nestedPayload.asJson.noSpaces + + given claimsDecoder: ClaimsDecoder[NestedPayload] = nestedPayloadJson => + parse(nestedPayloadJson).left + .map(parsingFailure => JwtVerifyError.DecodingError(parsingFailure.message, parsingFailure.underlying)) + .flatMap( + _.as[NestedPayload].left.map(decodingFailure => + JwtVerifyError.DecodingError(decodingFailure.getMessage(), decodingFailure.getCause) + ) + ) +} diff --git a/modules/oath-core/src/test/scala/io/oath/test/OathIssuerSpec.scala b/oath/core/src/test/scala/io/oath/OathIssuerSpec.scala similarity index 77% rename from modules/oath-core/src/test/scala/io/oath/test/OathIssuerSpec.scala rename to oath/core/src/test/scala/io/oath/OathIssuerSpec.scala index 89c5260..8d6c616 100644 --- a/modules/oath-core/src/test/scala/io/oath/test/OathIssuerSpec.scala +++ b/oath/core/src/test/scala/io/oath/OathIssuerSpec.scala @@ -1,13 +1,13 @@ -package io.oath.test +package io.oath -import io.oath.* -import io.oath.testkit.AnyWordSpecBase +import io.oath.OathIssuer.JIssuer +import io.oath.testkit.WordSpecBase -class OathIssuerSpec extends AnyWordSpecBase: +class OathIssuerSpec extends WordSpecBase { - "OathIssuer" should: - "create jwt token issuers" in: - inline def oathIssuer = OathIssuer.createOrFail[OathToken] + "OathIssuer" should { + "create jwt token issuers" in { + val oathIssuer = OathIssuer.createOrFail[OathToken] val accessTokenIssuer: JIssuer[OathToken.AccessToken.type] = oathIssuer.as(OathToken.AccessToken) val refreshTokenIssuer: JIssuer[OathToken.RefreshToken.type] = oathIssuer.as(OathToken.RefreshToken) @@ -20,3 +20,6 @@ class OathIssuerSpec extends AnyWordSpecBase: refreshTokenIssuer.issueJwt().value.claims.registered.iss shouldBe Some("refresh-token") activationEmailTokenIssuer.issueJwt().value.claims.registered.iss shouldBe Some("activation-email-token") forgotPasswordTokenIssuer.issueJwt().value.claims.registered.iss shouldBe Some("forgot-password-token") + } + } +} diff --git a/modules/oath-core/src/test/scala/io/oath/test/OathManagerSpec.scala b/oath/core/src/test/scala/io/oath/OathManagerSpec.scala similarity index 83% rename from modules/oath-core/src/test/scala/io/oath/test/OathManagerSpec.scala rename to oath/core/src/test/scala/io/oath/OathManagerSpec.scala index d8dcc9d..4f0f5e6 100644 --- a/modules/oath-core/src/test/scala/io/oath/test/OathManagerSpec.scala +++ b/oath/core/src/test/scala/io/oath/OathManagerSpec.scala @@ -1,13 +1,13 @@ -package io.oath.test +package io.oath -import io.oath.* -import io.oath.syntax.* -import io.oath.testkit.AnyWordSpecBase +import io.oath.OathManager.JManager +import io.oath.syntax.all._ +import io.oath.testkit.WordSpecBase -class OathManagerSpec extends AnyWordSpecBase: +class OathManagerSpec extends WordSpecBase { - "OathManager" should: - "create different token managers" in: + "OathManager" should { + "create different token managers" in { val oathManager = OathManager.createOrFail[OathToken] val accessTokenManager: JManager[OathToken.AccessToken.type] = oathManager.as(OathToken.AccessToken) @@ -26,3 +26,6 @@ class OathManagerSpec extends AnyWordSpecBase: refreshTokenManager.verifyJwt(refreshToken.toToken).isRight shouldBe true activationEmailTokenManager.verifyJwt(activationEmailToken.toToken).isRight shouldBe true forgotPasswordTokenManager.verifyJwt(forgotPasswordToken.toToken).isRight shouldBe true + } + } +} diff --git a/oath/core/src/test/scala/io/oath/OathToken.scala b/oath/core/src/test/scala/io/oath/OathToken.scala new file mode 100644 index 0000000..e35c8b6 --- /dev/null +++ b/oath/core/src/test/scala/io/oath/OathToken.scala @@ -0,0 +1,7 @@ +package io.oath + +import io.oath.macros.OathEnum + +enum OathToken derives OathEnum { + case AccessToken, RefreshToken, ActivationEmailToken, ForgotPasswordToken +} diff --git a/modules/oath-core/src/test/scala/io/oath/test/OathVerifierSpec.scala b/oath/core/src/test/scala/io/oath/OathVerifierSpec.scala similarity index 86% rename from modules/oath-core/src/test/scala/io/oath/test/OathVerifierSpec.scala rename to oath/core/src/test/scala/io/oath/OathVerifierSpec.scala index d17409b..b295a47 100644 --- a/modules/oath-core/src/test/scala/io/oath/test/OathVerifierSpec.scala +++ b/oath/core/src/test/scala/io/oath/OathVerifierSpec.scala @@ -1,10 +1,11 @@ -package io.oath.test +package io.oath -import io.oath.* -import io.oath.syntax.* -import io.oath.testkit.AnyWordSpecBase +import io.oath.OathIssuer.JIssuer +import io.oath.OathVerifier.JVerifier +import io.oath.syntax.all._ +import io.oath.testkit.WordSpecBase -class OathVerifierSpec extends AnyWordSpecBase: +class OathVerifierSpec extends WordSpecBase { val oathIssuer = OathIssuer.createOrFail[OathToken] @@ -15,9 +16,8 @@ class OathVerifierSpec extends AnyWordSpecBase: val forgotPasswordTokenIssuer: JIssuer[OathToken.ForgotPasswordToken.type] = oathIssuer.as(OathToken.ForgotPasswordToken) - "OathVerifier" should: - - "create different token verifiers" in: + "OathVerifier" should { + "create different token verifiers" in { val oathVerifier = OathVerifier.createOrFail[OathToken] val accessTokenVerifier: JVerifier[OathToken.AccessToken.type] = oathVerifier.as(OathToken.AccessToken) @@ -36,3 +36,6 @@ class OathVerifierSpec extends AnyWordSpecBase: refreshTokenVerifier.verifyJwt(refreshToken.toToken).isRight shouldBe true activationEmailTokenVerifier.verifyJwt(activationEmailToken.toToken).isRight shouldBe true forgotPasswordTokenVerifier.verifyJwt(forgotPasswordToken.toToken).isRight shouldBe true + } + } +} diff --git a/oath/core/src/test/scala/io/oath/TestData.scala b/oath/core/src/test/scala/io/oath/TestData.scala new file mode 100644 index 0000000..53f79da --- /dev/null +++ b/oath/core/src/test/scala/io/oath/TestData.scala @@ -0,0 +1,5 @@ +package io.oath + +import com.auth0.jwt.JWTCreator + +final case class TestData(registeredClaims: RegisteredClaims, builder: JWTCreator.Builder) diff --git a/modules/oath-core/src/test/scala/io/oath/test/config/AlgorithmLoaderSpec.scala b/oath/core/src/test/scala/io/oath/config/AlgorithmLoaderSpec.scala similarity index 83% rename from modules/oath-core/src/test/scala/io/oath/test/config/AlgorithmLoaderSpec.scala rename to oath/core/src/test/scala/io/oath/config/AlgorithmLoaderSpec.scala index 1f9eac3..3ac3ffa 100644 --- a/modules/oath-core/src/test/scala/io/oath/test/config/AlgorithmLoaderSpec.scala +++ b/oath/core/src/test/scala/io/oath/config/AlgorithmLoaderSpec.scala @@ -1,15 +1,14 @@ -package io.oath.test.config +package io.oath.config import com.auth0.jwt.JWT import com.typesafe.config.ConfigFactory -import io.oath.config.AlgorithmLoader -import io.oath.testkit.{AnyWordSpecBase, PropertyBasedTesting} +import io.oath.testkit._ -class AlgorithmLoaderSpec extends AnyWordSpecBase, PropertyBasedTesting: - private val AlgorithmConfigLocation = "algorithm" +class AlgorithmLoaderSpec extends WordSpecBase, PropertyBasedTesting { + val AlgorithmConfigLocation = "algorithm" - "AlgorithmLoader" should: - "load none encryption algorithm config" in forAll: (issuer: String) => + "AlgorithmLoader" should { + "load none encryption algorithm config" in forAll { (issuer: String) => val algorithmScopedConfig = ConfigFactory.load("algorithm-none").getConfig(AlgorithmConfigLocation) val issuingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = true) val verifyingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = false) @@ -21,8 +20,9 @@ class AlgorithmLoaderSpec extends AnyWordSpecBase, PropertyBasedTesting: verifyingAlgorithm.getName shouldBe "none" verifiedIssuer shouldBe issuer token should not be empty + } - "load RSXXX encryption algorithm with secret key" in forAll: (issuer: String) => + "load RSXXX encryption algorithm with secret key" in forAll { (issuer: String) => val algorithmScopedConfig = ConfigFactory.load("algorithm-rsxxx").getConfig(AlgorithmConfigLocation) val issuingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = true) val verifyingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = false) @@ -34,8 +34,9 @@ class AlgorithmLoaderSpec extends AnyWordSpecBase, PropertyBasedTesting: verifyingAlgorithm.getName shouldBe "RS256" verifiedIssuer shouldBe issuer token should not be empty + } - "load HSXXX encryption algorithm with secret key" in forAll: (issuer: String) => + "load HSXXX encryption algorithm with secret key" in forAll { (issuer: String) => val algorithmScopedConfig = ConfigFactory.load("algorithm-hsxxx").getConfig(AlgorithmConfigLocation) val issuingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = true) val verifyingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = false) @@ -47,8 +48,9 @@ class AlgorithmLoaderSpec extends AnyWordSpecBase, PropertyBasedTesting: verifyingAlgorithm.getName shouldBe "HS256" verifiedIssuer shouldBe issuer token should not be empty + } - "load ES256 encryption algorithm with secret key" in forAll: (issuer: String) => + "load ES256 encryption algorithm with secret key" in forAll { (issuer: String) => val algorithmScopedConfig = ConfigFactory.load("algorithm-es256").getConfig(AlgorithmConfigLocation) val issuingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = true) val verifyingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = false) @@ -60,8 +62,9 @@ class AlgorithmLoaderSpec extends AnyWordSpecBase, PropertyBasedTesting: verifyingAlgorithm.getName shouldBe "ES256" verifiedIssuer shouldBe issuer token should not be empty + } - "load ES384 encryption algorithm with secret key" in forAll: (issuer: String) => + "load ES384 encryption algorithm with secret key" in forAll { (issuer: String) => val algorithmScopedConfig = ConfigFactory.load("algorithm-es384").getConfig(AlgorithmConfigLocation) val issuingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = true) val verifyingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = false) @@ -73,8 +76,9 @@ class AlgorithmLoaderSpec extends AnyWordSpecBase, PropertyBasedTesting: verifyingAlgorithm.getName shouldBe "ES384" verifiedIssuer shouldBe issuer token should not be empty + } - "load ES512 encryption algorithm with secret key" in forAll: (issuer: String) => + "load ES512 encryption algorithm with secret key" in forAll { (issuer: String) => val algorithmScopedConfig = ConfigFactory.load("algorithm-es512").getConfig(AlgorithmConfigLocation) val issuingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = true) val verifyingAlgorithm = AlgorithmLoader.loadOrThrow(algorithmScopedConfig, isIssuer = false) @@ -86,8 +90,12 @@ class AlgorithmLoaderSpec extends AnyWordSpecBase, PropertyBasedTesting: verifyingAlgorithm.getName shouldBe "ES512" verifiedIssuer shouldBe issuer token should not be empty + } - "fail to load unsupported algorithm type" in forAll: (bool: Boolean) => + "fail to load unsupported algorithm type" in forAll { (bool: Boolean) => val algorithmScopedConfig = ConfigFactory.load("algorithm-unsupported").getConfig(AlgorithmConfigLocation) the[IllegalArgumentException] thrownBy AlgorithmLoader .loadOrThrow(algorithmScopedConfig, bool) should have message "Unsupported signature algorithm: Boom" + } + } +} diff --git a/modules/oath-core/src/test/scala/io/oath/test/config/JwtIssuerLoaderSpec.scala b/oath/core/src/test/scala/io/oath/config/JwtIssuerLoaderSpec.scala similarity index 71% rename from modules/oath-core/src/test/scala/io/oath/test/config/JwtIssuerLoaderSpec.scala rename to oath/core/src/test/scala/io/oath/config/JwtIssuerLoaderSpec.scala index 9b10beb..e3266ec 100644 --- a/modules/oath-core/src/test/scala/io/oath/test/config/JwtIssuerLoaderSpec.scala +++ b/oath/core/src/test/scala/io/oath/config/JwtIssuerLoaderSpec.scala @@ -1,27 +1,25 @@ -package io.oath.test.config +package io.oath.config import com.typesafe.config.{ConfigException, ConfigFactory} -import io.oath.config.{EncryptConfig, JwtIssuerConfig} -import io.oath.testkit.AnyWordSpecBase +import io.oath.testkit._ import scala.concurrent.duration.DurationInt -class JwtIssuerLoaderSpec extends AnyWordSpecBase: +class JwtIssuerLoaderSpec extends WordSpecBase { val configFile = "issuer" val DefaultTokenConfigLocation = "default-token" val TokenConfigLocation = "token" - val TokenWithEncryptionConfigLocation = "token-with-encryption" val WithoutPrivateKeyTokenConfigLocation = "without-private-key-token" val InvalidTokenEmptyStringConfigLocation = "invalid-token-empty-string" val InvalidTokenWrongTypeConfigLocation = "invalid-token-wrong-type" + val InvalidAudienceClaimsConfigLocation = "invalid-seq-empty-string" - "IssuerLoader" should: - "load default-token issuer config values from configuration file" in: + "IssuerLoader" should { + "load default-token issuer config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(DefaultTokenConfigLocation) val config = JwtIssuerConfig.loadOrThrow(configLoader) - config.encrypt shouldBe empty config.registered.issuerClaim shouldBe None config.registered.subjectClaim shouldBe None config.registered.audienceClaims shouldBe Seq.empty @@ -30,12 +28,12 @@ class JwtIssuerLoaderSpec extends AnyWordSpecBase: config.registered.expiresAtOffset shouldBe None config.registered.notBeforeOffset shouldBe None config.algorithm.getName shouldBe "HS256" + } - "load token issuer config values from configuration file" in: + "load token issuer config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(TokenConfigLocation) val config = JwtIssuerConfig.loadOrThrow(configLoader) - config.encrypt shouldBe empty config.registered.issuerClaim shouldBe Some("issuer") config.registered.subjectClaim shouldBe Some("subject") config.registered.audienceClaims shouldBe Seq("aud1", "aud2") @@ -44,25 +42,11 @@ class JwtIssuerLoaderSpec extends AnyWordSpecBase: config.registered.expiresAtOffset shouldBe Some(1.day) config.registered.notBeforeOffset shouldBe Some(1.minute) config.algorithm.getName shouldBe "RS256" + } - "load token issuer config values from configuration file with encryption key" in: - val configLoader = ConfigFactory.load(configFile).getConfig(TokenWithEncryptionConfigLocation) - val config = JwtIssuerConfig.loadOrThrow(configLoader) - - config.encrypt shouldBe Some(EncryptConfig("password")) - config.registered.issuerClaim shouldBe Some("issuer") - config.registered.subjectClaim shouldBe Some("subject") - config.registered.audienceClaims shouldBe Seq("aud1", "aud2") - config.registered.includeIssueAtClaim shouldBe true - config.registered.includeJwtIdClaim shouldBe false - config.registered.expiresAtOffset shouldBe Some(1.day) - config.registered.notBeforeOffset shouldBe Some(1.minute) - config.algorithm.getName shouldBe "RS256" - - "load token issuer config values from reference configuration file using location" in: + "load token issuer config values from reference.conf file using location" in { val config = JwtIssuerConfig.loadOrThrow(TokenConfigLocation) - config.encrypt shouldBe empty config.registered.issuerClaim shouldBe Some("issuer") config.registered.subjectClaim shouldBe Some("subject") config.registered.audienceClaims shouldBe Seq("aud1", "aud2") @@ -71,18 +55,30 @@ class JwtIssuerLoaderSpec extends AnyWordSpecBase: config.registered.expiresAtOffset shouldBe Some(1.day) config.registered.notBeforeOffset shouldBe Some(1.minute) config.algorithm.getName shouldBe "RS256" + } - "fail to load without-private-key-token issuer config values from configuration file" in: + "fail to load without-private-key-token issuer config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(WithoutPrivateKeyTokenConfigLocation) the[ConfigException.Missing] thrownBy JwtIssuerConfig.loadOrThrow(configLoader) + } - "fail to load invalid-token-empty-string issuer config values from configuration file" in: + "fail to load invalid-token-empty-string issuer config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(InvalidTokenEmptyStringConfigLocation) the[IllegalArgumentException] thrownBy JwtIssuerConfig.loadOrThrow(configLoader) + } - "fail to load invalid-token-wrong-type issuer config values from configuration file" in: + "fail to load invalid-token-wrong-type issuer config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(InvalidTokenWrongTypeConfigLocation) the[ConfigException.BadValue] thrownBy JwtIssuerConfig.loadOrThrow(configLoader) + } + + "fail to load issuer config when audience claims contain empty string in configuration file" in { + val configLoader = ConfigFactory.load(configFile).getConfig(InvalidAudienceClaimsConfigLocation) + + the[IllegalArgumentException] thrownBy JwtIssuerConfig.loadOrThrow(configLoader) + } + } +} diff --git a/modules/oath-core/src/test/scala/io/oath/test/config/JwtManagerLoaderSpec.scala b/oath/core/src/test/scala/io/oath/config/JwtManagerLoaderSpec.scala similarity index 88% rename from modules/oath-core/src/test/scala/io/oath/test/config/JwtManagerLoaderSpec.scala rename to oath/core/src/test/scala/io/oath/config/JwtManagerLoaderSpec.scala index baf3f67..65c485b 100644 --- a/modules/oath-core/src/test/scala/io/oath/test/config/JwtManagerLoaderSpec.scala +++ b/oath/core/src/test/scala/io/oath/config/JwtManagerLoaderSpec.scala @@ -1,18 +1,17 @@ -package io.oath.test.config +package io.oath.config import com.typesafe.config.ConfigFactory -import io.oath.config.JwtManagerConfig -import io.oath.testkit.AnyWordSpecBase +import io.oath.testkit._ import scala.concurrent.duration.DurationInt -class JwtManagerLoaderSpec extends AnyWordSpecBase: +class JwtManagerLoaderSpec extends WordSpecBase { val configFile = "manager" val TokenConfigLocation = "token" - "ManagerLoader" should: - "load default-token verifier config values from configuration file" in: + "ManagerLoader" should { + "load default-token verifier config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(TokenConfigLocation) val config = JwtManagerConfig.loadOrThrow(configLoader) @@ -33,3 +32,6 @@ class JwtManagerLoaderSpec extends AnyWordSpecBase: config.verifier.leewayWindow.expiresAt shouldBe Some(3.minutes) config.verifier.leewayWindow.notBefore shouldBe Some(2.minutes) config.verifier.algorithm.getName shouldBe "RS256" + } + } +} diff --git a/modules/oath-core/src/test/scala/io/oath/test/config/JwtVerifierLoaderSpec.scala b/oath/core/src/test/scala/io/oath/config/JwtVerifierLoaderSpec.scala similarity index 71% rename from modules/oath-core/src/test/scala/io/oath/test/config/JwtVerifierLoaderSpec.scala rename to oath/core/src/test/scala/io/oath/config/JwtVerifierLoaderSpec.scala index cff83cc..a5d850f 100644 --- a/modules/oath-core/src/test/scala/io/oath/test/config/JwtVerifierLoaderSpec.scala +++ b/oath/core/src/test/scala/io/oath/config/JwtVerifierLoaderSpec.scala @@ -1,27 +1,25 @@ -package io.oath.test.config +package io.oath.config import com.typesafe.config.{ConfigException, ConfigFactory} -import io.oath.config.{EncryptConfig, JwtVerifierConfig} -import io.oath.testkit.AnyWordSpecBase +import io.oath.testkit._ import scala.concurrent.duration.DurationInt -class JwtVerifierLoaderSpec extends AnyWordSpecBase: +class JwtVerifierLoaderSpec extends WordSpecBase { val configFile = "verifier" val DefaultTokenConfigLocation = "default-token" val TokenConfigLocation = "token" - val TokenWithEncryptionConfigLocation = "token-with-encryption" val WithoutPublicKeyTokenConfigLocation = "without-public-key-token" val InvalidTokenEmptyStringConfigLocation = "invalid-token-empty-string" val InvalidTokenWrongTypeConfigLocation = "invalid-token-wrong-type" + val InvalidAudienceClaimsConfigLocation = "invalid-seq-empty-string" - "VerifierLoader" should: - "load default-token verifier config values from configuration file" in: + "VerifierLoader" should { + "load default-token verifier config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(DefaultTokenConfigLocation) val config = JwtVerifierConfig.loadOrThrow(configLoader) - config.encrypt shouldBe empty config.providedWith.issuerClaim shouldBe None config.providedWith.subjectClaim shouldBe None config.providedWith.audienceClaims shouldBe Seq.empty @@ -31,12 +29,12 @@ class JwtVerifierLoaderSpec extends AnyWordSpecBase: config.leewayWindow.expiresAt shouldBe None config.leewayWindow.notBefore shouldBe None config.algorithm.getName shouldBe "HS256" + } - "load token verifier config values from configuration file" in: + "load token verifier config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(TokenConfigLocation) val config = JwtVerifierConfig.loadOrThrow(configLoader) - config.encrypt shouldBe empty config.providedWith.issuerClaim shouldBe Some("issuer") config.providedWith.subjectClaim shouldBe Some("subject") config.providedWith.audienceClaims shouldBe Seq("aud1", "aud2") @@ -45,25 +43,11 @@ class JwtVerifierLoaderSpec extends AnyWordSpecBase: config.leewayWindow.expiresAt shouldBe Some(3.minutes) config.leewayWindow.notBefore shouldBe Some(2.minutes) config.algorithm.getName shouldBe "RS256" + } - "load token verifier config values from configuration file with encryption" in: - val configLoader = ConfigFactory.load(configFile).getConfig(TokenWithEncryptionConfigLocation) - val config = JwtVerifierConfig.loadOrThrow(configLoader) - - config.encrypt shouldBe Some(EncryptConfig("password")) - config.providedWith.issuerClaim shouldBe Some("issuer") - config.providedWith.subjectClaim shouldBe Some("subject") - config.providedWith.audienceClaims shouldBe Seq("aud1", "aud2") - config.leewayWindow.leeway shouldBe Some(1.minute) - config.leewayWindow.issuedAt shouldBe Some(4.minutes) - config.leewayWindow.expiresAt shouldBe Some(3.minutes) - config.leewayWindow.notBefore shouldBe Some(2.minutes) - config.algorithm.getName shouldBe "RS256" - - "load token verifier config values from reference configuration file using location" in: + "load token verifier config values from reference.conf file using location" in { val config = JwtVerifierConfig.loadOrThrow(TokenConfigLocation) - config.encrypt shouldBe empty config.providedWith.issuerClaim shouldBe Some("issuer") config.providedWith.subjectClaim shouldBe Some("subject") config.providedWith.audienceClaims shouldBe Seq("aud1", "aud2") @@ -72,18 +56,30 @@ class JwtVerifierLoaderSpec extends AnyWordSpecBase: config.leewayWindow.expiresAt shouldBe Some(3.minutes) config.leewayWindow.notBefore shouldBe Some(2.minutes) config.algorithm.getName shouldBe "RS256" + } - "fail to load without-public-key-token verifier config values from configuration file" in: + "fail to load without-public-key-token verifier config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(WithoutPublicKeyTokenConfigLocation) the[ConfigException.Missing] thrownBy JwtVerifierConfig.loadOrThrow(configLoader) + } - "fail to load invalid-token-empty-string verifier config values from configuration file" in: + "fail to load invalid-token-empty-string verifier config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(InvalidTokenEmptyStringConfigLocation) the[IllegalArgumentException] thrownBy JwtVerifierConfig.loadOrThrow(configLoader) + } - "fail to load invalid-token-wrong-type verifier config values from configuration file" in: + "fail to load invalid-token-wrong-type verifier config values from configuration file" in { val configLoader = ConfigFactory.load(configFile).getConfig(InvalidTokenWrongTypeConfigLocation) the[ConfigException.WrongType] thrownBy JwtVerifierConfig.loadOrThrow(configLoader) + } + + "fail to load verifier config when audience claims contain empty string in configuration file" in { + val configLoader = ConfigFactory.load(configFile).getConfig(InvalidAudienceClaimsConfigLocation) + + the[IllegalArgumentException] thrownBy JwtVerifierConfig.loadOrThrow(configLoader) + } + } +} diff --git a/modules/oath-core/src/test/scala/io/oath/testkit/Arbitraries.scala b/oath/core/src/test/scala/io/oath/testkit/Arbitraries.scala similarity index 79% rename from modules/oath-core/src/test/scala/io/oath/testkit/Arbitraries.scala rename to oath/core/src/test/scala/io/oath/testkit/Arbitraries.scala index 70ba7fa..efe7746 100644 --- a/modules/oath-core/src/test/scala/io/oath/testkit/Arbitraries.scala +++ b/oath/core/src/test/scala/io/oath/testkit/Arbitraries.scala @@ -1,21 +1,20 @@ package io.oath.testkit import com.auth0.jwt.algorithms.Algorithm -import io.oath.RegisteredClaims +import io.oath.NestedHeader._ +import io.oath.NestedPayload._ +import io.oath._ import io.oath.config.JwtIssuerConfig.RegisteredConfig -import io.oath.config.JwtVerifierConfig.{LeewayWindowConfig, ProvidedWithConfig} +import io.oath.config.JwtVerifierConfig._ import io.oath.config._ -import io.oath.test.NestedHeader.SimpleHeader -import io.oath.test.NestedPayload.SimplePayload -import io.oath.test.{NestedHeader, NestedPayload} -import org.scalacheck.{Arbitrary, Gen} +import org.scalacheck._ import java.time.Instant import scala.concurrent.duration.{Duration, DurationInt} -trait Arbitraries: - lazy val genPositiveFiniteDuration = Gen.posNum[Long].map(Duration.fromNanos) - lazy val genPositiveFiniteDurationSeconds = Gen.posNum[Int].map(x => (x + 1).seconds) +trait Arbitraries { + private lazy val genPositiveFiniteDuration = Gen.posNum[Long].map(Duration.fromNanos) + private lazy val genPositiveFiniteDurationSeconds = Gen.posNum[Int].map(x => (x + 1).seconds) implicit val arbNonEmptyString: Arbitrary[String] = Arbitrary( @@ -27,12 +26,8 @@ trait Arbitraries: Gen.chooseNum(Long.MinValue, Long.MaxValue).map(Instant.ofEpochMilli) ) - implicit val arbEncryptConfig: Arbitrary[EncryptConfig] = - Arbitrary: - arbNonEmptyString.arbitrary.map(EncryptConfig.apply) - implicit val arbJwtIssuerConfig: Arbitrary[JwtIssuerConfig] = - Arbitrary: + Arbitrary { for { issuerClaim <- Gen.option(arbNonEmptyString.arbitrary) subjectClaim <- Gen.option(arbNonEmptyString.arbitrary) @@ -50,11 +45,11 @@ trait Arbitraries: expiresAtOffset, notBeforeOffset, ) - encrypt <- Gen.option(arbEncryptConfig.arbitrary) - } yield JwtIssuerConfig(Algorithm.none(), encrypt, registered) + } yield JwtIssuerConfig(Algorithm.none(), registered) + } implicit val arbJwtVerifierConfig: Arbitrary[JwtVerifierConfig] = - Arbitrary: + Arbitrary { for { encryptKey <- Gen.option(arbNonEmptyString.arbitrary) issuerClaim <- Gen.option(arbNonEmptyString.arbitrary) @@ -64,13 +59,13 @@ trait Arbitraries: issuedAt <- Gen.option(genPositiveFiniteDurationSeconds) expiresAt <- Gen.option(genPositiveFiniteDurationSeconds) notBefore <- Gen.option(genPositiveFiniteDurationSeconds) - encrypt = encryptKey.map(EncryptConfig.apply) leewayWindow = LeewayWindowConfig(leeway, issuedAt, expiresAt, notBefore) providedWith = ProvidedWithConfig(issuerClaim, subjectClaim, audienceClaims) - } yield JwtVerifierConfig(Algorithm.none(), encrypt, providedWith, leewayWindow) + } yield JwtVerifierConfig(Algorithm.none(), providedWith, leewayWindow) + } implicit val arbJwtManagerConfig: Arbitrary[JwtManagerConfig] = - Arbitrary: + Arbitrary { for { encryptKey <- Gen.option(arbNonEmptyString.arbitrary) issuerClaim <- Gen.option(arbNonEmptyString.arbitrary) @@ -85,7 +80,6 @@ trait Arbitraries: expiresAt <- Gen.option(genPositiveFiniteDurationSeconds) leewayWindow = LeewayWindowConfig(leeway, issuedAt, expiresAt, notBeforeOffset.map(_.plus(1.second))) providedWith = ProvidedWithConfig(issuerClaim, subjectClaim, audienceClaims) - encrypt = encryptKey.map(EncryptConfig.apply) registered = RegisteredConfig( issuerClaim, subjectClaim, @@ -95,12 +89,13 @@ trait Arbitraries: expiresAtOffset, notBeforeOffset, ) - verifier = JwtVerifierConfig(Algorithm.none(), encrypt, providedWith, leewayWindow) - issuer = JwtIssuerConfig(Algorithm.none(), encrypt, registered) + verifier = JwtVerifierConfig(Algorithm.none(), providedWith, leewayWindow) + issuer = JwtIssuerConfig(Algorithm.none(), registered) } yield JwtManagerConfig(issuer, verifier) + } implicit val arbRegisteredClaims: Arbitrary[RegisteredClaims] = - Arbitrary: + Arbitrary { for { iss <- Gen.option(arbNonEmptyString.arbitrary) sub <- Gen.option(arbNonEmptyString.arbitrary) @@ -110,31 +105,37 @@ trait Arbitraries: iat <- Gen.option(arbInstant.arbitrary) jti <- Gen.option(arbNonEmptyString.arbitrary) } yield RegisteredClaims(iss, sub, aud, exp, nbf, iat, jti) + } implicit val arbSimplePayload: Arbitrary[SimplePayload] = - Arbitrary: + Arbitrary { for { name <- Gen.alphaStr data <- Gen.listOf(Gen.alphaStr) } yield SimplePayload(name, data) + } implicit val arbSimpleHeader: Arbitrary[SimpleHeader] = - Arbitrary: + Arbitrary { for { name <- Gen.alphaStr data <- Gen.listOf(Gen.alphaStr) } yield SimpleHeader(name, data) + } implicit val arbNestedPayload: Arbitrary[NestedPayload] = - Arbitrary: + Arbitrary { for { name <- Gen.alphaStr mapping <- Gen.mapOf(Gen.alphaStr.flatMap(str => arbSimplePayload.arbitrary.map((str, _)))) } yield NestedPayload(name, mapping) + } implicit val arbNestedHeader: Arbitrary[NestedHeader] = - Arbitrary: + Arbitrary { for { name <- Gen.alphaStr mapping <- Gen.mapOf(Gen.alphaStr.flatMap(str => arbSimpleHeader.arbitrary.map((str, _)))) } yield NestedHeader(name, mapping) + } +} diff --git a/modules/oath-core/src/test/scala/io/oath/utils/ClockHelper.scala b/oath/core/src/test/scala/io/oath/testkit/ClockHelper.scala similarity index 84% rename from modules/oath-core/src/test/scala/io/oath/utils/ClockHelper.scala rename to oath/core/src/test/scala/io/oath/testkit/ClockHelper.scala index 1e4e2cd..1d322a9 100644 --- a/modules/oath-core/src/test/scala/io/oath/utils/ClockHelper.scala +++ b/oath/core/src/test/scala/io/oath/testkit/ClockHelper.scala @@ -1,9 +1,10 @@ -package io.oath.utils +package io.oath.testkit import java.time.temporal.ChronoUnit import java.time.{Clock, Instant, ZoneId} -trait ClockHelper: +trait ClockHelper { def getInstantNowSeconds: Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) def getFixedClock(time: Instant): Clock = Clock.fixed(time, ZoneId.of("UTC")) +} diff --git a/modules/oath-core/src/test/scala/io/oath/utils/CodecUtils.scala b/oath/core/src/test/scala/io/oath/testkit/CodecHelper.scala similarity index 69% rename from modules/oath-core/src/test/scala/io/oath/utils/CodecUtils.scala rename to oath/core/src/test/scala/io/oath/testkit/CodecHelper.scala index 0e72615..7b849f5 100644 --- a/modules/oath-core/src/test/scala/io/oath/utils/CodecUtils.scala +++ b/oath/core/src/test/scala/io/oath/testkit/CodecHelper.scala @@ -1,9 +1,10 @@ -package io.oath.utils +package io.oath.testkit import com.fasterxml.jackson.databind.ObjectMapper -trait CodecUtils: - val mapper = new ObjectMapper +object CodecHelper { + private val mapper = new ObjectMapper() def unsafeParseJsonToJavaMap(json: String): java.util.Map[String, Object] = mapper.readValue(json, classOf[java.util.HashMap[String, Object]]) +} diff --git a/modules/oath-core/src/test/scala/io/oath/testkit/PropertyBasedTesting.scala b/oath/core/src/test/scala/io/oath/testkit/PropertyBasedTesting.scala similarity index 96% rename from modules/oath-core/src/test/scala/io/oath/testkit/PropertyBasedTesting.scala rename to oath/core/src/test/scala/io/oath/testkit/PropertyBasedTesting.scala index 3ec7c82..8a2f75b 100644 --- a/modules/oath-core/src/test/scala/io/oath/testkit/PropertyBasedTesting.scala +++ b/oath/core/src/test/scala/io/oath/testkit/PropertyBasedTesting.scala @@ -3,8 +3,9 @@ package io.oath.testkit import org.scalactic.anyvals.PosInt import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks -trait PropertyBasedTesting extends ScalaCheckPropertyChecks, Arbitraries: +trait PropertyBasedTesting extends ScalaCheckPropertyChecks, Arbitraries { val minSuccessful = PosInt(25) override implicit val generatorDrivenConfig: PropertyCheckConfiguration = PropertyCheckConfiguration(minSuccessful) +} diff --git a/modules/oath-core/src/test/scala/io/oath/testkit/AnyWordSpecBase.scala b/oath/core/src/test/scala/io/oath/testkit/WordSpecBase.scala similarity index 61% rename from modules/oath-core/src/test/scala/io/oath/testkit/AnyWordSpecBase.scala rename to oath/core/src/test/scala/io/oath/testkit/WordSpecBase.scala index 0fdce24..91dee10 100644 --- a/modules/oath-core/src/test/scala/io/oath/testkit/AnyWordSpecBase.scala +++ b/oath/core/src/test/scala/io/oath/testkit/WordSpecBase.scala @@ -4,4 +4,4 @@ import org.scalatest.matchers.should import org.scalatest.wordspec.AnyWordSpec import org.scalatest.{EitherValues, OptionValues} -abstract class AnyWordSpecBase extends AnyWordSpec, should.Matchers, OptionValues, EitherValues +abstract class WordSpecBase extends AnyWordSpec, should.Matchers, OptionValues, EitherValues diff --git a/oath/core/src/test/scala/io/oath/utils/Base64Spec.scala b/oath/core/src/test/scala/io/oath/utils/Base64Spec.scala new file mode 100644 index 0000000..a9f0fe9 --- /dev/null +++ b/oath/core/src/test/scala/io/oath/utils/Base64Spec.scala @@ -0,0 +1,14 @@ +package io.oath.utils + +import io.oath.testkit.WordSpecBase + +class Base64Spec extends WordSpecBase { + + "Base64" when { + ".decodeToken" should {} + "decode token" in { + val str = "dGVzdA==" + Base64.decodeToken(str) shouldBe Right("test") + } + } +} diff --git a/modules/oath-core/src/test/secrets/es256-private.pem b/oath/core/src/test/secrets/es256-private.pem similarity index 100% rename from modules/oath-core/src/test/secrets/es256-private.pem rename to oath/core/src/test/secrets/es256-private.pem diff --git a/modules/oath-core/src/test/secrets/es256-public.pem b/oath/core/src/test/secrets/es256-public.pem similarity index 100% rename from modules/oath-core/src/test/secrets/es256-public.pem rename to oath/core/src/test/secrets/es256-public.pem diff --git a/modules/oath-core/src/test/secrets/es384-private.pem b/oath/core/src/test/secrets/es384-private.pem similarity index 100% rename from modules/oath-core/src/test/secrets/es384-private.pem rename to oath/core/src/test/secrets/es384-private.pem diff --git a/modules/oath-core/src/test/secrets/es384-public.pem b/oath/core/src/test/secrets/es384-public.pem similarity index 100% rename from modules/oath-core/src/test/secrets/es384-public.pem rename to oath/core/src/test/secrets/es384-public.pem diff --git a/modules/oath-core/src/test/secrets/es512-private.pem b/oath/core/src/test/secrets/es512-private.pem similarity index 100% rename from modules/oath-core/src/test/secrets/es512-private.pem rename to oath/core/src/test/secrets/es512-private.pem diff --git a/modules/oath-core/src/test/secrets/es512-public.pem b/oath/core/src/test/secrets/es512-public.pem similarity index 100% rename from modules/oath-core/src/test/secrets/es512-public.pem rename to oath/core/src/test/secrets/es512-public.pem diff --git a/modules/oath-core/src/test/secrets/rsa-private.pem b/oath/core/src/test/secrets/rsa-private.pem similarity index 100% rename from modules/oath-core/src/test/secrets/rsa-private.pem rename to oath/core/src/test/secrets/rsa-private.pem diff --git a/modules/oath-core/src/test/secrets/rsa-public.pem b/oath/core/src/test/secrets/rsa-public.pem similarity index 100% rename from modules/oath-core/src/test/secrets/rsa-public.pem rename to oath/core/src/test/secrets/rsa-public.pem diff --git a/oath/jsoniter-scala/src/main/scala/io/oath/jsoniter_scala/conversion.scala b/oath/jsoniter-scala/src/main/scala/io/oath/jsoniter_scala/conversion.scala new file mode 100644 index 0000000..72473f8 --- /dev/null +++ b/oath/jsoniter-scala/src/main/scala/io/oath/jsoniter_scala/conversion.scala @@ -0,0 +1,21 @@ +package io.oath.jsoniter_scala + +import com.github.plokhotnyuk.jsoniter_scala.core._ +import io.oath.JwtVerifyError +import io.oath.json._ + +import scala.util.control.Exception.allCatch + +object conversion { + given [P](using codec: JsonValueCodec[P]): ClaimsCodec[P] = new ClaimsCodec[P] { + override def decode(token: String): Either[JwtVerifyError.DecodingError, P] = + allCatch + .withTry(readFromString(token)(codec)) + .toEither + .left + .map(error => JwtVerifyError.DecodingError(error.getMessage, error.getCause)) + + override def encode(data: P): String = + writeToString(data)(codec) + } +} diff --git a/oath/jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/Bar.scala b/oath/jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/Bar.scala new file mode 100644 index 0000000..0276458 --- /dev/null +++ b/oath/jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/Bar.scala @@ -0,0 +1,10 @@ +package io.oath.jsoniter_scala + +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.macros._ + +final case class Bar(name: String, age: Int) + +object Bar { + given JsonValueCodec[Bar] = JsonCodecMaker.make[Bar] +} diff --git a/modules/oath-jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/JsoniterConversionSpec.scala b/oath/jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/JsoniterConversionSpec.scala similarity index 52% rename from modules/oath-jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/JsoniterConversionSpec.scala rename to oath/jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/JsoniterConversionSpec.scala index dc42157..86fb421 100644 --- a/modules/oath-jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/JsoniterConversionSpec.scala +++ b/oath/jsoniter-scala/src/test/scala/io/oath/jsoniter_scala/JsoniterConversionSpec.scala @@ -2,43 +2,44 @@ package io.oath.jsoniter_scala import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm -import io.oath.* +import io.oath._ import io.oath.config.JwtIssuerConfig.RegisteredConfig -import io.oath.config.JwtVerifierConfig.* +import io.oath.config.JwtVerifierConfig._ import io.oath.config.{JwtIssuerConfig, JwtVerifierConfig} -import io.oath.syntax.* -import io.oath.testkit.AnyWordSpecBase -import io.oath.utils.CodecUtils +import io.oath.json.ClaimsDecoder +import io.oath.jsoniter_scala.conversion.given +import io.oath.syntax._ +import io.oath.syntax.all._ +import io.oath.testkit.CodecHelper.unsafeParseJsonToJavaMap +import io.oath.testkit.WordSpecBase -class JsoniterConversionSpec extends AnyWordSpecBase, CodecUtils: +class JsoniterConversionSpec extends WordSpecBase { val verifierConfig = JwtVerifierConfig( Algorithm.none(), - None, ProvidedWithConfig(None, None, Nil), LeewayWindowConfig(None, None, None, None), ) val issuerConfig = JwtIssuerConfig( Algorithm.none(), - None, RegisteredConfig(None, None, Nil, includeJwtIdClaim = false, includeIssueAtClaim = false, None, None), ) - val jwtVerifier = new JwtVerifier(verifierConfig) - val jwtIssuer = new JwtIssuer(issuerConfig) + val jwtVerifier = JwtVerifier(verifierConfig) + val jwtIssuer = JwtIssuer(issuerConfig) - "JsoniterConversion" should: - - "convert jsoniter codec to claims (encoders & decoders)" in: + "JsoniterConversion" should { + "convert jsoniter codec to claims (encoders & decoders)" in { val bar = Bar("bar", 10) val jwt = jwtIssuer.issueJwt(JwtClaims.ClaimsP(bar)).value val claims = jwtVerifier.verifyJwt[Bar](jwt.token.toTokenP).value claims.payload shouldBe bar + } - "convert jsoniter codec to claims decoder and get error" in: + "convert jsoniter codec to claims decoder and get error" in { val fooJson = """{"name":"Hello","age":"not number"}""" val jwt = JWT .create() @@ -46,5 +47,13 @@ class JsoniterConversionSpec extends AnyWordSpecBase, CodecUtils: .sign(Algorithm.none()) val claims = jwtVerifier.verifyJwt[Bar](jwt.toTokenP) - val decodingError: JwtVerifyError = claims.left.value - decodingError.error should startWith("illegal number, offset: 0x00000016, buf:") + claims.left.value shouldBe a[JwtVerifyError.DecodingError] + } + + "convert jsoniter decoder to claims decoder and get error when format is incorrect" in { + val barJson = """{"name":,}""" + + summon[ClaimsDecoder[Bar]].decode(barJson).left.value shouldBe a[JwtVerifyError.DecodingError] + } + } +} diff --git a/oath/macros/src/main/scala/io/oath/macros/OathEnum.scala b/oath/macros/src/main/scala/io/oath/macros/OathEnum.scala new file mode 100644 index 0000000..d2b9c54 --- /dev/null +++ b/oath/macros/src/main/scala/io/oath/macros/OathEnum.scala @@ -0,0 +1,32 @@ +package io.oath.macros + +import scala.annotation.nowarn + +trait OathEnum[E] { + def values: Map[E, String] +} + +object OathEnum { + + inline private def summonSumInstances[ET <: Tuple, E](acc: Set[E]): Set[E] = { + import scala.compiletime.* + + inline erasedValue[ET] match { + case _: EmptyTuple => acc + case _: (t *: ts) => + val enumValue = summonInline[ValueOf[t]].value.asInstanceOf[E] + summonSumInstances[ts, E](acc + enumValue) + } + } + + private def convertUpperCamelToLowerHyphen(str: String): String = + str.split("(?=\\p{Lu})").map(_.trim.toLowerCase).filter(_.nonEmpty).mkString("-").trim + + @nowarn("msg=New anonymous class definition will be duplicated at each inline site") + inline def derived[E](using m: scala.deriving.Mirror.SumOf[E]): OathEnum[E] = + new OathEnum[E] { + def values: Map[E, String] = summonSumInstances[m.MirroredElemTypes, E](Set.empty) + .map(e => e -> convertUpperCamelToLowerHyphen(e.toString)) + .toMap + } +} diff --git a/oath/macros/src/test/scala/io/oath/macros/OathEnumSpec.scala b/oath/macros/src/test/scala/io/oath/macros/OathEnumSpec.scala new file mode 100644 index 0000000..c24eb43 --- /dev/null +++ b/oath/macros/src/test/scala/io/oath/macros/OathEnumSpec.scala @@ -0,0 +1,91 @@ +package io.oath.macros + +import org.scalatest.matchers.should +import org.scalatest.wordspec.AnyWordSpec + +class OathEnumSpec extends AnyWordSpec with should.Matchers { + + enum Foo derives OathEnum { + case Foo1, FooBar, fooBar + } + + sealed trait Bar derives OathEnum + + object Bar { + case object Bar1 extends Bar + case object Bar2 extends Bar + case object Bar3 extends Bar + case object Bar4 extends Bar + } + + sealed abstract class FooBar derives OathEnum + + object FooBar { + case object FooBar1 extends FooBar + case object FooBar2 extends FooBar + case object fooBar3 extends FooBar + } + +// This should fail to compile because sealed class is not supported +// sealed class FooBar derives OathEnum +// +// object FooBar { +// case object Bar1 extends FooBar +// case object Bar4 extends FooBar +// } + +// This should fail to compile because enum contains not product type +// enum FooIllegal derives OathEnum { +// case FooIllegal1(value: String) extends FooIllegal +// case FooIllegal2 extends FooIllegal +// } + +// This should fail to compile because sealed trait contains not product type +// sealed trait BarIllegal derives OathEnum +// object BarIllegal { +// case class BarIllegal1(value: String) extends BarIllegal +// case object BarIllegal2 extends BarIllegal +// case object BarIllegal3 extends BarIllegal +// } + + "OathEnum" when { + ".value" should { + "discover all children directories for Enum" in { + val oathEnum = summon[OathEnum[Foo]] + oathEnum.values should contain theSameElementsAs + Map(Foo.Foo1 -> "foo1", Foo.FooBar -> "foo-bar", Foo.fooBar -> "foo-bar") + } + + "discover all children directories for sealed trait" in { + val children = summon[OathEnum[Bar]] + children.values should contain theSameElementsAs Map( + Bar.Bar1 -> "bar1", + Bar.Bar2 -> "bar2", + Bar.Bar3 -> "bar3", + Bar.Bar4 -> "bar4", + ) + } + + "discover all children directories for sealed abstract class" in { + val children = summon[OathEnum[FooBar]] + children.values should contain theSameElementsAs Map( + FooBar.FooBar1 -> "foo-bar1", + FooBar.FooBar2 -> "foo-bar2", + FooBar.fooBar3 -> "foo-bar3", + ) + } + +// "fail to compile children directories when sealed trait enum contains not enum type" in { +// val children = EnumValues[FooIllegal] +// } +// +// "fail to compile children directories when sealed trait contains not enum type" in { +// val children = EnumValues[BarIllegal] +// } +// +// "fail to compile for sealed abstract class" in { +// val children = EnumValues[FooBarIllegal] +// } + } + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 935df21..a243c4b 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -1,65 +1,39 @@ import sbt.* -import sbt.Keys.* object Dependencies { - object Versions { - val scalaTest = "3.2.19" - val scalaTestPlusCheck = "3.2.17.0" - val scalacheck = "1.17.1" - val javaJWT = "4.4.0" - val config = "1.4.3" - val bcprov = "1.78.1" - val circe = "0.14.7" - val jsoniterScala = "2.27.3" - } - - object Testing { - val scalaTest = "org.scalatest" %% "scalatest" % Versions.scalaTest % Test - val scalaTestPlusCheck = "org.scalatestplus" %% "scalacheck-1-17" % Versions.scalaTestPlusCheck % Test - val scalacheck = "org.scalacheck" %% "scalacheck" % Versions.scalacheck % Test - - val all = Seq(scalaTest, scalaTestPlusCheck, scalacheck) - } - - object Circe { - val core = "io.circe" %% "circe-core" % Versions.circe - val generic = "io.circe" %% "circe-generic" % Versions.circe - val parser = "io.circe" %% "circe-parser" % Versions.circe - - val all = Seq(core, generic, parser) - } - - object JsoniterScala { - val core = "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-core" % Versions.jsoniterScala - val macros = - "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % Versions.jsoniterScala % "provided" - - val all = Seq(core, macros) - } - - object Utils { - val config = "com.typesafe" % "config" % Versions.config - val bcprov = "org.bouncycastle" % "bcprov-jdk18on" % Versions.bcprov - - val all = Seq(config, bcprov) - } - - object Auth0 { - val javaJWT = "com.auth0" % "java-jwt" % Versions.javaJWT - - val all = Seq(javaJWT) - } - - lazy val oathMacros = - libraryDependencies ++= Testing.all - - lazy val oathCore = - libraryDependencies ++= Testing.all ++ Auth0.all ++ Utils.all ++ Circe.all.map(_ % Test) - - lazy val oathCirce = - libraryDependencies ++= Circe.all - - lazy val oathJsoniterScala = - libraryDependencies ++= JsoniterScala.all + private lazy val scalaTestV = "3.2.19" + private lazy val scalaTestPlusCheckV = "3.2.18.0" + private lazy val scalacheckV = "1.18.1" + private lazy val javaJWTV = "4.5.0" + private lazy val configV = "1.4.3" + private lazy val bcprovV = "1.80" + private lazy val circeV = "0.14.10" + private lazy val jsoniterScalaV = "2.33.1" + private lazy val catsV = "2.13.0" + private lazy val tinkV = "1.16.0" + + // Testing + lazy val scalaTest = "org.scalatest" %% "scalatest" % scalaTestV + lazy val scalaTestPlusScalaCheck = "org.scalatestplus" %% "scalacheck-1-17" % scalaTestPlusCheckV + lazy val scalacheck = "org.scalacheck" %% "scalacheck" % scalacheckV + + // Circe + lazy val circeCore = "io.circe" %% "circe-core" % circeV + lazy val circeGeneric = "io.circe" %% "circe-generic" % circeV + lazy val circeParser = "io.circe" %% "circe-parser" % circeV + + // Jsoniter-scala + lazy val jsoniterScalacore = "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-core" % jsoniterScalaV + lazy val jsoniterScalamacros = + "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % jsoniterScalaV % "provided" + + // Typelevel + lazy val catsCore = "org.typelevel" %% "cats-core" % catsV + + lazy val typesafeConfig = "com.typesafe" % "config" % configV + lazy val bcprov = "org.bouncycastle" % "bcprov-jdk18on" % bcprovV + lazy val tink = "com.google.crypto.tink" % "tink" % tinkV + + lazy val javaJWT = "com.auth0" % "java-jwt" % javaJWTV } diff --git a/project/Projects.scala b/project/Projects.scala deleted file mode 100644 index 14180b2..0000000 --- a/project/Projects.scala +++ /dev/null @@ -1,21 +0,0 @@ -import sbt.Keys.* -import sbt.* -import org.scalafmt.sbt.ScalafmtPlugin.autoImport.scalafmtOnCompile -import scalafix.sbt.ScalafixPlugin.autoImport.scalafixOnCompile - -object Projects { - - def createModule(moduleName: String): Project = createModule(moduleName, moduleName) - - def createModule(moduleName: String, fileName: String): Project = - Project(moduleName, base = file(fileName)) - .settings( - Test / fork := true, - run / fork := true, - Test / parallelExecution := false, - scalafmtOnCompile := sys.env.getOrElse("RUN_SCALAFMT_ON_COMPILE", "false").toBoolean, - scalafixOnCompile := sys.env.getOrElse("RUN_SCALAFIX_ON_COMPILE", "false").toBoolean, - semanticdbEnabled := true, - semanticdbVersion := "4.8.15", - ) -} diff --git a/project/build.properties b/project/build.properties index 49214c4..fe69360 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version = 1.9.9 +sbt.version = 1.10.7 diff --git a/project/plugins.sbt b/project/plugins.sbt index 55f1dc6..273d9ba 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.11.1") -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.1.0") -addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.0") -addSbtPlugin("org.typelevel" % "sbt-typelevel-ci-release" % "0.6.7") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.0") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.3.0") +//addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.2") +addSbtPlugin("org.typelevel" % "sbt-typelevel-ci-release" % "0.7.7")